Tech Tea Studio
Заказы 0 Доставлено 0 Потеряно 0

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

Блог · · 13 минут чтения

Мы 3 827 раз уронили свой пайплайн событий

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

427контролируемых прогонов
1 485 269закоммиченных заказов
3 827падений в заданных точках
223настоящих убийств процессов

01 · Публикация

Публикация из цепочки запроса теряет заказы

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

Мы роняли сервис заказов именно в этих точках с фиксированной вероятностью на запрос. При частоте падений 1 % схема «коммит сначала» теряла 1,57 % своих закоммиченных заказов. Каждое падение обходилось примерно в 1,6 события, потому что умирающий процесс забирает с собой все запросы в полёте. Отказ брокера был хуже: при пяти быстрых повторах на запрос 15-секундный простой терял 41 % всех заказов за прогон, почти каждый заказ, закоммиченный пока брокера не было.

Потерянные и фантомные события на 100 закоммиченных заказов

Потерянные событияФантомные события
Пять прогонов по 20 с при 100 заказах в секунду на столбик. Все три варианта с outbox доставили все до единого свои 40 580 закоммиченных заказов, без потерянных и фантомных событий.

Транзакционный outbox пишет событие в ту же транзакцию БД, что и заказ, а публикует его позже отдельный relay. В наших прогонах он доставил каждый закоммиченный заказ: 1,3 млн заказов через падения, разделения сети с брокером и убийства процессов. Он же сделал сервис заказов быстрее: когда брокер уходит из цепочки запроса, сервис принимал на 50 % больше заказов в секунду в режиме насыщения.

02 · Дубликаты

Дубликаты всё равно приходят

Outbox гарантирует, что событие отправлено как минимум один раз. О не более одного раза он не говорит ничего. Когда relay падает после того, как брокер подтвердил пачку, но до того, как relay пометил строки отправленными, после перезапуска он публикует всю пачку ещё раз. В среднем в наших пачках было 11,5 строк, и каждое падение relay давало почти ровно столько же дубликатов.

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

Эффекты дубликатов в нашей БД, на 1000 заказов

Без дедупликацииТаблица inboxИдемпотентный обработчик
Вероятность падения 2 % в отмеченной точке, пять прогонов на столбик. «С ключом» означает, что консьюмер передаёт id заказа платёжному провайдеру как ключ идемпотентности.

Таблица inbox или идемпотентный обработчик убирали все дубликаты из нашей БД. Платёжный провайдер всё равно списывал двойную оплату с 31 до 51 заказа на тысячу. Исправлял это только ключ идемпотентности.

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

03 · Relay

Выбор relay: это выбор точки на кривой

Кто-то должен переносить события из таблицы outbox в брокер. Relay с опросом запрашивает БД через заданный интервал, захват изменений данных (CDC) стримит новые строки из журнала предзаписи, а наш адаптивный relay опрашивает с backoff и просыпается по уведомлению от БД. Каждый вариант меняет задержку на работу БД предсказуемым образом: опрос каждые 100 мс давал медиану 69 мс и стоил БД 30 запросов в секунду даже совсем без трафика, почти ровно столько, сколько даёт прикидка на салфетке.

Задержка против стоимости простоя БД

Медианная сквозная задержка при выбранной нагрузке против числа запросов в секунду, которые relay шлёт в простаивающую БД. Обе оси логарифмические. CDC не шлёт ни одного и сидит у левой границы.

CDC быстр и тих, когда ничего не происходит. Адаптивный relay с уведомлениями был быстрее всех на малой и средней нагрузке: 13,6 мс, и столь же дёшев, как опрос раз в секунду, когда простаивает. На тяжёлой нагрузке он дорожает: каждое уведомление запускает опрос, и при 150 заказах в секунду он слал больше запросов, чем опрос каждые 10 мс.

04 · Падение брокера

Backoff может проспать восстановление

Мы отрезали relay от брокера на 5, 15 и 30 секунд, пока заказы продолжали поступать. Ни одна схема не потеряла события: outbox просто рос. Отличия начинались в том, насколько быстро каждая политика повторов замечала, что брокер снова на связи, и как упорно она работала, пока ждала.

После возвращения брокера

Медианы пяти прогонов с четырьмя воркерами relay при 100 заказах в секунду. Стоимость ожидания считает попытки соединения с брокером и опросы БД в секунду во время простоя.

Неотправленные события за один 30-секундный простой

По одному прогону на политику. Бэклог растёт на 100 событий в секунду у всех: экспоненциальный backoff оставляет его стоять ещё 21 секунду после возвращения брокера.

Экспоненциальный backoff с потолком 30 с спал посреди долгого ожидания, когда брокер вернулся, и возобновил работу с опозданием на 21 секунду. Фиксированные повторы каждые 100 мс возобновились мгновенно, но весь простой долбили БД по 38 опросов в секунду. Схема с пробами, которую мы предлагаем, возобновилась менее чем за секунду и опрашивала БД максимум раз в секунду. Она перестаёт опрашивать БД, как только брокер выглядит мёртвым, и вместо этого пробует сам брокер дешёвой попыткой соединения.

