Проектирование Erlang-клиента к memcached

Лев Валкин

Аннотация: memcached — сервис кэширования данных в оперативной памяти компьютера, широко использующийся в популярных интернет-проектах (LiveJournal, Wikipedia, Twitter).

В статье рассказывается о проектировании библиотеки клиентского доступа к экземплярам и кластерам memcached. Производится сравнение с альтернативными реализациями.

Код библиотеки доступен под лицензией BSD.


memcached is an in-memory data cache widely used in popular Internet projects, such as LiveJournal, Wikipedia and Twitter.

The article describes an Erlang client library that facilitates access to memcached instances and clusters. Design and implementation issues of the library are discussed in detail, and a comparison to other open source implementations is provided.

The library source is available under the BSD license.



Обсуждение статьи ведётся по адресу
http://community.livejournal.com/fprog/4486.html.

1  Коротко о memcached

Главу 1 можно пропустить тем, кто уже знаком с memcached, DHT и consistent hashing.


memcached (memory cache daemon, [5]) — это простой кэш-сервер, хранящий произвольные данные исключительно в памяти. Отказ от работы с диском обеспечивает значительную скорость работы и предсказуемость времени отклика. memcached обычно используется в качестве дополнительного уровня кэширования перед какой-либо базой данных, либо в качестве базы данных для простейших задач, не требующих высокой надёжности хранения информации. Memcached широко используется в web-проектах с высокой нагрузкой: LiveJournal, Wikipedia, Twitter и множестве других. Библиотеки доступа к сервису memcached есть для всех распространённых языков программирования.

Запустив программу memcached в фоновом режиме (memcached -d), мы получаем сервис на известном TCP (или UDP: [6]) порту, способный сохранять и отдавать двоичные данные по текстовому ключу. По умолчанию memcached принимает соединения на порту 11211. На рисунке 1 приведён сеанс общения с memcached-сервером с использованием текстового протокола. В примере мы сохранили значение hello world, занимающее одиннадцать байт, под именем someKey и успешно получили обратно это значение, предъявив ключ someKey.


$> memcached -d
$> telnet localhost 11211
Connected to localhost.
get someKey
END
set someKey 0 0 11
hello world
STORED
get someKey
VALUE someKey 0 11
hello world
END
quit
Connection closed by foreign host.
Рис. 1: Пример сеанса работы с memcached в ручном режиме

1.1  Работа с фермой memcached-серверов

В проектах с высокой нагрузкой практически всегда используют не один, а множество memcached сервисов, работающих на разных машинах. Это позволяет использовать под кэш больше оперативной памяти, чем может быть доступно на одной машине, а также повысить отказоустойчивость. Такой набор похожих серверов на сленге называется «фермой» (server farm).

При работе с множеством memcached-серверов для каждой проводимой операции возникает задача выбора сервера, который должен эту операцию обслужить.

Случайный сервер

Если требуется только обновлять значения каких-то счётчиков (например, счётчика числа посещений страницы), то можно просто использовать случайный доступный сервер для каждой операции. На этапе считывания информации мы можем пройтись по всем memcached-серверам, просуммировав значения счётчиков для известных ключей. Заметим, что стандартная реализация memcached не поддерживает операцию «дай мне список всех ключей, которые у тебя есть»).

Хеширование

Случайный выбор сервера подходит для относительно небольшого числа применений. Например, если нам нужно сохранять результаты выполнения «тяжёлых» SQL запросов, то желательно сохранять данные на тот memcached-сервер, где они смогут быть прочитаны следующим же запросом на чтение.

Для поддержки такого режима доступа к набору серверов используется хеширование. На основании ключа запроса высчитывается некое число, с помощью которого выбирается нужный нам сервер. В простейшем варианте используется формула hash(K) modN, где hash(K) — это функция хеширования, создающая число из произвольного (строкового) ключа K, а N — количество доступных серверов memcached. В качестве hash-функции используют алгоритмы SHA-1, MD5 и даже CRC, так как устойчивости к коллизиям от неё не требуется.

В рамках данной схемы возможны два варианта реакции на выход из строя серверов memcached.

В простейшем случае, если сервер, на который указывает вышеупомянутая функция, становится недоступен, то библиотека будет возвращать ошибку на все запросы для данного ключа. Недостатки этого варианта реакции понятны: при недоступности части memcached-серверов пропорциональная часть операций с ними будет вынужденно создавать дополнительную нагрузку на следующий уровень доступа к данным (базу данных и т. п.).

В случае необходимости обеспечения бóльшей доступности фермы серверов, мы можем предложить динамически менять N в зависимости от количества доступных в данный момент серверов. Данный вариант работы хорош ровно до тех пор, пока все memcached-серверы работают бесперебойно. Но как только из-за сетевой ошибки, проблем с питанием или технологических работ на ферме исчезает или появляется один из серверов, функция hash(K) modN мгновенно начинает перенаправлять запросы на другие наборы серверов. Операции с каждым ключом K, которые с помощью формулы f(K) modN перенаправлялись на сервер Sx, при изменении количества доступных серверов N теперь уже будут перенаправляться на сервер Sy. То есть, с высокой вероятностью все виды запросов вдруг станут посылаться не на те серверы, на которые эти запросы посылались ранее. Возникает ситуация, практически эквивалентная перезагрузке всех машин с memcached: система начинает работать с чистого листа, начиная отвечать «нет данных» практически на каждый посланный ей запрос. Для проектов с высокой нагрузкой такая ситуация недопустима, так как она имеет шанс мгновенно «обвалить» резким повышением нагрузки следующие уровни доступа к данным, например, SQL сервер или сторонний веб-сервис.

Устойчивое хеширование

Для того чтобы выход из строя серверов или плановое расширение фермы не приводили к каскадным перегрузкам следующих уровней доступа к данным, применяется схема устойчивого хеширования (consistent hashing, также см. [4]). При использовании устойчивого хеширования область значений hash-функции разбивается на сегменты, каждый из которых ассоциируется с каким-то сервером memcached. Если соответствующий сервер memcached выходит из строя, то все запросы, которые должны были быть адресованы ему, перенаправляются на сервер, ассоциированный с соседним сегментом. Таким образом, выход из строя одного сервера затронет расположение только небольшой части ключей, пропорциональной размеру сегмента (сегментов) области определения хеш-функции, ассоциированной с вышедшим из строя сервером.

Запас прочности фермы memcached-серверов при перераспределении нагрузки обеспечивается как дизайном программы memcached (memcached написан на C и использует достаточно эффективные алгоритмы), так и предварительным планированием нагрузки при построении фермы. На практике нагрузочная устойчивость memcached-ферм редко является узким местом.


Наиболее развитые библиотеки клиентского доступа к memcached работают с фермами memcached-серверов с использованием именно алгоритмов устойчивого хеширования.

