Статьи

Распределенные счетчики

Это еще один эксперимент с более длинными постами.

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

Во всяком случае, вчера у нас был запрос на счетчики в стиле Cassandra в списке рассылки. И пока я делаю серию дизайна, я думал, что смогу рассказать о том, как я буду реализовывать это. Опять же, рассмотрите эту фикцию, у меня нет планов по реализации этого в настоящее время.

Суть в том, что мы хотим, это уметь… считать вещи. Эффективно, распределенным образом, с дополнительной поддержкой для перекрестной репликации центров обработки данных.

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

Давайте наметим различные части решения в том же порядке, в котором я использовал временные ряды.

Место хранения

Счетчик — это просто именованное 64-битное целое число со знаком. Имя счетчика может быть любой строкой длиной до 128 печатных символов. Внешний интерфейс хранилища будет выглядеть так:

   1: public struct CounterIncrement

   2: {

   3:     public string Name;

   4:     public long Change;

   5: }

   6:  

   7: public struct Counter

   8: {

   9:     public string Name;

  10:     public string Source;

  11:     public long Value;

  12: }

  13:  

  14: public interface ICounterStorage

  15: {

  16:     void LocalIncrementBatch(CounterIncrement[] batch);

  17:  

  18:     Counter[] Read(string name);

  19:  

  20:     void ReplicatedUpdates(Counter[] updates);

  21: }

Как видите, это дает нам очень простой интерфейс для хранилища. Мы можем либо изменить данные локально (которые изменяют наше собственное хранилище), либо мы можем получить обновление от реплики об их изменениях.

Честно говоря, ничего особенного в этом нет. LocalIncrementBatch () увеличивает локальное значение, а Read () возвращает все значения для счетчика. Существует небольшая хитрость в том, как именно хранить значения счетчиков.

Сейчас я думаю, что мы будем хранить каждый счетчик как два шага. У нас будет дерево из нескольких значений дерева, которое будет нести каждое значение из каждого источника. Это означает, что счетчик займет примерно 4 КБ или около того. С ним легко работать, и он прекрасно подходит для модели, которую использует Ворон.

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

По проводу

Пропустив любые оптимизации, которые могут потребоваться, мы предоставим следующие конечные точки:

  • GET / counters / read? Id = users / 1 / посещения и пользователи / 1 / posts <- вернет ответ json со всеми соответствующими значениями (уже суммированы).
    {«Пользователи / 1 / посещения»: 43, «пользователи / 1 / посещения»: 3}
  • GET / counters / read? Id = users / 1 / посещения и пользователи / 1/1 / posts & raw = true <- вернет ответ json со всеми соответствующими значениями для каждого источника.
    {«Users / 1 / посещения»: {«rvn1»: 21, «rvn2»: 22}, «пользователи / 1 / posts»: {«rvn1»: 2, «rvn3»: 1}}
  • POST / counters / increment <- позволяет увеличивать счетчики. Запрос представляет собой массив json с именем счетчика и изменением.

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

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

Поведение системы

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

Клиентский API

Клиентский API, вероятно, будет выглядеть примерно так:

   1: counters.Increment("users/1/posts");

   2: counters.Increment("users/1/visits", 4);

   3:  

   4: using(var batch = counters.Batch())

   5: {

   6:     batch.Increment("users/1/posts");

   7:     batch.Increment("users/1/visits",5);

   8:     batch.Submit();

   9: }

Обратите внимание, что мы предлагаем как пакетный, так и единый API. Мы, вероятно, также захотим предложить стиль «запусти и забывай», который сможет предложить еще лучшую производительность (потому что они могут выполнять группирование по нескольким потокам), но пока это выходит за рамки.

Для простоты у нас будет клиент просто контейнер для всех конечных точек, о которых он знает. Контейнер будет отвечать за… обновление видимой клиентской топологии, выбор наилучшего сервера для использования в любой заданной точке и т. Д.

Пользовательский интерфейс

Там не так много. Просто покажите список значений счетчиков в списке. Разрешить поиск по префиксу, позволить погрузиться в определенный счетчик и прочитать его необработанные значения, но это все. Да, и разрешить удалить счетчик.

Удаление данных

Честно говоря, я действительно ненавижу удаляет. Они очень дороги для правильной обработки в тот момент, когда у вас есть более одного узла. В этом случае существует неотъемлемое условие состязания между удаляемым удалением и другим узлом, получающим приращение. И затем возникает вопрос, что произойдет, если у вас был узел, когда вы сделали удаление и т. Д.

Это просто отстой. Удаление обычно обрабатывается (очевидно, с оговоркой о состоянии гонки), и я немного расскажу, как мы их повторим.

Высокая доступность / масштабирование

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

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

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

Поэтому есть только два реальных способа сделать это:

  • Добавьте новый пустой узел в кластер и заполните его от всех других серверов.
  • Добавьте новый узел, сделав резервную копию существующего узла и восстановив его как новый узел.

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

Однако это означает, что процесс запуска нового сервера теперь будет:

  1. Обновите все узлы в кластере, указав новый адрес узла (узел еще не подключен, репликация на него не удастся и будет поставлена ​​в очередь).
  2. Сделайте резервную копию существующего узла и восстановите на новом узле.
  3. Запустите новый узел.

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

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

Но это подводит нас к интересной проблеме: как мы на самом деле справляемся с репликацией?

Карта топологии

Чтобы упростить вещи, мы собираемся пойти с одним способом репликации от узла до другого. Это допускает сложные топологии, такие как мастер-мастер, кластер-кластер, цепочка репликации и т. Д. Но, в конце концов, это все о репликации одного узла на другой.

Первый вопрос, который нужно задать, — будем ли мы копировать только наши локальные изменения, или нам придется копировать и внешние изменения? Проблема с репликацией внешних изменений заключается в том, что у вас может быть следующая топология:

образ

Теперь сервер A получил значение и отправил его на сервер B. Затем сервер B переправил его на сервер C. Однако в этот момент у нас также есть значение с сервера A, реплицированное непосредственно на сервер C. Какое значение он должен выбирать? А как насчет сценария, где у вас более сложная топология?

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

Конечно, это приводит к раздражающему вопросу: что произойдет, если у нас будет кластер из 3 узлов, и один узел выйдет из строя катастрофически. Мы можем ввести новый узел, и два других узла смогут заполнить свои значения посредством репликации, но как насчет узла, который не работает? Данные не исчезли, они все еще находятся в двух других узлах, но нам нужен способ их извлечь.

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

  • Зарегистрируйте себя во всех узлах кластера (можно обнаружить из существующего узла). Это предполагает стандартную двухстороннюю связь репликации между всеми серверами, в противном случае операторы будут обязаны самостоятельно устанавливать фактическую семантику репликации.
  • Новый узел теперь начинает получать обновления со всех узлов в кластере. Он пока держит их в журнале, пока ничего не делая.
  • Попросите этот узел полностью обновить все его текущее состояние.
  • Когда он имеет все законченное состояние существующего узла, он воспроизводит все запомненные журналы, которые он еще не имел возможности применить.
  • Затем он объявляет, что находится в допустимом состоянии, чтобы начать принимать клиентские подключения.

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

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

Название сервера

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

Фактическая семантика репликации

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

Всегда безопасно перезаписывать данные с помощью репликации, потому что мы перезаписываем наши собственные данные, а не кто-либо другой.

И … это на самом деле. Вероятно, есть много деталей, которые я упускаю / которые могли бы обнаружить, если бы мы действительно осуществили это. Но я думаю, что это довольно хорошая идея о том, о чем эта функция.