05 · Отказ ниже по цепочке

Повторы превращают сбой в недоставленные сообщения

Когда платёжный провайдер падал на 20 секунд, консьюмеры с ограниченными повторами сдавались и клали сообщения в очередь недоставленных сообщений: 288 сообщений при фиксированных повторах, 48 при экспоненциальном backoff. Они же продолжали дёргать упавшего провайдера до 88 раз в секунду. Небольшая модель окон повторов предсказала пять из этих шести значений с точностью до сообщения.

Сообщения в DLQ за прогон

Медианы пяти прогонов. Рядом с каждым столбиком: вызовы в секунду, которые доходили до упавшего провайдера во время простоя.

Адаптивный консьюмер открывает предохранитель, перестаёт забирать сообщения и оставляет одного пробника проверять здоровье провайдера. Сообщения ждут в брокере, а не падают. Ни одно сообщение не попало в DLQ, а к сломанному провайдеру он обращался меньше двух раз в секунду. Цена: задержка для сообщений, которые пришли во время простоя.

06 · Всё сразу

Четыре архитектуры, одна плохая минута

Напоследок мы собрали четыре законченных системы и дали каждой одну и ту же минуту: 100 заказов в секунду, сервис заказов, relay и консьюмер убиваются сигналом SIGKILL с интервалом от 15 до 20 секунд, 5-секундное разделение сети с брокером и 5-секундный простой платёжного провайдера.

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

07 · Что мы делаем теперь

Чек-лист для событийно-ориентированных сервисов

  1. Никогда не публикуйте из цепочки запроса. Пишите событие в таблицу outbox в той же транзакции, что и бизнес-изменение.
  2. Дедуплицируйте в каждом консьюмере. Таблица inbox по id сообщения или идемпотентная запись по бизнес-id: примерно пятая часть пропускной способности консьюмера, потраченная не зря.
  3. Отправляйте ключ идемпотентности в каждую внешнюю систему. Дедупликация в БД не защитит вызов, который не оставил следа в БД.
  4. Выбирайте размер пачек relay с оглядкой на дубликаты. Одно падение relay переотправляет одну целую пачку.
  5. Ограничивайте задержки повторов примерно секундой и добавляйте джиттер. Потолок в 30 секунд проспит восстановление.
  6. Удерживайте сообщения во время простоя зависимости. Ставьте обработку на паузу за предохранителем (circuit breaker) и пробуйте снова: удержанные сообщения снова пойдут сами, как только зависимость вернётся.

Как мы измеряли

Стенд: система на .NET 10 с двумя базами PostgreSQL 17, RabbitMQ 4.1 и Toxiproxy для сетевых сбоев, всё в контейнерах с фиксированными лимитами CPU. Падения вбрасываются в шести именованных точках между коммитом, публикацией и обработкой, как внутрипроцессные падения и как настоящие завершения процессов. Каждый прогон считает потерянные, дублированные и фантомные события по одному журналу и берёт все задержки по часам БД. Мы провели 427 прогонов примерно за четыре часа замеров, по пять повторений на конфигурацию в случайном порядке, а семейство протоколов проверили верификатором моделей TLA+ до того, как замеряли его.

Нужен полный технический отчёт или подробности стенда? Напишите нам.

Глоссарий