Стоит особо отметить, что сам механизм устойчивого хеширования в библиотеках memcached-клиентов не лишен недостатков. Допустим, мы привыкли сохранять значение для ключа K=ПОГОДА на сервере №13. В какой-то момент этот сервер временно выходит из игры (перезагружается), и сегмент области значений hash-функции, которая ранее была ассоциирована с сервером №13, начинает обслуживаться сервером №42. На этот сервер №42 начинают записываться данные вроде ПОГОДА ⇒ ДОЖДИ. Затем сервер №13 возвращается в строй, и на него опять посылаются данные для ключа ПОГОДА, например, ПОГОДА ⇒ СОЛНЕЧНО. Потребители, периодически спрашивающие значение для ключа ПОГОДА, получают ответ СОЛНЕЧНО, возвращаемый с сервера №13, и счастливы. Теперь, допустим, сервер №13 уходит в астрал второй раз. Потребители данных опять начинают ходить на сервер №42 и видят старые данные, ПОГОДА ⇒ ДОЖДИ. Возникает проблема, которую универсально решить не так уж просто.

На практике эту проблему обычно игнорируют, стремясь свести вероятность ее проявления к минимуму путем обеспечения максимальной возможной доступности серверов memcached.

2  Проектирование клиентской библиотеки к memcached

Эта часть статьи описывает дизайн и некоторые детали реализации клиентской библиотеки к memcached, реализованной командой стартапа JS-Kit в 2007 году.

Для обслуживания клиентов мы развивали систему на Эрланге. Так как в системе использовались десятки машин, возникала необходимость распределённого кэширования данных.

В то время свободно доступных клиентских средств для общения с memcached сервисом на Эрланге не было, несмотря на то, что в Эрланг-сообществе то и дело анонсировались новые интернет-проекты с использованием этого языка, а также всевозможные инструменты и библиотеки для веб-разработки.

Нам было необходимо, как минимум, повысить эффективность использования памяти на машинах. Вместо одного «большого» сервера memcached лучше использовать несколько мелких серверов, получая пропорциональное увеличение доступной памяти для хранения временных данных. Тридцать машин, с выделенными четырьмя гигабайтами памяти под memcached-процесс на каждой, дают 120 гигабайт распределённого кэша. Использовать ферму серверов оказывается выгоднее, чем купить или арендовать одну машину с таким количеством памяти.

Однако, при использовании десятков серверов вступает в действие закон больших чисел: перезагрузка или выход из строя серверов становится ежемесячным, если не еженедельным явлением. Соответственно, требовался способ организации кэширования, устойчивый к случайным сбоям серверов.

Рассматривалась возможность использования встроенной в Эрланг распределённой системы управления базами данных Mnesia, но этот вариант в итоге был отброшен.

▷ Первая проблема с Mnesia заключалась в не слишком высокой надёжности кластера из экземпляров Mnesia при высоких нагрузках. При некоторых условиях (например, не слишком длинная, но существенная загрузка машины) кластер может необратимо распасться и рассинхронизироваться. Чтобы избежать необходимости ручного восстановления кластера, требуется организация системы автоматического отслеживания и реконфигурации.

▷ Вторая проблема с Mnesia состояла в том, что её использование для организации большого кэша требует специальной конфигурации. Одна ets-таблица в Mnesia не может превышать размера доступной оперативной памяти. Это значит, что необходимо создать не одну таблицу для кэширования, а несколько, а также обеспечить механизмы распределения обращений к данным в разных таблицах и процедуры расширения пула таблиц при добавлении серверов в кластер.

▷ Третья проблема с Mnesia — в том, что в ней нет встроенных механизмов замещения наименее нужных данных. Такие механизмы необходимы для того, чтобы не выходить за рамки выделенной памяти. Пришлось бы организовывать механизм LRU самостоятельно, то есть брать на себя кодирование того, что уже сделано в memcached.

Обеспечение автоматизации решения этих проблем с Mnesia наверняка превзошло бы по сложности реализацию memcached клиента, рассматриваемого в этой статье.

Принимая решение о реализации клиента для memcached-протокола, мы также получали дополнительные преимущества, не связанные с задачами кэширования данных. Например, стало возможным осуществлять соединение с различными альтернативными базами данных, а также другими сервисами, использующими протокол memcached.

2.1  Требования к библиотеке и предварительные решения

При проектировании библиотеки важно правильно выбрать уровень абстракции интерфейса, который библиотека будет предоставлять приложению.

Доступ к одному или множеству серверов.
Должна ли библиотека работы с memcached просто организовывать взаимодействие с указанным сервером или обслуживать сразу ферму memcached-серверов, используя алгоритм устойчивого хеширования, описанный в предыдущей части статьи? С точки зрения предоставления максимально абстрактного API, мы можем требовать от библиотеки максимум функциональности. То есть, библиотека должна скрывать детали реализации общения с фермой серверов. С другой стороны, идеология отделения механизма от политики подсказывает нам, что системы надо собирать из как можно более ортогональных частей. Одна часть решения может заниматься транспортом данных и управлением соединением с произвольным сервером, а другая — дирижировать множеством таких однонаправленных транспортных сервисов. Каждая часть при этом решает одну задачу, но решает её хорошо. Это разделение хорошо перекликается с функциональным подходом, в котором декомпозиция задачи на простые, ортогональные подзадачи всячески приветствуется. Поэтому уже на этапе проектирования API мы внесли коррективы в наши планы: мы не стали разрабатывать универсальную библиотеку, работающую с memcached фермой, а разбили функциональность на три Эрланг-модуля:
mcd.erl:
модуль, реализующий интерфейс к одному-единственному серверу memcached;
dht_ring.erl:
модуль, реализующий алгоритм устойчивого хеширования для произвольных учётных единиц (в данной статье мы не будем заострять на нём внимание);
mcd_cluster.erl:
модуль для организации устойчивой к сбоям фермы из многих memcached-серверов, соединяющий mcd и dht_ring вместе.
Каждый из компонентов можно отлаживать по-отдельности, а первые два — ещё и использовать независимо друг от друга в разных проектах. При необходимости, в решении можно независимо заменить на альтернативные реализации как транспортный механизм mcd, так и алгоритм устойчивого кэширования dht_ring или способ организации работы множества серверов mcd_cluster.
Использование постоянного соединения.
Для успешного применения в проектах с высокими нагрузками библиотека работы с memcached по TCP должна уметь устанавливать и использовать постоянное соединение с сервером, избегая затрат на установку и разрыв TCP-соединения для каждого запроса. Обеспечением обслуживания постоянного соединения будет заниматься модуль mcd.erl.
Полноценный игрок на поле OTP.
Open Telecom Platform (OTP) — это коллекция библиотек, поведений (behavior) и сопутствующей идиоматики. OTP — интегральная часть дистрибутива Erlang.

Библиотека работы с memcached должна быть подключаема в какой-либо OTP-супервизор — процесс, управляющий запуском дочерних процессов и их перезапуском в случае аварийного завершения. Некоторые библиотеки для доступа к memcached выбирают альтернативный вариант: они оформлены как самостоятельное OTP-приложение (application).

Выбор между этими двумя способами оформления библиотеки является делом вкуса, но сам факт оформленности кода в сущности OTP позволяет обеспечивать отказоустойчивость решения, упрощает отладку, развёртывание кода, обновление версий кода в «горячем» режиме, да и просто является хорошим тоном.

Поддержка именованных наборов memcached-серверов.
Использование функциональности одновременной работы с разными фермами memcached может понадобиться в крупных проектах, где существует какая-то иерархия кэшей или требуется изоляция данных в разных «банках».

