Перейти к содержанию

Transactional Outbox

Transactional Outbox — это паттерн надежной публикации событий. Он нужен, когда сервис меняет свои данные в БД и должен сообщить об этом другим сервисам через Kafka или другой брокер. Главная идея в том, что сервис не пытается сразу отправить событие в брокер в середине бизнес-логики. Вместо этого он в той же локальной транзакции пишет событие в таблицу outbox, а потом отдельный процесс публикует это событие наружу.

Главная цель: надежно зафиксировать намерение отправить событие вместе с локальным бизнес-commit.


Какую проблему решает Outbox

Outbox решает dual write problem. Типичный плохой сценарий:

1. сохранить заказ в своей БД
2. отправить OrderCreated в Kafka

Это две независимые записи в две разные системы. Если приложение упадет между шагами, то БД уже может быть committed, а события нет, или наоборот, событие могло уйти, а локальная модель данных не пришла к тому состоянию, на которое рассчитывали. Именно так система разъезжается по состоянию.

Почему нельзя просто сделать один общий @Transactional:

  • БД и брокер — разные системы;
  • глобальная XA-транзакция часто недоступна или не нужна;
  • в микросервисах это обычно плохой tradeoff.

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


Как работает Outbox

Вместо схемы:

save business data
publish event directly

используется схема:

save business data
save outbox record
commit local transaction
publish later from outbox

То есть в одной локальной транзакции фиксируются:

  • бизнес-изменение;
  • намерение отправить событие.

Именно это делает паттерн надежным.

Полный поток обычно выглядит так:

Application transaction:
  insert/update business tables
  insert into outbox(...)
  COMMIT

Relay:
  read outbox
  publish to broker
  mark as published / advance state

Если приложение упало до commit, нет ни бизнес-изменения, ни outbox record. Если приложение упало после commit, но до publish, событие не потеряно: оно лежит в outbox и будет отправлено позже.


Что хранится в Outbox

Обычно таблица outbox содержит:

  • id
  • aggregate_type
  • aggregate_id
  • event_type
  • payload
  • headers или metadata
  • status
  • created_at
  • published_at
  • retry_count
  • error_message

Часто добавляют:

  • topic
  • partition_key
  • correlation_id
  • trace_id
  • saga_id
  • next_attempt_at

Пример:

{
  "id": "evt-001",
  "event_type": "OrderCreated",
  "aggregate_type": "Order",
  "aggregate_id": "order-123",
  "partition_key": "order-123",
  "payload": {
    "orderId": "order-123",
    "userId": "u-55",
    "amount": 4500
  },
  "created_at": "2026-04-18T10:15:00Z",
  "published_at": null
}

Практический смысл полей такой:

  • id — event id для dedup/idempotency;
  • aggregate_id — к какой сущности относится событие;
  • partition_key — как маршрутизировать в Kafka;
  • correlation_id / saga_id — как трассировать бизнес-flow;
  • retry_count — сколько раз relay пытался отправить;
  • status — где сейчас находится сообщение.

Кто публикует из Outbox

Есть два самых частых подхода.

Polling Publisher — это отдельный воркер, который периодически читает outbox, берет пачку непубликованных сообщений, публикует их в брокер, при успехе обновляет статус, а при ошибке увеличивает retry_count.

Плюсы Polling Publisher:

  • просто реализовать;
  • не нужен отдельный CDC-стек;
  • легко понять и отлаживать.

Минусы:

  • есть задержка между COMMIT и фактической публикацией;
  • надо решать конкуренцию между несколькими воркерами;
  • polling может давать лишнюю нагрузку на БД.

CDC / Debezium — это подход, в котором приложение только пишет строку в outbox, а дальше инфраструктура читает изменения БД через WAL/binlog, превращает новые outbox rows в события и публикует их в Kafka. Это типичный сценарий для Debezium Outbox Event Router.

Плюсы CDC / Debezium:

  • меньше ручного кода публикации;
  • надежная интеграция с Kafka-экосистемой;
  • приложение остается проще.

Минусы:

  • больше инфраструктуры;
  • выше operational complexity;
  • избыточно для маленьких проектов.

Почему дубликаты все равно возможны

Outbox не делает систему magically exactly-once.

Типичный сценарий:

  1. relay прочитал запись из outbox;
  2. успешно отправил ее в Kafka;
  3. упал до обновления статуса в БД.

После рестарта он увидит запись как еще не опубликованную и отправит ее снова.

Значит реальная семантика обычно такая:

at least once publish

Следствие:

  • downstream consumer должен быть идемпотентным;
  • event id должен быть стабильным;
  • deduplication надо считать нормальной частью дизайна.

Главная мысль:

Outbox решает потерю исходящего события, но не отменяет дубликаты.


Outbox И Порядок Событий

Важно продумать не только надежность, но и ordering. Если события по одной сущности должны идти последовательно, нужно использовать стабильный partition_key, например orderId, и следить, чтобы relay не ломал ожидаемый порядок для одного aggregate.

Важно помнить:

  • глобального порядка все равно нет;
  • ordering обычно имеет смысл только в рамках одной сущности и одной partition.

То есть Outbox не снимает вопрос порядка событий, а просто переносит его в явный архитектурный дизайн.


Outbox, Inbox И Saga

Outbox отвечает на вопрос:

как мне надежно отправить событие наружу после локального commit?

Inbox отвечает на вопрос:

как мне надежно обработать входящее событие и не сделать это дважды?

Очень часто они используются вместе:

Service A:
  local transaction
  + outbox record

Kafka

Service B:
  check inbox / processed_messages
  local transaction
  mark processed

Именно эта связка обычно дает реальную надежность в event-driven flow.

Saga очень часто строится на событиях и командах. Если сервис сделал локальный commit, но событие о следующем шаге потерялось, вся сага может зависнуть. Поэтому в реальных микросервисах связка:

  • Saga
  • Outbox
  • idempotent consumers

встречается постоянно.


Ограничения Outbox

Outbox не является глобальной distributed transaction, не дает exactly-once across the whole system, не отменяет необходимость в idempotency, добавляет таблицу, relay и operational concerns, а между COMMIT и publish почти всегда есть небольшой lag.

То есть Outbox решает конкретную проблему:

надежно зафиксировать намерение отправить событие вместе с локальным бизнес-commit.