Паттерн Outbox: как не растерять сообщения в микросервисной архитектуре

955f05eaa12490a934cd8bbee681d249.png

Привет! Меня зовут Михаил Боровиков, я тимлид команды, которая отвечает за систему процессинга заказов Lamoda — Orders Management. Эта система, словно сердце Lamoda, через которое проходит самый важный для бизнеса шаг — оформление заказа.

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

Для решения этой проблемы мы выбрали паттерн Outbox. И в этой статье я расскажу:

  • что он из себя представляет;

  • как мы его применили;

  • почему пошли по пути at-least-once и не положились на работу одного брокера сообщений.

Как устроен процесс создания заказа

Когда пользователь на сайте или в мобильном приложении Lamoda нажимает «Создать заказ», он проходит через api gateway и попадает в сервис Orders Management. У него есть своя база данных, где он хранит все заказы.

Процесс создания заказа разбит на три шага:

Шаг 1. Сохранение в базу для дальнейшей обработки.

Шаг 2. Обращение в другие сервисы.

На этом шаге мы ходим в другие микросервисы, каждый из которых отвечает за свой контекст. Например, в сервис Payments, который валидирует данные платежа / купона / подарочного сертификата, формирует ссылку оплаты или в сервис stock, который резервирует товары на складе.

Рассматривать взаимодействия со всеми этими сервисами по отдельности мы не будем, так как это займет слишком много времени. Вместо этого посмотрим на сервис доставки Order Delivery, на примере которого и разберем сценарий потери данных. 

Основная функция сервиса доставки — провалидировать данные доставки с адресом (запрос Create Delivery). Если все хорошо, то в таком случае происходит резервирование интервала доставки — это время, в которое торговый представитель привозит заказ. У интервала есть два важных свойства:

  • Capacity — тот максимум заказов, который мы можем доставить в это время.

  • Quantity — количество заказов, зарезервированных на текущий момент.

2f8672cd44fa6d2850cca72bc4e68439.png

Когда взаимодействие со всеми сервисами завершено, в конце принимается решение о том, будут подтверждаться данные по доставке или нет. Это похоже на механизм транзакции. Для подтверждения отправляется специальный запрос «Confirm» с параметром «true» или «false». С параметром true данные подтверждаются, но если вдруг мы отправляем параметр false, то значит, с заказом что-то не так (например, некорректные данные доставки или не удалось зарезервировать товары на складе), и нужно освободить ранее зарезервированный интервал доставки.

a1790208edeeb7c6bc8dd60305199f01.png

Для освобождения интервала доставки выполняется операция Release. На предыдущем шаге у нас quantity увеличивался на 1, когда резервировался интервал под доставку. Следовательно, если нужно освободить время доставки, то вычитаем из quantity 1:

e2a2cc855f918547ad9415b1467f8302.png

Шаг 3. Обновление информации по заказу. 

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

Примерно так и выглядит весь процесс создания заказа. Но тут есть проблема: когда мы взаимодействуем с сервисом по сети, он может затаймаутить. Если тайм-аут появится на запросе Confirm с параметром false, то освободить занятый интервал доставки не получится.Это приведет к тому, что мы займем лишний слот в интервале доставки, и по факту заказов будет отправлено меньше, чем планировалось изначально.

Рассмотрим проблему подробнее

Допустим, у нас есть вот такой интервал доставки:

b1cba19e70dd8e4d681c0f9a1b6dcbf6.png

Мы можем доставить максимум 10 заказов, а количество зарезервированных заказов пока равно нулю. Прошло x времени, все слоты зарезервировали. Допустим, 50% заказов были обработаны успешно, и мы будем их доставлять. А в остальных 50% мы должны были отменить доставку, но, к сожалению, сервис затаймаутил или полностью лег, и у нас не получилось это сделать.

Это означает, что в этот промежуток времени мы доставим на 50% заказов меньше, чем могли бы. Это приводит к потере денег.

Как решить проблему

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

  На схеме это выглядит следующим образом:

4e009a2672eabeb94fe9ea17386d35cc.png

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

Но давайте задумаемся: достаточно ли нам одного брокера сообщений?

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

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

Применяем паттерн Outbox

Паттерн Outbox обеспечивает сохранение сообщений в хранилище данных (как правило, в таблице outbox в базе данных), прежде чем они будут в конечном итоге переданы в брокер сообщений. Если бизнес-объект и соответствующие сообщения сохраняются в рамках одной транзакции базы данных, это гарантирует, что данные не будут потеряны. Либо будет зафиксировано все, либо при возникновении ошибки произойдет полный откат.