Библиотеки, предоставляющие интерфейс типа foobar:get(Key) и foobar:set(KeyValue), не позволят в одном приложении жонглировать данными между разными наборами memcached-серверов. Гораздо лучше, если можно явно указывать, какой сервер или ферму использовать. Отсюда возникает требование явного указания фермы в базовом API: mcd:get(FarmKey), mcd:set(FarmKeyValue).

В Эрланге вся работа делается набором процессов, адресуемых по их идентификаторам (Pid). Аргумент Farm из приведённых примеров — это идентификатор процесса, организующего общение с данной фермой или отдельным memcached-сервером. Эрланг в существенной степени облегчает использование подобных API, давая возможность регистрировать составные идентификаторы процессов, типа <0.7772.267>, под более мнемоничными именами, такими как localCache. Данная возможность приводит использование API к более приличному виду: mcd:get(localCacheKey), mcd:set(remoteFarm1KeyValue). С точки зрения пользователя API нам не важно, где находится localCache или из каких узлов состоит remoteFarm1. Достаточно знать, что процессы под названием localCache и remoteFarm1 были созданы и зарегистрированы при старте системы.

Конструктор из модулей с идентичным API.
Как можно заметить из предыдущих примеров, существует некоторый конфликт между заявленной для модуля mcd.erl функциональностью (общение с единственным memcached-сервером) и тем, как применяется API в примерах выше (возможность использовать ферму в вызовах функций, например mcd:set(remoteFarm1KeyValue)). Этот конфликт неслучаен. Мы хотим, чтобы с точки зрения API не существовало разницы между вызовом операции с единственным сервером и вызовом операции с фермой серверов. Это различие несущественно, чтобы поднимать его на уровень API: сегодня пользователю хочется использовать один memcached-сервер, а завтра — десять, но код должен оставаться идентичным, с точностью до имени процесса, ответственного за результат.

Потому мы сразу проектируем систему так, чтобы mcd_cluster являлся простым транслятором сообщений в нужный экземпляр mcd. С точки зрения пользователя API, mcd_cluster вызывается только один раз из супервизора для организации фермы:

mcd_cluster:start_link(remoteFarm1, [["server1.startup.tld", 11211], ["server2.startup.tld", 11211], ["server3.startup.tld", 11211]]).

и в дальнейшем общение с фермой идёт через API модуля mcd.

Почему это хорошо? Наш get/set API не зависит от конфигурации фермы и использующейся функциональности. Программист прикладного уровня имеет только один API. Мы можем использовать mcd отдельно от mcd_cluster. Мы можем сменить конфигурацию кэша, основанного на mcd, на более тяжеловесную (с точки зрения количества взаимодействующих частей) конфигурацию фермы, просто изменив способ инициализации с mcd:start_link(NameAddress) на mcd_cluster:start_link(Name, [Address]).

Почему это плохо? С точки зрения первоначального изучения кода, да и последующей отладки, использование API модуля mcd для посылки сообщений процессу, порождённому mcd_cluster (стрелка 1 на рисунке 2), может показаться неочевидным. Ведь этот процесс просто обеспечивает диспетчеризацию сообщений процессу, стартовавшему как экземпляр функциональности mcd (стрелка 6).


Рис. 2: Передача сообщений одному из серверов фермы, адресуемой через mcd_cluster

Мы постараемся сделать слой абстракции, предоставляемый модулем mcd_cluster, очень тонким, чтобы этот пинг-понг между модулями не мешал даже беглому изучению кода. Рисунок 2 поясняет, как именно происходит переброс сообщений:

  • API, предоставляемый модулем mcd.erl (например, mcd:get/2 mcd:set/3), формирует запрос к процессу, указанному в первом аргументе (mcd:get(remoteFarm1, ...), стрелка 1). В случае организации общения с набором memcached-серверов с помощью mcd_cluster, этим процессом будет процесс, обслуживаемый кодом mcd_cluster.erl.
  • Код mcd_cluster.erl, пользуясь функциональностью библиотеки устойчивого хеширования dht_ring (2…5), пересылает запрос одному из процессов mcd, связанному с конкретным memcached-сервером (6).
  • Обработав запрос, mcd-процесс возвращает результат (7), завершая выполнение исходного вызова API.
