Привет! Меня зовут Михаил Боровиков, я тимлид команды, которая отвечает за систему процессинга заказов Lamoda — Orders Management. Эта система, словно сердце Lamoda, через которое проходит самый важный для бизнеса шаг — оформление заказа.
Раньше система представляла из себя монолит. Теперь вместо него у нас много отдельных сервисов, которые общаются по сети. В рамках новой схемы взаимодействия сервисов между собой мы и столкнулись с проблемой потери данных в процессе создания заказа, чего допускать в важной для нас системе было категорически нельзя.
Для решения этой проблемы мы выбрали паттерн Outbox. И в этой статье я расскажу:
что он из себя представляет;
как мы его применили;
почему пошли по пути at-least-once и не положились на работу одного брокера сообщений.
Как устроен процесс создания заказа
Когда пользователь на сайте или в мобильном приложении Lamoda нажимает «Создать заказ», он проходит через api gateway и попадает в сервис Orders Management. У него есть своя база данных, где он хранит все заказы.
Процесс создания заказа разбит на три шага:
Шаг 1. Сохранение в базу для дальнейшей обработки.
Шаг 2. Обращение в другие сервисы.
На этом шаге мы ходим в другие микросервисы, каждый из которых отвечает за свой контекст. Например, в сервис Payments, который валидирует данные платежа / купона / подарочного сертификата, формирует ссылку оплаты или в сервис stock, который резервирует товары на складе.
Рассматривать взаимодействия со всеми этими сервисами по отдельности мы не будем, так как это займет слишком много времени. Вместо этого посмотрим на сервис доставки Order Delivery, на примере которого и разберем сценарий потери данных.
Основная функция сервиса доставки — провалидировать данные доставки с адресом (запрос Create Delivery). Если все хорошо, то в таком случае происходит резервирование интервала доставки — это время, в которое торговый представитель привозит заказ. У интервала есть два важных свойства:
Capacity — тот максимум заказов, который мы можем доставить в это время.
Quantity — количество заказов, зарезервированных на текущий момент.
Когда взаимодействие со всеми сервисами завершено, в конце принимается решение о том, будут подтверждаться данные по доставке или нет. Это похоже на механизм транзакции. Для подтверждения отправляется специальный запрос “Confirm” с параметром “true» или “false”. С параметром true данные подтверждаются, но если вдруг мы отправляем параметр false, то значит, с заказом что-то не так (например, некорректные данные доставки или не удалось зарезервировать товары на складе), и нужно освободить ранее зарезервированный интервал доставки.
Для освобождения интервала доставки выполняется операция Release. На предыдущем шаге у нас quantity увеличивался на 1, когда резервировался интервал под доставку. Следовательно, если нужно освободить время доставки, то вычитаем из quantity 1:
Шаг 3. Обновление информации по заказу.
На втором шаге мы сходили в сторонние сервисы и обогатили данные по заказу, теперь обновляем наш заказ в базе.
Примерно так и выглядит весь процесс создания заказа. Но тут есть проблема: когда мы взаимодействуем с сервисом по сети, он может затаймаутить. Если тайм-аут появится на запросе Confirm с параметром false, то освободить занятый интервал доставки не получится. Это приведет к тому, что мы займем лишний слот в интервале доставки, и по факту заказов будет отправлено меньше, чем планировалось изначально.
Рассмотрим проблему подробнее
Допустим, у нас есть вот такой интервал доставки:
Мы можем доставить максимум 10 заказов, а количество зарезервированных заказов пока равно нулю. Прошло x времени, все слоты зарезервировали. Допустим, 50% заказов были обработаны успешно, и мы будем их доставлять. А в остальных 50% мы должны были отменить доставку, но, к сожалению, сервис затаймаутил или полностью лег, и у нас не получилось это сделать.
Это означает, что в этот промежуток времени мы доставим на 50% заказов меньше, чем могли бы. Это приводит к потере денег.
Как решить проблему
Первое что приходит в голову это уйти от синхронного запроса и сделать его асинхронным, где мы будем пытаться переотправить запрос пока он в итоге не будет выполнен. Эту работу можно поручить брокеру сообщений.
На схеме это выглядит следующим образом:
Мы добавляем Message broker, в который продюсим сообщение, и потом уже в асинхронном режиме происходит попытка отправить его в сервис доставки.
Но давайте задумаемся: достаточно ли нам одного брокера сообщений?
Здесь мы сталкиваемся с той же проблемой, что и при синхронном запросе: при продюсинге сообщений в брокере может произойти сбой и сообщение потеряется. А нам нужно, чтобы оно гарантированно было доставлено.
Кто-то скажет, что если брокер сообщений недоступен, то можно считать это критичной ситуацией и возвращать пользователю ошибку. Но мы не можем себе позволить прерывать процесс создания заказов. Что же делать, когда брокер не столь надежен?
Применяем паттерн Outbox
Паттерн Outbox обеспечивает сохранение сообщений в хранилище данных (как правило, в таблице outbox в базе данных), прежде чем они будут в конечном итоге переданы в брокер сообщений. Если бизнес-объект и соответствующие сообщения сохраняются в рамках одной транзакции базы данных, это гарантирует, что данные не будут потеряны. Либо будет зафиксировано все, либо при возникновении ошибки произойдет полный откат.
Как он работает. Представим, что у нас есть Command handler. Это наше приложение, например, по созданию заказов, у которого есть своя локальная база. В приложение пришел запрос на то, чтобы создать заказ. Это выглядит так:
Здесь открывается transaction scope с базой, в которую мы сохраняем заказ. И если появятся сообщения, которые мы хотим гарантированно куда-то доставить, то мы сохраняем их в базу, в специальную табличку Outbox. Затем транзакция коммитится.
После этого запускается отдельный процесс, который забирает сообщения из таблицы outbox и начинает их обработку.
Как сообщения хранятся в базе. Это небольшая табличка, где есть Primary key, Payload, то есть тело нашего сообщения и статусы, по которым мы будем понимать, что с сообщением сейчас происходит.
Плюсы и минусы паттерна Outbox
Плюсы
Решает проблему связи между сервисами. Теперь не нужно беспокоиться, что брокер или сервис доставки будут недоступны. Все сообщения сохраняются в базу и будут обработаны, когда недоступные сервисы оживут.
Сообщения отправляются, только когда транзакция базы данных коммитится. То есть у нас не получится так, что если нам не удалось сохранить заказ в базу, хотя мы сохранили сообщение в Outbox, то выполнится не нужная команда. Здесь гарантируется атомарность.
Минусы
Необходима база данных. Если ее нет, то придется затащить эту зависимость, потому что сообщения обязательно нужно где-то хранить.
Дополнительная сложность эксплуатации и поддержки решения. Появляются дополнительные зависимости: брокер сообщений и база данных. Необходимо всегда быть готовым к отказам одного или другого.
Вместо того, чтобы самостоятельно в Outbox-процессоре совершать работу над сообщением, мы поручим эту работу брокеру, тем самым облегчив реализацию обработчика.
Типы гарантий при отправке сообщений
Когда мы определились с решением проблемы, нам нужно также определить, какую гарантию данных мы обеспечиваем нашим пользователям. Для этого вспомним, какие вообще бывают гарантии доставки сообщения:
At-most-once — может быть доставлено 0 или 1 раз.
At-least-once — может быть доставлено 1 или более раз (т.е. возможны дубликаты сообщения)
Exactly once — может быть доставлено строго один раз.
Наиболее привлекательно среди всех этих способов выглядит exactly once. Но если вы начнете погружаться в эту тему или обсуждать с коллегами, то наверняка столкнетесь с такой ситуацией, когда все в один голос скажут, что в реальном мире не бывает данной семантики.
Почему же тогда она вообще существует? Давайте с этим разберемся.
Почему Exactly once не бывает
Вернемся к определению — это гарантированная доставка сообщений строго один раз. В реальном мире действительно так не бывает.
Это доказывается на небольшом примере — проблеме двух генералов. Два генерала пытаются захватить замок, но им нужно синхронизировать время и напасть в один момент. Для этого один генерал отправляет второму разведчика. Однако есть проблема: защитники замка могут его перехватить.
Переложим этот пример на наши микросервисы. Мы не можем на 100% гарантировать, что сообщение будет доставлено, потому что взаимодействие происходит по сети. Сеть — это нестабильная среда, где может все, что угодно пойти не так: из-за человеческого фактора или других причин.
Некоторые могут возразить, что, например, в Kafka есть exactly once, и там с этим справились. Но давайте разберемся, что же подразумевается под данной семантикой, если доставка невозможна, и что есть в самой Kafka.
Exactly once в Kafka
Вернемся к определению: «гарантированная доставка сообщения строго один раз». Мы выяснили, что доставки строго один раз не существует. Но если вместо «доставки» поставить слово «обработка», то в таком случае мы можем достигнуть данной семантики, и это то, что и гарантирует Kafka.
В Kafka есть идемпотентные продюсеры, которые не дадут запушить одно и то же сообщение несколько раз. Эта схема с Kafka и семантикой exactly once работает только тогда, когда вы взаимодействуете внутри Kafka. Вы в нее запушили, прочитали и не выходите из этого круга. Но как только вы начнете выходить, гарантия доставки теряется.
Какую же гарантию стоит выбрать?
At-most-once нам не подходит, потому что мы все-таки хотим, чтобы данные в итоге были доставлены. С exactly-once мы разобрались: гарантированной доставки сообщения строго один раз не бывает. Остается At-least-once — доставим сообщение один раз или более. Собственно этот вариант мы и выбрали.
Так выглядит концептуальная схема нашего решения:
Что здесь есть. Приложение, которое взаимодействует с базой и складывает в нее сообщения, которые мы хотим куда-то потом доставить. И есть отдельный процессор, который вычитывает данные сообщения, и затем продюсит их в брокер.
На стороне брокера с Outbox-процессором мы можем гарантировать At-least-once. Но если мы выбираем для себя такую семантику, то значит, мы можем в том числе доставлять в другие сервисы сообщения более одного раза, то есть возможны дубликаты.
Где они могут возникать: при продюсинге сообщения в брокер или при отправке сообщения в определенный сервис. Сервис может затаймаутить, но все-таки выполнить свою работу. Мы же снова попробуем сделать отправку, потому что в предыдущий раз у нас не получилось.
Как бороться с дубликатами
На стороне сервисов мы можем обеспечить идемпотентность, то есть exactly-once процессинг. Как этого достичь? Есть несколько вариантов:
Хранить идентификаторы обработанных сообщений и проверять, когда сообщения нам приходят, что это не дубликат.
Важно: если выбираем такой путь, то идентификатор должен устанавливаться на стороне приложения отправителя, чтобы со временем он не менялся.
У сервиса должна быть уникальная сущность, по которой мы можем понять, что нам пришло сообщение, с которым мы что-то уже до этого делали. Например, это может быть заказ.
Теперь вернемся на самую первую схему и попробуем наложить на нее выбранное решение нашей проблемы:
Изначально мы столкнулись с тем, что у нас могут отлетать запросы на Confirm. С применением паттерна вместо того, чтобы напрямую ходить в сервис доставки, мы можем в конце при обновлении данных по заказу сохранять сообщения в базу, затем Outbox-процессор будет получать эти сообщения и продюсить в брокер, который в свою очередь выполнит свою работу.
Резюмируем
Первое, что мы для себя поняли: не существует однократной гарантии доставки сообщений. Наши сервисы взаимодействуют по сети, а это нестабильная среда, где сообщения теряются.
В каких случаях стоит об этом переживать:
если компания теряет деньги;
если это приводит к регулярному саппорту.
Как мы поняли из показанного примера, одного брокера сообщений может быть недостаточно для надежной доставки данных. Чтобы этого избежать, можно применить паттерн Outbox и повысить гарантии доставки данных, что мы и сделали. Если пойти по этому пути, то мы сталкиваемся с некоторыми сложностями, а также у нас появляется дополнительный scope задач, которые мы разобрали выше. Но для нас это решение оказалось приемлемым. Кроме того, что мы успешно его внедрили и стабилизировали, сейчас оно также масштабировалось на другие сервисы компании, где активно используется.
Комментарии (15)
AndreySu
02.08.2022 14:00+1В Kafka есть идемпотентные продюсеры, которые не дадут запушить одно и то же сообщение несколько раз. Эта схема с Kafka и семантикой exactly once работает только тогда, когда вы взаимодействуете внутри Kafka. Вы в нее запушили, прочитали и не выходите из этого круга. Но как только вы начнете выходить, гарантия доставки теряется.
Что означает не выходите из круга? Объяснение что в Kafka не работает exactly once не продано. Чем ACK тамошний не устраивает в этом случае?
База будет медленнее, и с ростом нагрузки на подобный сторадж сообщений, необходимо будет придумывать различные велосипеды или переделывать архитектуру чтобы увеличить скорость работы.
Mania_c Автор
02.08.2022 21:26> Что означает не выходите из круга ?
Это означает, что не стоит выходить за пределы работы по схеме: запродюсили сообщение в kafka, на другой стороне его прочитали и снова запродюсили своё сообщение.
Если выйти из этой схемы работы, например, добавить поход в микросервис, то теряется семантика exactly-once. Почему? Например, мы вычитали сообщение из kafka, делаем запрос в микросервис, он таймаутит, мы получаем ответ об этом и не выполняем ACK. При этом микросервис мог выполнить свою работу, но просто не успеть ответить нам. Происходит повторная попытка обработать сообщение. Снова делаем запрос в микросервис, он отвечает нам ошибкой т.к. пришли дублирующие данные. Exactly once нарушен.AndreySu
03.08.2022 10:21Если в этом случае Kafka заменить на базу, то такой вопрос: что если сервис выполнил работу но не записал в базу в строку сообщения статус новый и упал? Разве не тоже самое?
Mania_c Автор
04.08.2022 11:54В отличие от примера с kafka где мы рассматривали exactly-once и пути его достижения, мы выбрали at-least-once поэтому это согласуется с тем что вы написали. Если сервис не успеет отработать или упадёт, то мы попытаемся еще раз выполнить работу.
RouR
02.08.2022 14:37+3Вернемся к определению — это гарантированная доставка сообщений строго один раз. В реальном мире действительно так не бывает.
Это доказывается на небольшом примере — проблеме двух генералов.
Некоторые аналогии подобны котёнку с дверцей - такие же странные.
Возвращаясь к программированию - добавьте idempotency key и retry. И будет вам exatly once.
alexhott
02.08.2022 15:57+3И каждый сам решает одну и туже задачу, много раз описанную и рассказанную. И каждый идет своим верным путем.
Протокол интернет эквайринга изначально примерно так работает, генерим ИД транзакции, отправляем банку, банк шлет запрос на возможность проведения платежа с нашим ИД + свой ИД транзакции, мы его у себя сохраняем, убедились что сохранили и отвечаем банку "ок". Если не ответили дальше дело не идет. Банк принял платеж и отправляет нам запрос с ИД и результатом, пока не получит от нас ответ что все ок.
За 20 лет наблюдений ни одной потерянной транзакции.VVitaly
03.08.2022 09:38Ответ OK послать недостаточно... :-) Он может и не дойти...
Нужно в следующем "обмене" получать результат предыдущего... :-)ggo
03.08.2022 10:44Ок достаточно, в случае если банк берет на себя обязательство при отсутствии Ок, позвать повторно.
VVitaly
03.08.2022 11:11Предлагаете банку "прокрутить фарш взад"? Еще раз вам слать "тоже самое"? А если никакого ответа от вас повторно нет? Предлагаете деньги платежа откатывать (банк же не в курсе как там у вас "все прошло", а деньги у клиента уже ушли)? А если у вас уже по предыдущему "все куплено" и OK ранее "честно" отправлен (но не дошел)? :-)
Опять Ok пошлете, но банк и его может не получить?
Все "велосипеды" уже до нас придуманы, но любителей их придумывать это не останавливает... :-)
flight643
03.08.2022 09:38А как вы решаете потенциальную проблему с потенциально устаревшим значением quantity в сервисе доставки?
Допустим, ваш outbox processor или брокер чуть затупили и скопилось некоторое количество еще не доставленных сообщений. В таком случае вы можете показать доступным интервал, который окажется занят после обработки текущих сообщений, либо наоборот не покажете доступным тот интервал, который освободится.
Mania_c Автор
03.08.2022 12:51В таком случае вы можете показать доступным интервал, который окажется занят после обработки текущих сообщений.
У нас не возникает проблем с потенциально устаревшим значением quantity. В нашем примере отправляется запрос на то, чтобы освободить интервал т.е. вычесть из quantity единицу. Поэтому после обработки текущих сообщений интервал наоборот освободится т.е. появятся доп. слоты для доставки заказов
ggo
03.08.2022 10:49Честно говоря так и не понял, почему считается, что брокер сообщений может терять сообщения, а БД не может терять данные.
В общем случае, если проблемы на инфраструктуре, то данные может терять и брокер, и БД.
В вашем случае, вы создали два гетерогенных персистентных хранилища вместо одного. Два лучше одного, в смысле надежности, это бесспорно. А как называется подход (outbox), уже неважно.
md_backend_binance
"взаимодействия со всеми этими сервисами по отдельности мы не будем "
Эх это самое интересное , как происходит взаимодействие и транзакции, если у вас Биллинг поделен на сервисы Order , Payment , Bill . Какую там тактику применяете, с откатами или нет. Что делаете с параллельностью если один пользователь покупает одновременно два разных товара, и какая тут тактика , ведь если делать сначала резерв в микросервисе, а потом ему не хватит денег на товар, он при этом в "очереди" был платёжеспособный покупатель которому вы отказали, хотя он мог бы купить , но ему не досталось.
Mania_c Автор
Думаю, на это можно сделать отдельный доклад, но постараюсь ответить=)
> Какую там тактику применяете, с откатами или нет.
У нас реализован паттерн Saga, а сервис Orders Management является оркестратором, который сообщает другим микросервисам, какое действие необходимо выполнить далее. Как было сказано в статье если в процессе создания заказа, что-то пошло не так, то сервис отправляет ряд компенсирующих запросов, чтобы откатить сделанные изменения в других микросервисах. Поэтому да, откат есть.
> Что делаете с параллельностью если один пользователь покупает одновременно два разных товара, и какая тут тактика, ведь если делать сначала резерв в микросервисе, а потом ему не хватит денег на товар, он при этом в "очереди" был платёжеспособный покупатель которому вы отказали, хотя он мог бы купить , но ему не досталось.
Если это два разных товара, например, один будет доставлять Lamoda, а другой наш партнёр, то он разбивается на два разных заказа. Эти заказы будут обработаны последовательно, но оплата будет общая. Если оплата не пройдёт, то будет попытка перевести заказ в постоплату и сохранить его за пользователем. Если в постоплату невозможно выполнить перевод, то Orders Management выполнит ряд компенсирующих запросов, чтобы откатить сделанные изменения, в том числе снять резерв товара за пользователем. После этого товары снова будут отображены на сайте доступными для покупки.
md_backend_binance
Да было бы здорово статью про это, и указать что работает параллельно , что последовательно по каким ключам