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

  • at-most-once (максимум один раз)

  • at-least-once (минимум один раз)

  • и exactly-once delivery (строго одноразовая доставка).

Они хорошо описаны в различных публикациях, поэтому вкратце напомним, что при доставке at-most-once сообщение может быть потеряно. При доставке at-least-once все сообщения будут доставлены один или несколько раз. А exactly-once доставки не существует. Впрочем, это не совсем так. С точки зрения доставки сообщений "exactly-once" невозможна, но при использовании таких методов, как дедупликация, мы можем добиться "effectively-once" (эффективно один раз), что является гораздо более подходящим названием. Результат или эффект может быть достигнут один раз, и это выполнимо.

Доставка at-most-once

Семантика доставки at-most-once очень проста. Мне нравится называть это YOLO-доставкой (You Only Live Once — живем только один раз). Мы можем послать сообщение из системы A в B, и нас не волнует, получит его B или нет. Это очень полезно для некоторых случаев массового получения данных, например, для сбора кликов на веб-страницах, при отслеживании автомобиля и т.д.

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

Доставка at-least-once 

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

В большинстве ситуаций продюсер (A) отвечает за at-least-once доставку хотя бы одного сообщения (m1), ожидая подтверждения от консьюмера (B) о том, что сообщение (m1) было доставлено. Если оно не получено, продюсер отправляет сообщение снова.

Это стандартный подход, и в большинстве случаев это вполне приемлемый выбор. Если вам нужно больше подробностей о возможной реализации, посмотрите этот блог о паттерне Transaction Outbox Pattern.

Многие разработчики забывают, что мы можем развернуть эту зависимость и сделать консьюмера (B) ответственным за гарантию доставки "at-least-once". Предположим, что продюсер (A) отправляет сообщения m1, m2, m3 и по каким-то причинам сообщение m4 теряется. Следующим сообщением от продюсера  будет m5.

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

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

Если вы можете генерировать для каждого сообщения строго монотонно возрастающий порядковый номер без пробелов, то реализация доставки по принципу "at-least-once" будет довольно простой. Иногда это глобальная последовательность, но очень часто она генерируется для каждого агрегата домена, который является хранителем целостности состояния.

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

Иногда поток сообщений не может быть "перезапущен". В качестве примера этого может служить коммуникация fun-out:

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

В этом случае потребуется реконсиляция состояния:

  1. буферизация основного потока сообщений,

  2. запрашивание актуального состояния с текущим порядковым номером

  3. использование состояния и начало обработки потока сообщений с этого порядкового номера (сообщение m1 должно быть проигнорировано)

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

В основном, такая стратегия доставки по принципу "at-least-once" не требует больших затрат трафика. Реконсиляция будет случаться относительно редко, поэтому нам не придется переплачивать за подтверждение каждого сообщения. Я нахожу это особенно полезным, когда экспериментирую с WebSocket-коммуникациями для многих клиентов, потребляющих один и тот же поток данных.

Доставка "effectively-once" с дедупликацией

Как насчет знаменитой и такой востребованной доставки точно/эффективно один раз (exactly/effectively-once)? Конечно, мы все знаем, как решить эту проблему. Каждое сообщение должно иметь некоторый уникальный идентификатор, при помощи которого можно проверить, было ли оно использовано ранее. Здесь существуют две готчи (gotchas).

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

begin transaction;
update something based on a given message;
save unique id from the message if not present;
end transaction;

начать транзакцию;
обновить данные на основе заданного сообщения;
сохранить уникальный идентификатор из сообщения, если его нет;
завершить транзакцию;

Для классической РСУБД (Реляционная система управления базами данных. RDBMS) добиться этого не составит труда. Отдельная таблица с уникальным ограничением на столбец id, и можно приступать к работе. В случае обработки одного и того же сообщения дважды, транзакция завершится неудачно, можно предположить, что это сообщение уже обрабатывалось ранее.

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

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

В распределенных базах данных, таких как Cassandra, имеется определенная поддержка транзакций, но с ней следует быть очень осторожным. Любое обновление или вставка с оператором IF запускает под капотом легковесную транзакцию (lightweight transaction). Само название обманчиво, потому что это довольно тяжелая операция:

Lightweight transactions should not be used casually as the latency of operations increases fourfold due to the round-trips necessary between the CAS coordinators.

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

Также в Cassandra (как и во многих распределенных базах данных) вы не обновите две таблицы в одной транзакции, просто потому что они могут быть на двух разных узлах.

Совет: убедитесь, что ваше базовое хранилище поддерживает транзакции, как указано выше, прежде чем вы пообещаете обеспечить эффективную однократную (effectively-once) обработку.

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

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

Если на стороне консьюмера согласована доставка по принципу "at-least-once", мы можем использовать порядковый номер и для дедупликации. Исходя из того, что очередное сообщение будет иметь порядковый номер + 1, нам нужно хранить только текущее значение последовательности. Такой вид дедупликации не будет иметь ограничений по времени/хранилищу.

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

Включение идемпотентности минимизирует вероятность появления дубликатов, но не устранит ее полностью.

Резюме

Один из моих любимых твитов о распределенных системах — словесный каламбур Матиаса Верраеса (Mathias Verraes):

Существуют только две большие проблемы в распределенных системах: 2. Доставка exactly-once 1. Гарантированный порядок сообщений 2. Доставка exactly-once

(источник)

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

Теперь ваша очередь погрузиться глубже и попрактиковаться с ними на примерах из реальной жизни.


Завтра пройдет открытое занятие «Аналитик в Agile: как выжить и куда расти». О чем поговорим:
— Почему Agile уже почти везде?
— Какие стартовые позиции у аналитиков сейчас.
— Как аналитики в компании Stenn стали драйверами трансформации и куда она их завела?
Если интересно, регистрируйтесь по ссылке.

Комментарии (0)