Поддержка нативной модели данных.
API, предоставляемый библиотекой, должен обеспечивать возможность работы с произвольными данными. Такой подход типичен для библиотек на Эрланге. Так, мы должны иметь возможность сохранить произвольную структуру данных Erlang в memcached и вытащить её в неизменном виде. Библиотеки, предоставляющие интерфейс только к сохранению бинарных объектов, вынуждают пользователя использовать term_to_binary/1 и binary_to_term/1 на уровне приложения, навязывая недостаточно абстрактный, на наш взгляд, интерфейс. Сравним подобный интерфейс с гибкостью API, позволяющего использовать Erlang API на всю катушку (демонстрируется использование составных ключей, автоматическую сериализацию объектов и сохранение замыканий в memcached):
1> mcd:set(myCache, [42, "string"], {"weather", fun() -> "normal" end}). {ok,{"weather",#Fun<erl_eval.20.67289768>}} 2> {ok, {_, Fun}} = mcd:get(myCache, [42, "string"]). {ok,{"weather",#Fun<erl_eval.20.67289768>}} 3> Fun. #Fun<erl_eval.20.67289768> 4> Fun(). "normal" 5>
Асинхронность обработки запросов.
Библиотека mcd должна максимально использовать асинхронность. Она должна иметь возможность одновременно передавать сразу несколько независимых запросов на сервер memcached. Даже на задержках локальной сети (менее 1 миллисекунды) имеет смысл не дожидаться ответа на предыдущий запрос перед посылкой следующего. Должен существовать способ сопоставить клиенту, запросившему у mcd ту или иную операцию, предназначенный именно ему ответ от memcached.

В разделе 3.4 мы покажем, как это требование влияет на производительность библиотеки в реальных условиях.

2.2  Реализация mcd.erl

mcd.erl — модуль, который организует запуск Erlang-процессов, умеющих общаться с memcached-сервером по указанному при запуске адресу.

2.2.1  От рекурсивного обработчика к OTP-«поведению»

Одно из перечисленных выше требований к клиентской библиотеке — использование постоянного соединения с memcached. Мы должны организовать процесс так, чтобы он постоянно имел доступ к дескриптору TCP-соединения с memcached. В нижеприведенном фрагменте псевдокода показано, как можно организовать простой долгоживущий процесс, управляющий каким-то TCP-каналом.

-module(mcd_example). -export([get/2, start_link/2]). get(ServerRef, Key) -> Ref = make_ref(), ServerRef ! {get, self(), Ref, Key}, receive {response, Ref, Resp} -> Resp after 5000 -> {error, timeout} end. start_link(Address, Port) -> {ok, TcpSocket} = gen_tcp:connect(Address, Port), {ok, spawn_link(?MODULE, memcached_client, [TcpSocket])}. memcached_client(TcpSocket) -> receive {get, From, Ref, Key} -> From ! {response, Ref, askServerGet(TcpSocket, Key)} memcached_client(TcpSocket); {tcp_closed, TcpSocket} -> ok. end.

Этот псевдокод почти рабочий — не хватает только функции askServerGet.

При вызове spawn_link/3 запускается параллельный процесс, выполняющий функцию memcached_client/1. Последняя имеет обязательный аргумент — дескриптор TCP-канала, — дающий возможность процессу в любой момент послать или принять по данному каналу сообщение.

Так как мы проектируем промышленную библиотеку, а не «наколенную» поделку, здесь необходимо уйти от ручного кодирования рекурсивных функций и перейти к использованию готовых инструментов и идиом, доступных в Erlang OTP. Для данного процесса необходимо использовать поведение gen_server.

Кроме того, что gen_server скрывает детали организации рекурсивного цикла, он также придаёт процессу, порождённому с его помощью, ряд свойств, делающих его полноценным членом иерархии процессов в OTP.

Вот практически эквивалентный, но более идиоматичный вариант предыдущего кода, переписанный с использованием поведения gen_server.

-module(mcd). -behavior(gen_server). -compile(export_all). get(ServerRef, Key) -> gen_server:call(ServerRef, {get, Key}). start_link(Address, Port) -> gen_server:start_link(?MODULE, [Address, Port], []). -record(mcdState, { socket }). init([Address, Port]) -> {ok, TcpSocket} = gen_tcp:connect(Address, Port), {ok, #mcdState{ socket = TcpSocket }}. handle_call({get, Key}, _From, State) -> Socket = State#mcdState.socket, {reply, askServerGet(Socket, Key), State}. handle_cast(_, State) -> {noreply, State}. handle_info({tcp_closed, Sock}, #mcdState{ socket = Sock } = State) -> {stop, normal, State}.

Поведение gen_server берёт на себя организацию процесса, исполняющего рекурсивный цикл обработки приходящих к нему сообщений. При получении сообщения, посланного функцией gen_server:call/2,3, этот цикл вызывает функцию handle_call/3. Результат выполнения handle_call/3 будет послан назад тому, кто запросил данные. При получении сообщения, посланного функцией gen_server:cast/2, этот цикл вызывает функцию handle_cast/2. При получении сообщения, которое было послано иным способом (например, через примитив Pid ! Message), рекурсивный цикл вызовет функцию handle_info/2. Кроме этого, рекурсивный цикл, организованный поведением gen_server, самостоятельно отвечает на некоторые системные сообщения, обеспечивая нашему процессу улучшенное время взаимодействия с остальными модулями в OTP.

Ещё отметим, что поведение gen_server даёт нам примитив gen_server:call/2,3, реализующий надёжный механизм IPC. Встроенная в язык возможность послать сообщение через Pid ! Message по многим причинам не является надёжным механизмом для обмена данными. Даже тот трюк с make_ref(), изображённый в псевдокоде на странице ??, не лишён недостатков. Этот трюк защищает от дупликатов сообщений: если функция get/2 вернула {errortimeout}, и её вызвали заново с другим аргументом, она может вернуть предыдущий ответ. Налицо проблема гонок (race condition) в очерёдности событий «сообщение послали», «сообщение приняли», «наступило превышение времени ожидания».

С наивно организованным на основе конструкции Pid ! Message обменом сообщениями часто бывает и другая проблема. К примеру, показанная на странице ?? реализация get/2 не сможет определить, когда процесс ServerRef уже умер, и при вызовах будет завершаться через тайм-аут, а не мгновенно, как это следовало бы делать. Использование gen_server:call/2,3 избавляет нас от этой и многих других проблем.


Если вы ещё не используете поведение gen_server в своих программах, самое время начать это делать.

2.2.2  Восстановление после ошибок соединения

Что происходит, когда сервер memcached прекращает своё существование или становится недоступен по иной причине? По идее, нам нужно каким-то образом реагировать на закрытие канала TcpSocket. Вариантов всего два. Либо при получении сообщения {tcp_closed_} мы тут же завершаем работу процесса (возврат {stop, ...} в обработчике сообщения handle_* провоцирует gen_server на завершение всего процесса), либо пытаемся подключиться к серверу вновь и вновь, возвращая в это время клиентам что-нибудь вроде {errornoconn}.

Оба способа обладают своими достоинствами и недостатками.

Завершая процесс в случае сбоя сервера, мы следуем мантре Erlang’а «let it fail» («пусть падает») в расчёте на то, что процесс этот запущен в вышестоящем супервизоре, и супервизор перезапустит его. Это достаточно удобно с точки зрения логики программы: не нужно делать никаких дополнительных шагов для обеспечения надёжности и защиты от сбоев. Если наш процесс умирает, его кто-то запускает заново. С другой стороны, если сервис memcached умирает надолго, а mcd был запущен каким-то стандартным супервизором (функциональностью стандартного модуля supervisor), то мы, часто перезапускаясь, рискуем убить себя, супервизор и всю связанную с ним иерархию процессов. Особенностью поведения стандартного супервизора является то, что он следит за количеством перезапусков подчинённых ему процессов в единицу времени и может завершить себя и остальные процессы в его подчинении, если поведение подчинённого процесса выглядит для супервизора похожим на бесконечный цикл.

Для того чтобы иметь возможность запускать mcd под стандартным супервизором и не опасаться периодической недоступности memcached-серверов, нам всё-таки придётся обеспечивать логику переподключения к сбойнувшему серверу, добавив поле status в наше «глобальное» состояние. Чтобы знать, к чему же именно нам нужно переподключаться, необходимо иметь адрес и порт в #mcdState{}:

-record(mcdState, { address = "localhost", port = 11211, socket = nosocket, % Connection state: % disabled | ready % | {connecting, Since, ConnPid} % | {testing, Since} % protocol compatibility % | {wait, Since} % waiting between reconnects status = disabled }).

При некоторых видах выхода из строя memcached-серверов последующие попытки подсоединения к ним будут приводить к длительному ожиданию соединения. Функция gen_tcp:connect/3, используемая для переподключения к TCP серверу, синхронна. Подразумевается, что асинхронность, при необходимости, должна обеспечиваться средствами языка (порождением отдельного процесса для ожидания соединения), а не расширенными средствами библиотеки gen_tcp (например, путём предоставления асинхронного режима работы с TCP-каналами). Соответственно, имеет смысл осуществлять ожидание соединения в отдельном процессе, а затем передавать результат ожидания процессу mcd. Время начала попытки соединения сохраняем в Since, а идентификатор процесса, инициирующего соединение, хранится в ConnPid. Получив сообщение от ConnPid об успешно завершённом соединении, мы должны начать тестирование memcached-протокола, чтобы понять, что memcached-сервер действительно доступен и готов к работе (status меняется на {testingSince}). При неуспешном соединении или результате тестирования протокола, mcd переходит в режим {waitSince}, ждёт от нескольких миллисекунд до нескольких десятков секунд (в зависимости от количества проведённых ранее неуспешных попыток), а затем повторяет попытку соединения.

2.2.3  Поддержка асинхронной работы с запросами и ответами

В разделе 2.1 было упомянуто, что mcd должен работать по возможности асинхронно, чтобы избежать ненужной зависимости от сетевых задержек. Это значит, что необходимо иметь очередь посланных на memcached-сервер запросов. Очередь должна содержать идентификаторы процессов, сделавших запросы, чтобы пришедший ответ можно было направить тому, кто его ждет.

Представим на минуту протокол memcached, разработанный специально для Эрланг-программистов. В нём была бы возможность указать произвольный идентификатор транзакции, в качестве которого разработчики клиентов к memcached использовали бы обратный адрес процесса, которому нужно отправить ответ по результатам этой транзакции. Это обеспечило бы отсутствие необходимости запоминать что, для кого и в какой именно последовательности мы послали на memcached-сервер: он бы сам сообщал, что делать с ответом.

Так как протокол доступа к memcached не содержит возможности назначить произвольный идентификатор транзакции для каждой операции, то нам необходима именно последовательная очередь «квитков» на транзакции, requestQueue.

-record(mcdState, { address = "localhost", port = 11211, socket = nosocket, % Queue for matching responses with requests requestQueue, status = disabled }).

Приход очередного ответа от memcached-сервера инициирует извлечение самого старого квитка из очереди и отправку этого ответа процессу, обозначенному в квитке. Проблема теперь только в том, что протокол memcached достаточно нерегулярный, и вычленить данные из ответа сервера не так-то просто. Например, на запрос типа stats memcached посылает набор строк, оканчивающихся строкой «END». На запрос get memcached посылает строку «VALUE», имеющую в своём составе длину двоичного ответа, затем сам потенциально двоичный ответ (содержащий произвольные символы, в том числе переводы строк), а затем завершает это строкой «END». Но когда мы посылаем запрос типа incr или decr, memcached отвечает просто целым числом на отдельной строке, без завершающего «END».

Чтобы правильно интерпретировать этот протокольный бардак в асинхронном режиме, необходимо в квитке хранить информацию о том, как проинтерпретировать соответствующее сообщение. «Тип» квитка таков: {{pid(), ref()}, atom()}. Ранее в статье было показано, почему одного только pid() недостаточно, чтобы надёжно вернуть результат запроса тому, кто его запросил. Тип {pid(), ref()} описывает пару из идентификатора процесса, который запрашивает у mcd данные, и уникального идентификатора самого запроса. Именно эта пара приходит в качестве аргумента From в функцию handle_call(QueryFromState) поведения gen_server Значение atom() является внутренним идентификатором, позволяющим при подготовке к разбору очередного ответа от memcached понять, какого именно формата данные следует ожидать.

Таким образом, квиток содержит две сущности: информацию о том, как интерпретировать ответ от memcached, и информацию о том, куда посылать проинтерпретированный ответ. Вот пример нескольких квитков, лежащих последовательно в очереди requestQueue:

{{<0.21158.282>,#Ref<0.0.164.61572>},rtVersion}. {{<0.22486.282>,#Ref<0.0.164.62510>},rtDelete}. {{<0.23511.282>,#Ref<0.0.164.65512>},rtFlush}.

Когда наступает время обработать очередной ответ от сервера, информация из квитка используется для построения дожидания1, которое будет способно правильно интерпретировать ответ, даже если он придёт от сервера не целиком, а в несколько приёмов. Дожидание — это просто функция, которая на основании аргумента (данных из TCP-канала) возвращает новую функцию, которая сможет продолжить интерпретацию ответа, если изначально данных для завершения интерпретации не хватило. Если сервер возвращает ответ, разбитый на множество пакетов, использование дожиданий позволяет простым образом обработать их, не блокируя основной поток процесса необходимостью синхронного дочитывания данных.

Конструирование сложных функций с помощью комбинаторов — приём функционального программирования, который делает эту часть реализации mcd интересной. Ниже показана функция expectationByRequestType/1, которая «собирает» дожидание для приходящих из memcached данных.

expectationByRequestType(rtVersion) -> mkExpectKeyValue("VERSION"); expectationByRequestType(rtGet) -> mkAny([mkExpectResponse(<<"END\r\n">>, {error, notfound}), mkExpectValue()]); expectationByRequestType(rtDelete) -> mkAny([mkExpectResponse(<<"DELETED\r\n">>, {ok, deleted}), mkExpectResponse(<<"NOT_FOUND\r\n">>, {error, notfound})]); expectationByRequestType(rtGenericCmd) -> mkAny([mkExpectResponse(<<"STORED\r\n">>, {ok, stored}), mkExpectResponse(<<"NOT_STORED\r\n">>, {error, notstored})]); expectationByRequestType(rtFlush) -> mkExpectResponse(<<"OK\r\n">>, {ok, flushed}).

Функция expectationByRequestType/1 использует комбинатор mkAny/1 и ряд других функций, каждая из которых умеет принимать аргумент определённого вида. Комбинатор mkAny/1 конструирует «сложную» функцию из передаваемого ему списка примитивных действий. Возвращаемая комбинатором функция при вызове пробует вызвать каждую из примитивных функций последовательно и возвращает ответ от первой из них, завершившейся без ошибок.

mkAny(RespFuns) -> fun (Data) -> mkAnyF(RespFuns, Data, unexpected) end. mkAnyF([], _Data, Error) -> { error, Error }; mkAnyF([RespFun|Rs], Data, _Error) -> case RespFun(Data) of { error, notfound } -> {error, notfound}; { error, Reason } -> mkAnyF(Rs, Data, Reason); Other -> Other end.

Рассмотрим частный случай функции expectationByRequestType/1. В данном случае комбинатор mkAny/1 используется для того, чтобы создать функцию, которая будет ожидать от memcached-сервера либо строку «DELETED», либо строку «NOT_FOUND» и возвращать, соответственно, либо {okdeleted}, либо {errornotfound}:

expectationByRequestType(rtDelete) -> mkAny([mkExpectResponse(<<"DELETED\r\n">>, {ok, deleted}), mkExpectResponse(<<"NOT_FOUND\r\n">>, {error, notfound})]); mkExpectResponse(Bin, Response) -> fun (Data) when Bin == Data -> Response; (_Data) -> {error, unexpected} end.

Для понимания того, что написано ниже, примем условие, что данные из TCP-канала приходят уже разбитыми на строки. Это обеспечивается включением режима (фильтра) {packetline} при установке соединения с memcached-сервером: gen_server:connect("localhost", 11211, [{packetline}, binary]).

Функция mkExpectResponse/2 конструирует очень простое дожидание, которое возвращает позитивный или негативный ответ по первому же вызову.

Более интересным конструктором дожиданий является функция mkExpectValue(). Дожидание, сконструированное функцией mkExpectValue(), работает в двух фазах. В первой фазе пришедшая от memcached строка интерпретируется в соответствии с протоколом: из строки вида «VALUE key flags valueSize» (см. рис. 3) выясняется размер данных, которое необходимо будет вычленить из ответа memcached.


$> telnet localhost 11211
Connected to localhost.
get someKey
VALUE someKey 0 11
hello world
END
quit
Connection closed by foreign host.
Рис. 3: Пример ответа memcached одиннадцатью байтами строки «hello world»

Ответ от memcached приходит в несколько приёмов: после обработки первой строки ответа дожидание переходит во вторую фазу своей жизни — фазу накопления данных. В этой фазе дожидание, отработав, возвращает следующее дожидание, и т. д., раз за разом принимая очередные сегменты данных, пока размер принятых данных не станет соответствовать продекларированному memcached-сервером в первой строке ответа.

mkExpectValue() -> fun (MemcachedData) -> Tokens = string:tokens(binary_to_list(MemcachedData), " \r\n"), case Tokens of ["VALUE", _Key, _Flags, BytesString] -> Bytes = list_to_integer(BytesString), { more, mkExpectBody([], Bytes, Bytes+2) }; _ -> {error, unexpected} end end. mkExpectBody(DataAccumulator, Bytes, ToGo) -> fun(MemcachedData) -> DataSize = iolist_size(MemcachedData), if DataSize < ToGo -> { more, mkExpectBody( [MemcachedData | DataAccumulator], Bytes, ToGo - DataSize) }; DataSize == ToGo -> I = lists:reverse(DataAccumulator, [Data]), B = iolist_to_binary(I), V = binary_to_term(B), { more, mkExpectResponse(<<"END\r\n">>, { ok, V })} end end.

При приёме длинного ответа основная работа происходит внутри дожидания и его наследников, рекурсивно порождаемых функцией mkExpectBody/3. В процессе работы дожидание накапливает принятые данные в своём первом аргументе-аккумуляторе, чтобы, приняв финальный блок данных от memcached, вернуть все накопленные данные.

2.2.4  Хранение дожидания в состоянии mcd процесса

Когда мы находимся в процессе дожидания большого ответа, состоящего из множества пакетов, нужно где-то хранить текущую функцию дожидания. Для этого ещё раз преобразуем структуру состояния процесса, добавив туда поле expectation:

-record(mcdState, { address = "localhost", port = 11211, socket = nosocket, requestQueue, expectation = no_expectation, status = disabled }).

Теперь обработка ответов от memcached сводится к запуску дожидания при получении очередных данных из канала #mcdState.socket:

handle_info({tcp, Socket, Data}, #state{socket = Socket} = State) -> {noreply, handleMemcachedServerResponse(Data, State)}. handleMemcachedServerResponse(Data, #state{requestQueue = RequestQueue, expectation = OldExpectation} = State) -> {From, Expectation} = case OldExpectation of no_expectation -> {Requestor, RequestType} = dequeue(RequestQ), ExpectFun = expectationByRequestType(RequestType), {Requestor, ExpectFun} ExistingExpectation -> ExistingExpectation end, NewExpectation = case Expectation(Data) of {more, NewExp} -> {From, NewExp}; Result -> replyBack(From, Result), no_expectation end, State#mcdState{expectation = {From, NewExpectation}}.

Сообщения о приёме новых данных запускают очередной шаг текущего дожидания. Между сообщениями о приёме новых данных наш mcd-процесс может без задержки реагировать на произвольные сообщения, например, отвечать на запросы о статусе соединения с сервером memcached, количестве проведённых через mcd запросов и ответов, и других метриках, которые не требуют общения с memcached-сервером через TCP-канал. Этот положительный артефакт обработки событий в асинхронном режиме с использованием механизма дожиданий используется в коде mcd.erl. Так, он позволяет быстро ответить клиенту {errortimeout}, если у нас в очереди по каким-либо причинам скопилось более тысячи неотвеченных сообщений.

2.2.5  Альтернатива дожиданиям

Описанному выше механизму дожиданий существует пара альтернатив.

Первая заключается в отказе от асинхронной обработки сообщений. Такой выбор сделан в реализации Erlang-клиента erlmc [1], за счёт чего соответствующая часть кода читается значительно проще:

send_recv(TcpSocket, Request) -> ok = send(TcpSocket, Request), recv(TcpSocket).

Эффект подобного отказа от асинхронной обработки сообщений будет рассмотрен далее в разделе 3.4.


Вторая альтернатива заключается разбиении mcd-процесса на два. Один процесс (назовём его Sender) занимается посылкой запросов memcached-серверу. При получении запроса на осуществление операции, этот процесс формирует соответствующий пакет и посылает его в канал связи с memcached-сервером. Второй процесс (Receiver) независимо занимается приёмом ответов от memcached-сервера.

Как было показано на странице ??, при использовании текстового протокола memcached перед приёмом данных от сервера нам необходимо знать структуру получаемого ответа. Значит, между Sender и Receiver необходима коммуникация. Так, Sender, посылая запрос memcached-серверу, должен сообщить процессу Receiver структуру сообщения, которое Receiver получит от memcached-сервера в ответ. С точки зрения Receiver это можно представить примерно следующим образом:

processReceiver_loop(TcpSocket) -> {From, RequestType} = receive {operation, RequestTicket} -> RequestTicket end, Response = assembleResponse(TcpSocket, MessageType), gen_server:reply(From, Response), processReceiver_loop(TcpSocket).

Для вычитывания нужного объема данных из TCP-канала связи с memcached-сервером в функции assembleResponse/2 можно использовать gen_tcp:recv/2. Если общение с memcached происходит с помощью текстового протокола, то для облегчения синтаксического анализа ответа можно воспользоваться возможностью gen_tcp динамически менять канальные фильтры.

Например, если необходимо вычитать одну строку ответа (содержащую заголовочную информацию), а затем какое-то количество двоичных данных, можно делать так:

% Parses "VALUE someKey 0 11\r\nhello world\r\nEND\r\nNextAnswer..." assembleResponse(TcpSocket, rtGet) -> ok = inet:setopts(TcpSocket, [{packet, line},list]), {ok, HeaderLine} = gen_tcp:recv(TcpSocket, 0), ok = inet:setopts(TcpSocket, [{packet, raw},binary]). case string:tokens(HeaderLine, " \r\n") of ["END"] -> undefined; ["VALUE", _Value, _Flag, DataSizeStr] -> Size = list_to_integer(DataSizeStr), {ok, Data} = gen_tcp:recv(TcpSocket, Size), {ok, <<"\r\nEND\r\n">>} = gen_tcp:recv(TcpSocket, 7), Data end.

Эффективность этой альтернативы по сравнению с использованием дожиданий выглядит существенной и заслуживает отдельных тестов. Недостатками являются невозможность запросить состояние клиента, пока он находится в процессе приёма ответа от memcached-сервера, а также некоторая потеря декларативности в описании протокола. Достаточно сравнить вышеприведённый код функции assembleResponse(TcpSocketrtGet) с рассмотреным на странице ?? вариантом expectationByRequestType(rtGet).

3  Сравнения и тесты

3.1  Двоичный протокол

Начиная с версии 1.3, создатели memcached-сервера внедрили в него поддержку двоичного протокола, обладающего некоторыми дополнительными возможностями. Значимость старого протокола при этом не исчезает: текстовый протокол остаётся для обеспечения обратной совместимости со старыми клиентами. Текстовый протокол memcached также используется вне связи с memcached сервером: в частности, существуют простые системы управления базами данных и системы организации очередей, использующие memcached-протокол, что позволяет им быть совместимыми с большим количеством клиентов, написанных на разных языках.

В августе 2009 года2 двоичный протокол стало возможным применять и в асинхронном режиме.

Логично предположить, что двоичный протокол в асинхронном режиме даст большую производительность, чем текстовый протокол в асинхронном режиме. Но на момент тестирования существовала только одна библиотека, написанная целиком на Эрланге — erlmc [1] — поддерживающая двоичный протокол memcached, и она работала синхронно.

За несколько дней до выхода данной статьи появилась библиотека echou/memcached-client [3], поддерживающая двоичный протокол в асинхронном режиме. К сожалению, библиотека вышла несколько сырая, только что «из-под пера». Запустить и протестировать её на скорость работы до выхода этого номера журнала мы не успели.

Интересно, что в конце 2009 года для Эрланга появилось сразу несколько новых клиентских библиотек работы с memcached.

3.2  Библиотеки работы с memcached

erlangmc
Erlang-библиотека erlangmc, которая вполне может считаться использующей двоичный протокол, является простой надстройкой над C-библиотекой libmemcached. Её автор, открыв код, сделал выбор в пользу необычной для Эрланг-сообщества лицензии GPLv3.
cacherl::memcached_client
Порт функциональности memcached-сервера на Erlang, cacherl, содержит реализацию функциональности memcached-клиента. Клиентский код использует текстовый протокол и работает в синхронном режиме. Клиент умеет работать с несколькими серверами memcached, но использует простую схему хеширования, неустойчивую к отказам memcached-серверов. Код cacherl доступен под лицензией LGPL.
joewilliams/merle
Библиотека merle, написанная Joe Williams и Nick Gerakines, обеспечивает интерфейс к указанному в аргументе серверу memcached. Она использует текстовый протокол memcached и работает в синхронном режиме. Библиотека доступна по лицензии MIT.
higepon/memcached-client
В декабре 2009 на GitHub появилась библиотека работы с memcached сервером higepon/memcached-client, написанная Taro Minowa. higepon/memcached-client использует текстовый протокол в синхронном режиме и организует работу только с одним сервером memcached. Код доступен под лицензией BSD.
echou/memcached-client
Также в декабре 2009 на GitHub появилась другая библиотека, с похожим названием echou/memcached-client, написанная Zhou Li [3]. Этот очень развитый проект обладает набором полезных возможностей:
  • работа с множеством именованных ферм memcached-серверов;
  • автоматическое переподключение индивидуальных соединений с memcached серверами после разрыва связи;
  • поддержка бинарного протокола (текстовый не поддерживается);
  • поддержка асинхронной обработки команд.
Библиотека echou/memcached-client комбинирует код на Эрланге с с библиотеками и драйверами на C и доступна под лицензией Apache.
JacobVorreuter/erlmc
Библиотека erlmc, выпущенная в октябре 2009 года, общается с memcached-сервером посредством двоичного протокола. erlmc позволяет работать с несколькими memcached-серверами, используя устойчивое хеширование для распределения операций между ними. Библиотека доступна под лицензией MIT.

Несмотря на то, что дизайн библиотеки erlmc существенно отличается от описываемого в данной статье, имело смысл сравнить mcd именно с ней, так как на момент написания статьи именно эти две полностью написанные на Erlang библиотеки предоставляли наиболее развитые возможности (например, устойчивое хеширование и работу с фермой memcached cерверов) по сравнению с немногочисленными другими проектами.

3.3  Сравнение с erlmc

Jacob Vorreuter выпустил erlmc в октябре 2009 года.

erlmc имеет простой API с набором вызовов, отражающим набор операций, доступных в memcached-протоколе.

Наборы memcached-серверов

Интерфейс erlmc позволяет обеспечить доступ только к одному набору memcached-серверов. Это не слишком серьёзная проблема для небольших проектов, в которых, как правило, принято использовать единственный memcached-сервер, либо одну ферму.

Интерфейс mcd даёт возможность использовать произвольное количество именованных наборов серверов memcached.

Поддержка встроенных типов данных

erlmc требует и возвращает двоичные данные в качестве значения для ключа.

mcd позволяет сохранять и восстанавливать произвольные структуры данных, автоматически вызывая term_to_binary/1 и binary_to_term/1.

Реакция на отсутствие данных

erlmc возвращает двоичный объект нулевой длины, если memcached сервер не нашёл значения, ассоциированного с данным ключом.

mcd возвращает {okany()} при наличии данных в memcached-сервере и {errornotfound} при отсутствии. Это позволяет на уровне приложения отличить отсутствие данных от данных нулевой длины.

Размер и тип ключа

erlmc позволяет использовать ключи нескольких распространённых типов, но имеет ограничение на размер ключа в 64 килобайта.

mcd не имеет ограничений на размер или тип ключа, так как ключом является результат md5-хеширования двоичного представления Эрланг-структуры. Это может оказаться неудобным, так как отсутствует простая возможность запросить данные, сохранённые в memcached, посредством telnet или из программы, написанной на другом языке программирования.

Версия протокола

erlmc общается с memcached только с помощью двоичного протокола. Это позволяет использовать простой код, так как при разработке двоичного протокола создатели memcached учли ошибки проектирования его текстового эквивалента и сделали протокол достаточно регулярным. С другой стороны, по этой же причине erlmc не может быть использован в качестве клиента к другим программам, реализующим текстовый вариант memcached-протокола.

mcd использует более сложный в обработке, но более совместимый текстовый протокол.

Тип хеширования

erlmc умеет распределять запросы между несколькими memcached-серверами, используя устойчивое хеширование, которое мы рассмотрели на странице ??.

Интерфейс mcd_cluster предоставляет такую же возможность.

Способы инициализации memcached-фермы в библиотеках erlmc и mcd_cluster практически идентичны.

Реакция на ошибки соединения

При использовании erlmc, если в ферме memcached-серверов выходит из строя один сервер, то часть запросов, адресуемая этому серверу, начинает возвращать исключения. Это может являться адекватным решением проблемы «свежести» данных, описанной на странице ??, в разделе об устойчивом хешировании.

Реализация mcd_cluster перераспределяет запросы от неисправного сервера другим, в соответствии с правилами устойчивого хеширования. Таким образом, mcd_cluster может произвольное время работать с частично неисправной фермой, не создавая нагрузки на следующий уровень доступа к данным.

Работа над ошибками

erlmc, обнаружив потерю соединения с каким-то из memcached-серверов, не делает автоматических попыток подключиться к нему вновь. В перспективе возможна ситуация, когда перезагрузки отдельных серверов в профилактических или иных целях оставят erlmc совершенно без возможности совершать полезную работу.

mcd автоматически осуществляет попытки переподключения к «упавшим» memcached-серверам.

3.4  Скорость доступа к данным

3.4.1  Производительность в LAN

Все тесты, результаты которых представлены в этой части статьи были проведены между двумя одноядерными виртуализованными машинами, предоставляемыми Amazon EC2 (Small instance).

Даже в случае, когда ферма memcached-серверов находится в той же локальной сети, что и обращающийся к ней клиент, задержки на канале связи могут влиять на производительность memcached библиотеки. Так, типичные задержки в Ethernet LAN составляют 0.2 миллисекунд и более, особенно под нагрузкой. Это значит, что в случае синхронных запросов мы будем ограничены сверху величиной в 5000 запросов в секунду в идеальном случае.

И действительно, простой цикл получения небольших объёмов данных через интерфейсы mcd и erlmc показывает среднюю величину в 3567 запросов в секунду. Это соответствует средней задержке в сети 0.28 миллисекунд.

Но стоит запустить несколько независимых эрланг-процессов, которые запрашивают данные через один и тот же экземпляр процесса mcd, как тут же картина преображается.

В таблице 1 показан результат ста тысяч прогонов следующей функции:

1> mcd:get(web7, a). {ok, {a,b,{[c,d,e],"some string",12324234234,<<"binary">>}}} 2>

Попытка1 поток2 потока410100
1)3764430576221234018105
2)3668409575491233217279
3)3683379672751161217487
Таблица 1: Попытка общения с mcd в несколько потоков


Попытка1 поток2 потока410100
1)35053752377436573536
2)32183086355833033633
3)35673545355636213405
Таблица 2: Попытка общения с erlmc в несколько потоков

