1. Transactional Outbox. Как не потерять сообщения
  2. Two-Phased Commit и eXtended Architecture

В распределённых системах постоянно встает одна и та же задача: как атомарно изменить данные в локальной базе и надёжно уведомить другие сервисы через брокер сообщений?

Возьмём классический пример интернет‑магазина: пользователь создаёт заказ. Нужно:

  1. Сохранить запись о заказе в таблицу orders (Postgres/MySQL).
  2. Отправить событие OrderCreated в очередь (Kafka/RabbitMQ и т.п.), чтобы сервис доставки начал работу.

Наивная реализация приводит к проблеме dual write (двойной записи) - рассинхронизации между базой и брокером.

Почему простые решения не работают

Рассмотрим типичные сценарии и риски.

Сценарий A - сначала отправляем, потом пишем.

  • Мы отправили сообщение в брокер, а затем при записи в БД произошла ошибка (constraint, сетевой сбой); транзакция откатилась.
  • Риск: сервис-получатель уже начал обработку, а запись в базе не появилась - рассинхронизация.

Сценарий B - сначала пишем, потом отправляем.

  • Запись в БД зафиксирована, но отправка в брокер провалилась (брокер недоступен, таймаут).
  • Риск: запись есть, а остальные сервисы о ней не знают.

Сценарий C - отправка внутри транзакции БД.

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

Итог: нам нужна семантика «сообщения публикуются тогда и только тогда, когда транзакция БД успешно закоммичена»; именно это решает паттерн Transactional Outbox.

Паттерн Transactional Outbox

Вместо прямой отправки в брокер - сохраняем сообщение в локальной таблице outbox в рамках той же транзакции, где сохраняется бизнес‑сущность (orders). Это использует гарантию атомарности реляционной БД: либо коммитится и заказ, и запись в outbox, либо ничего.

Шаги:

  1. Открыть транзакцию БД.
  2. Сохранить сущность orders.
  3. В этой же транзакции сформировать и вставить запись в outbox.
  4. Коммит.
  5. После этого отдельный асинхронный процесс (Relay/Publisher) берёт записи из outbox и отправляет их в брокер.

Transactional 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-группе!