- Transactional Outbox. Как не потерять сообщения
- Two-Phased Commit и eXtended Architecture
В распределённых системах постоянно встает одна и та же задача: как атомарно изменить данные в локальной базе и надёжно уведомить другие сервисы через брокер сообщений?
Возьмём классический пример интернет‑магазина: пользователь создаёт заказ. Нужно:
- Сохранить запись о заказе в таблицу
orders(Postgres/MySQL). - Отправить событие
OrderCreatedв очередь (Kafka/RabbitMQ и т.п.), чтобы сервис доставки начал работу.
Наивная реализация приводит к проблеме dual write (двойной записи) - рассинхронизации между базой и брокером.
Почему простые решения не работают
Рассмотрим типичные сценарии и риски.
Сценарий A - сначала отправляем, потом пишем.
- Мы отправили сообщение в брокер, а затем при записи в БД произошла ошибка (constraint, сетевой сбой); транзакция откатилась.
- Риск: сервис-получатель уже начал обработку, а запись в базе не появилась - рассинхронизация.
Сценарий B - сначала пишем, потом отправляем.
- Запись в БД зафиксирована, но отправка в брокер провалилась (брокер недоступен, таймаут).
- Риск: запись есть, а остальные сервисы о ней не знают.
Сценарий C - отправка внутри транзакции БД.
- Делать внешние сетевые вызовы внутри транзакции - антипаттерн: держатся блокировки дольше, возрастают задержки и риск дедлоков; при успешной отправке и неуспешном коммите всё равно получим рассинхронизацию.
Итог: нам нужна семантика «сообщения публикуются тогда и только тогда, когда транзакция БД успешно закоммичена»; именно это решает паттерн Transactional Outbox.
Паттерн Transactional Outbox
Вместо прямой отправки в брокер - сохраняем сообщение в локальной таблице outbox в рамках той же транзакции, где сохраняется бизнес‑сущность (orders). Это использует гарантию атомарности реляционной БД: либо коммитится и заказ, и запись в outbox, либо ничего.
Шаги:
- Открыть транзакцию БД.
- Сохранить сущность
orders. - В этой же транзакции сформировать и вставить запись в
outbox. - Коммит.
- После этого отдельный асинхронный процесс (Relay/Publisher) берёт записи из
outboxи отправляет их в брокер.
Relay: как сообщения попадают в брокер
outbox - это источник правды, но нужно отдельный процесс (Relay), который: опрашивает outbox, публикует в брокер и отмечает запись как отправленную.
Два популярных подхода:
Polling (SELECT + publish)
Простой и широко используемый.
Плюсы: простота, независимость от БД‑версии.
Минусы: polling‑нагрузка, лаг между коммитом и публикацией.
CDC (Change Data Capture) / WAL tailing
Инструменты вроде Debezium читают WAL/redo log и публикуют события в Kafka или другую систему.
Плюсы: низкий лаг, порядок транзакций, отсутствие polling‑нагрузки.
Минусы: более сложная настройка и зависимость от инфраструктуры.
Выбор зависит от нагрузки, требуемого лага и операционных возможностей команды.
Гарантии доставки
Существует три классических семантики доставки:
- At-most-once: сообщение может быть потеряно, но дубликатов нет.
- At-least-once: сообщение обязательно будет доставлено, но возможны дубликаты.
- Exactly-once: сообщение доставлено и обработано ровно один раз.
Важно уточнить: end‑to‑end exactly‑once в гетерогенной распределённой системе - крайне сложно обеспечить; некоторые платформы (например, Kafka с транзакционным продюсером и идемпотентным продюсером) дают exactly‑once semantics (EOS) внутри собственной экосистемы (Kafka → Kafka); но как только вы выходите из этой экосистемы (БД → Kafka → внешний сервис), гарантии становятся сложнее и обычно стремятся к at‑least‑once.
Transactional Outbox по своей природе даёт at‑least‑once: при сбое Relay может повторно отправить то же сообщение, поэтому получатель должен быть готов к дубликатам.
Борьба с дубликатами: идемпотентность получателя
Получатель должен быть идемпотентен.
Уникальные идентификаторы (message_id): отправитель генерирует message_id и сохраняет его в outbox. Получатель проверяет наличие message_id в своей таблице processed_messages и, если запись уже есть, игнорирует повтор.
Ordering: что гарантирует Outbox
Outbox фиксирует порядок коммитов в локальной БД. Но порядок доставки в брокер зависит от:
- стратегии публикации Relay (batching, параллелизм),
- партиционирования в брокере (например, в Kafka порядок гарантируется только внутри одной партиции и одного ключа).
Если важно сохранить порядок для одного агрегата (например, order_id), используйте этот агрегат в качестве partition key и гарантируйте, что сообщения одного агрегата попадают в одну партицию.
Альтернативы и когда их выбирать
- Distributed transactions / 2PC (XA): даёт strong consistency, но сложен и плохо масштабируется.
- SAGA (choreography/orchestration): хорошо для долгих бизнес‑процессов с компенсирующими транзакциями.
- CDC (Debezium/WAL) → Kafka Connect: отличная альтернатива для high‑throughput систем, минимизирует lag.
Заключение
Transactional Outbox - надёжный и практичный паттерн для обеспечения целостности данных при межсервисном взаимодействии. Он даёт гарантию, что запись в локальной БД и подготовленное сообщение будут зафиксированы атомарно; публикация в брокер производится асинхронно Relay‑процессом.
Outbox обычно реализуется как at‑least‑once решение, поэтому важно проектировать потребителей идемпотентными и продумывать эксплуатационные аспекты: мониторинг, очистку, резервные стратегии (CDC vs polling) и соответствие требованиям по безопасности/конфиденциальности.
Комментарии в Telegram-группе!