В таблице 2 показан результат ста тысяч прогонов функции erlmc:get(a). memcached-сервер в этом случае хранит и отдаёт данные того же размера, что и в примере с mcd.

Наглядно разница показана на рисунке 4. По вертикали отложены значения количества запросов в секунду. На горизонтальной оси отмечено количество параллельных тестирующих процессов, нагружающих mcd или erlmc, соответственно.


Рис. 4: Сравнение скорости mcd и erlmc на 64 байтах данных

Что произойдёт, если мы попробуем брать из memcached-сервера не 64 байта данных, а больше? Для 10 килобайт данных тестирование с использованием параллелизма показывает похожую разницу в результатах. Результаты десяти тысяч прогонов изображены на рисунке 5.


Рис. 5: Сравнение скорости mcd и erlmc на 10 килобайтах данных

Тестирование показывает, что изначальное решение делать работу с memcached серверами асинхронной дало mcd существенный выигрыш в производительности по сравнению с синхронным способом работы. Даже то, что erlmc использует двоичный, а не текстовый протокол общения с memcached, не дало этой библиотеке никакого преимущества перед mcd.

Справедливости ради нужно отметить, что при типичном использовании erlmc, когда erlmc подключён сразу к нескольким memcached-серверам и распределяет нагрузку между ними, полученные значения скорости будут существенно выше. В идеальном случае, когда имеется достаточная энтропия в ключах, позволяющая erlmc разбрасывать операции с разными ключами по разным memcached-серверам, мы будем видеть производительность, кратную количеству используемых memcached-серверов. Например, при балансировании между двумя memcached-серверами скорость erlmc на случайно выбранных ключах может составлять в 64-байтном тесте не 3.5 тысяч запросов в секунду (таблица 2), а 7 тысяч. Таким образом, при использовании пяти memcached-серверов erlmc может оказаться несколько быстрее, чем mcd, использующий один сервер.

