АрхитектураАрхитектура данныхИнженер по разработке backend-систем

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

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

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

Надёжная доставка обычно имеет семантику at-least-once: событие может быть опубликовано повторно. Поэтому потребители должны обрабатывать его повторно безопасно, например по уникальному идентификатору события.

Исторический контекст

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

Паттерн Transactional Outbox появился как практический способ связать изменение бизнес-состояния с намерением отправить событие без распределённой транзакции между базой данных и брокером. Он использует уже доступную атомарность транзакций одной базы.

Постановка проблемы

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

Такие расхождения приводят к неполному поисковому индексу, неверным остаткам, пропущенным уведомлениям или состоянию downstream-систем, которое нельзя объяснить только данными основной базы. Повторная отправка после каждого сбоя без фиксированного журнала событий также может породить дубликаты.

Подробное решение

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

После фиксации транзакции отдельный relay или воркер выбирает опубликованные в outbox события, передаёт их брокеру и отмечает результат. Если процесс завершился после публикации, но до отметки, событие будет отправлено повторно при следующей попытке. Это нормальная цена отказоустойчивой схемы без общей транзакции с брокером.

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

Outbox необходимо обслуживать: индексировать выборку необработанных записей, ограничивать размер полезной нагрузки, повторять временно неуспешные публикации с контролем нагрузки и удалять либо архивировать подтверждённые события. Для больших объёмов применяют партиционирование или CDC-экстракцию outbox, но при этом сохраняют атомарность записи бизнес-данных и события.

Паттерн не даёт мгновенной публикации и не обеспечивает exactly-once end-to-end. Он также не заменяет согласование схемы событий, мониторинг задержки доставки, контроль переполнения outbox и процедуру исправления неисправимых сообщений.

Ситуация из практики

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

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

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

Что кандидаты часто упускают

  1. Что произойдёт, если relay упадёт после успешной публикации, но до отметки события как отправленного?

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

  2. Почему нельзя считать сам outbox гарантией доставки в брокер?

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

  3. Когда outbox может стать узким местом?

    При высокой частоте событий таблица outbox превращается в горячий журнал: растёт конкуренция за выборку, увеличивается объём индексов и усложняется очистка. Решение выбирают по нагрузке: пакетная отправка, партиционирование, отдельные индексы для необработанных записей, архивирование или CDC outbox. При этом нельзя жертвовать главным свойством паттерна — событие должно записываться в той же транзакции, что и изменение бизнес-состояния.