PostgreSQL
Популярная открытая СУБД. В нашем стенде она служит долговременной памятью системы: записанный туда заказ переживает падения и перезапуски.
RabbitMQ
Широко используемый брокер сообщений. Представьте его как почту между сервисами: принимает сообщения, держит их в очередях и доставляет тем, кто подписался.
Транзакция
Группа изменений в БД, которые проходят вместе или не проходят вовсе. Если что-то идёт не так на полпути, БД откатывает всю группу, и никто никогда не видит половину заказа.
Коммит
Момент, когда транзакция становится окончательной. До коммита её изменения ещё могут исчезнуть, после него они переживают даже отключение питания.
Событие
Короткое сообщение о том, что что-то произошло, например «создан заказ 42». Другие сервисы слушают события и реагируют на них, скажем, выставляют клиенту счёт.
Фантомное событие
Объявление о том, чего не было. Событие ушло, но изменение в БД, которое оно описывает, было отменено, и другие сервисы работают с несуществующим заказом.
Брокер сообщений
Посредник, который переносит сообщения между сервисами, в нашем случае RabbitMQ. Пока он недоступен, сервисы не могут ни отправлять через него сообщения, ни получать их.
Транзакционный outbox
Сервис пишет исходящее сообщение в таблицу своей БД в той же транзакции, что и заказ, а отдельный воркер отправляет его позже. Заказ и его сообщение сохраняются вместе или не сохраняются вовсе.
Relay
Фоновый воркер, который забирает неотправленные сообщения из таблицы outbox, передаёт их брокеру и помечает как отправленные.
Разделение сети
Сетевой сбой, который разрезает систему на части, не способные достать друг друга, при этом каждая часть продолжает работать.
Убийство процесса
Резкая остановка работающей программы снаружи, как выдернуть вилку из розетки. У программы нет шанса закончить начатое или прибраться.
Как минимум один раз
Обещание доставки: каждое сообщение приходит, но некоторые могут прийти дважды. Тому, кто принимает сообщения, приходится справляться с повторами.
Не более одного раза
Зеркальное обещание: сообщение никогда не приходит дважды, но может не прийти вовсе.
Консьюмер
Сервис на принимающей стороне. Забирает сообщения из брокера и действует по ним: у нас он записывает каждый заказ в биллинговый журнал и выставляет клиенту счёт.
Подтверждение (ack)
Квитанция консьюмера брокеру: «готово, можешь удалить это сообщение». Пока брокер не получит её, он держит сообщение у себя и отдаёт его снова, если консьюмер исчезает.
Таблица inbox
Таблица, в которую консьюмер записывает id каждого обработанного сообщения, в той же транзакции, что и саму работу. Повтор находит свой id уже там и пропускается.
Идемпотентный
Даёт один и тот же результат, сколько ни повторяй. Нажать кнопку лифта пять раз всё равно вызовет один лифт.
Ключ идемпотентности
Уникальная метка, которая отправляется вместе с запросом, скажем номер заказа. Принимающая система запоминает увиденные метки и игнорирует повтор, так что повторный платёж списывается только один раз.
Дедупликация
Распознавание копий уже обработанного сообщения и их отбрасывание.
Опрос (polling)
Спрашивать «есть ли что-то новое?» снова и снова через заданный интервал, как заглядывать в почтовый ящик каждые несколько минут. Короткие интервалы реагируют быстро, но тратят работу, даже когда ничего не пришло.
Захват изменений данных (CDC)
Следить за собственным журналом изменений БД по мере их появления, так что новые строки замечаются без явного запроса. Быстро реагирует и ничего не стоит, пока ничего не меняется.
Журнал предзаписи (WAL)
Дневник БД. Каждое изменение сначала пишется туда, до того как попасть в таблицы: это позволяет БД восстановиться после падения, а захвату изменений данных видеть новые строки.
Уведомление от БД
Встроенный сигнал PostgreSQL (LISTEN/NOTIFY). БД пингует всех слушателей, как только новые данные закоммичены, и relay может проснуться сразу.
Задержка (latency)
Время от момента, когда заказ закоммичен, до момента, когда биллинг-сервис его записал.
Медиана
Среднее значение по порядку: половина замеров была быстрее, половина медленнее. Несколько экстремальных значений не могут дёрнуть её так, как они дёргают среднее арифметическое.
Политика повторов
Правило, что делать после неудачной попытки: сколько раз пробовать ещё и сколько ждать между попытками.
Экспоненциальный backoff
Ждать вдвое дольше после каждой неудачной попытки: 1 с, 2 с, 4 с, 8 с и так далее. Он щадит систему, которой тяжело, но долгое ожидание может проспать момент, когда она восстанавливается.
Восстановление по пробам
Пока зависимость лежит, настоящая работа ставится на паузу, а одиночный дешёвый тест-вызов (проба) время от времени проверяет, вернулась ли она. Работа возобновляется, как только проба проходит.
Ограниченные повторы
Повторяем фиксированное число раз, затем сдаёмся. Сообщение, у которого попытки кончились, переезжает в очередь недоставленных сообщений.
Очередь недоставленных сообщений (DLQ)
Побочная очередь для сообщений, на которых система сдалась. Они хранятся, и каждому нужен человек, чтобы посмотреть и переотправить.
Предохранитель (circuit breaker)
Работает как домашний автомат в электрощите: после череды сбоев срабатывает и блокирует новые вызовы к сломанной зависимости, что даёт ей место для восстановления.
SIGKILL
Сигнал операционной системы «остановись немедленно». Процесс завершается на месте, не сохраняя и не прибираясь, почти как отключение питания для одной программы.
Хвостовая задержка
Задержка самых медленных запросов, например самых медленных 5 %. Средние значения её прячут, а ждут её именно самые невезучие клиенты.
Двойная запись
Запись одного и того же факта в две разные системы по очереди, у нас в БД и в брокер, без общей транзакции на обе. Если программа останавливается между двумя записями, они расходятся.
Джиттер
Немного случайности, добавленной к каждой задержке повтора, чтобы тысячи клиентов не повторяли все в один и тот же миг и не роняли восстанавливающийся сервис снова.
Toxiproxy
Инструмент, который стоит в сети между двумя сервисами и ведёт себя плохо по команде: он может оборвать, замедлить или сломать соединение, так что сбои можно разыгрывать намеренно.
Верификатор моделей TLA+ (model checker)
Инструмент, который берёт точное описание протокола, перебирает все возможные порядки его шагов и сообщает о любой последовательности, которая нарушает заявленное правило, ещё до того, как реальный код запущен.

Ещё из блога

Hyperion Minecraft Launcher

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

Релиз проекта Ёж

Наш коллега вывел в продакшен своё приложение UPS.

Все записи