3.4.2  Производительность на локальной машине

Для полноты картины приведём усреднённые результаты тестирования memcached-сервера, расположенного на локальной одноядерной машине Amazon EC2 (Small instance).

Было проведено 6 тестов mcd и erlmc, отличающихся размером получаемых от memcached-сервера данных. Каждый тест прогонялся три раза и состоял из десяти или ста тысяч итераций операции mcd:get/2 или erlmc:get/1, производимых последовательно, с пятью разными степенями параллелизма в части количества инициаторов операций с memcached (1, 2, 4, 10, 100).


ОтветКлиент1 поток2410100
пустоmcd74618452919696879576
 erlmc78088017825582877440
<<>>mcd65227233782281677538
 erlmc77157997822883037484
63 байтаmcd63466920736274436930
 erlmc76487975820182127352
64 байтаmcd64227162773879077138
 erlmc76567959820182777397
937 байтmcd29262977304130192976
 erlmc75957817796779947138
10 кбайтmcd26562657278624192454
 erlmc60936279634662195441
Таблица 3: Падение производительности mcd относительно erlmc с ростом размера данных

Результаты тестирования приведены в таблице 3. Как и следовало ожидать, использование разных степеней параллелизма существенно не изменяет результаты тестирования memcached, доступного на локально на одноядерной машине. Разницу в пределах 30 % можно объяснить особенностями параллельной сборки мусора для разного количества параллельных нагрузочных процессов.