Как он работает. Представим, что у нас есть Command handler. Это наше приложение, например, по созданию заказов, у которого есть своя локальная база. В приложение пришел запрос на то, чтобы создать заказ. Это выглядит так:

907453171025c40115c97cd259bcee23.png

Здесь открывается transaction scope с базой, в которую мы сохраняем заказ. И если появятся сообщения, которые мы хотим гарантированно куда-то доставить, то мы сохраняем их в базу, в специальную табличку Outbox. Затем транзакция коммитится.

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

415486373a70df2bdedd76a9741e064f.png

Как сообщения хранятся в базе. Это небольшая табличка, где есть Primary key, Payload, то есть тело нашего сообщения и статусы, по которым мы будем понимать, что с сообщением сейчас происходит.

9c58534ba394ea96099e92de2736d47c.png

Плюсы и минусы паттерна Outbox

Плюсы

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

  2. Сообщения отправляются, только когда транзакция базы данных коммитится. То есть у нас не получится так, что если нам не удалось сохранить заказ в базу, хотя мы сохранили сообщение в Outbox, то выполнится не нужная команда. Здесь гарантируется атомарность.

Минусы

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

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

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

8109b8c751be8bb8858ec15dac8d7fc1.png

Типы гарантий при отправке сообщений

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

  1. At-most-once — может быть доставлено 0 или 1 раз.

  2. At-least-once — может быть доставлено 1 или более раз (т.е. возможны дубликаты сообщения)

  3. Exactly once — может быть доставлено строго один раз.

Наиболее привлекательно среди всех этих способов выглядит exactly once. Но если вы начнете погружаться в эту тему или обсуждать с коллегами, то наверняка столкнетесь с такой ситуацией, когда все в один голос скажут, что в реальном мире не бывает данной семантики. 

3da06e69ff851f90b491a320d5c67776.png

Почему же тогда она вообще существует? Давайте с этим разберемся. 

Почему Exactly once не бывает

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

Это доказывается на небольшом примере — проблеме двух генералов. Два генерала пытаются захватить замок, но им нужно синхронизировать время и напасть в один момент. Для этого один генерал отправляет второму разведчика. Однако есть проблема: защитники замка могут его перехватить. 

9fe41d78f70680ecf6bb5290abc4efd4.png

Переложим этот пример на наши микросервисы. Мы не можем на 100% гарантировать, что сообщение будет доставлено, потому что взаимодействие происходит по сети. Сеть — это нестабильная среда, где может все, что угодно пойти не так: из-за человеческого фактора или других причин.

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

Exactly once в Kafka

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

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

Какую же гарантию стоит выбрать?

At-most-once нам не подходит, потому что мы все-таки хотим, чтобы данные в итоге были доставлены. С exactly-once мы разобрались: гарантированной доставки сообщения строго один раз не бывает. Остается At-least-once — доставим сообщение один раз или более. Собственно этот вариант мы и выбрали.

Так выглядит концептуальная схема нашего решения:

f5fb8ec7970a976689acc5deee95c2ec.png

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

На стороне брокера с Outbox-процессором мы можем гарантировать At-least-once. Но если мы выбираем для себя такую семантику, то значит, мы можем в том числе доставлять в другие сервисы сообщения более одного раза, то есть возможны дубликаты.

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

Как бороться с дубликатами 

На стороне сервисов мы можем обеспечить идемпотентность, то есть exactly-once процессинг. Как этого достичь? Есть несколько вариантов:

  1. Хранить идентификаторы обработанных сообщений и проверять, когда сообщения нам приходят, что это не дубликат.

Важно: если выбираем такой путь, то идентификатор должен устанавливаться на стороне приложения отправителя, чтобы со временем он не менялся.

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

Теперь вернемся на самую первую схему и попробуем наложить на нее выбранное решение нашей проблемы:

28a11a61218426942bc3272018333465.png

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

Резюмируем

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

В каких случаях стоит об этом переживать:

Как мы поняли из показанного примера, одного брокера сообщений может быть недостаточно для надежной доставки данных. Чтобы этого избежать, можно применить паттерн Outbox и повысить гарантии доставки данных, что мы и сделали. Если пойти по этому пути, то мы сталкиваемся с некоторыми сложностями, а также у нас появляется дополнительный scope задач, которые мы разобрали выше. Но для нас это решение оказалось приемлемым. Кроме того, что мы успешно его внедрили и стабилизировали, сейчас оно также масштабировалось на другие сервисы компании, где активно используется.

© Habrahabr.ru