Из-за отсутствия возможности устранения сетевых задержек (на локальной машине сетевых задержек как правило не бывает) ориентированный на текстовый протокол код mcd демонстрирует отставание от скорости работы библиотеки erlmc, использующей протокол бинарный. Скорость обработки запросов через mcd снижалась вплоть до 2.5 тысяч запросов в секунду, тогда как скорость erlmc практически не падала ниже 6 тысяч запросов в секунду. На малых данных разница была практически незаметна, но с ростом количества отдаваемых в ответе данных производительность mcd снижалась до 2.6 раз от скорости erlmc.

Заключение

Как было показано, создание memcached клиента — концептуально несложная задача. Интересной эту задачу делает попытка сделать решение чуть более оптимальным, используя асинхронный подход к управлению потоками операций. Результаты тестирования показывают, что на задержках, существующих в LAN, подобная оптимизация оправдана и приводит к результатам, существенно превышающим производительность более простых решений.

Интересный приём, представляющий интерес с точки зрения функционального программирования, реализован в механизме «дожиданий». Дожидания позволяют не блокировать работу клиентского процесса даже при приёме серверного ответа, разбитого на несколько пакетов с произвольными задержками между ними. В статье также рассмотрена более простая альтернатива дожиданиям, в большей степени использующая удобные механизмы Эрланга по организации параллельных процессов.

С появлением двоичного протокола появляется возможность дальнейшей оптимизации кода mcd, как в части его возможного упрощения, так и в части повышения его производительности на локальной машине, где сетевые задержки практически отсутствуют. Тесты, описанные в разделе «Производительность на локальной машине», показывают, что в этом отношении у mcd есть пространство для роста.

Полный исходный код memcached-клиента, рассмотренного в статье, размещён на GitHub под именем EchoTeam/mcd [2].

Список литературы

[1]
Erlang-клиент для двоичного протокола memcached. Проект Jacob Vorreuter на GitHub (en), http://github.com/JacobVorreuter/erlmc/.
[2]
Erlang-клиент для текстового протокола memcached. Проект Jacknyfe на GitHub (en), http://github.com/EchoTeam/mcd/.
[3]
Erlang приложение для клиентского доступа к memcached. Проект Zhou Li на GitHub (en), http://github.com/echou/memcached-client/.
[4]
Ketama: Consistent Hashing. Сайт проекта (en), http://www.audioscrobbler.net/development/ketama/.
[5]
memcached — a distributed memory object caching system. Страница проекта (en), http://memcached.org/.
[6]
Проект facebook-memcached, реализующий UDP для memcached. Проект Marc Kwiatkowski на GitHub (en), http://github.com/fbmarc/facebook-memcached.

1
По аналогии со специальными терминами-существительными «продолжение» (continuation) и «будущее» (future).
2
Исправлена ошибка № 72: http://code.google.com/p/memcached/issues/detail?id=72

Этот документ был получен из LATEX при помощи HEVEA