Transactional Outbox¶
Transactional Outbox — это паттерн надежной публикации событий. Он нужен, когда сервис меняет
свои данные в БД и должен сообщить об этом другим сервисам через Kafka или другой брокер. Главная
идея в том, что сервис не пытается сразу отправить событие в брокер в середине бизнес-логики.
Вместо этого он в той же локальной транзакции пишет событие в таблицу outbox, а потом отдельный
процесс публикует это событие наружу.
Главная цель: надежно зафиксировать намерение отправить событие вместе с локальным бизнес-commit.
Какую проблему решает Outbox¶
Outbox решает dual write problem. Типичный плохой сценарий:
Это две независимые записи в две разные системы. Если приложение упадет между шагами, то БД уже может быть committed, а события нет, или наоборот, событие могло уйти, а локальная модель данных не пришла к тому состоянию, на которое рассчитывали. Именно так система разъезжается по состоянию.
Почему нельзя просто сделать один общий @Transactional:
- БД и брокер — разные системы;
- глобальная XA-транзакция часто недоступна или не нужна;
- в микросервисах это обычно плохой tradeoff.
Outbox как раз и нужен, чтобы сделать надежной исходящую публикацию.
Как работает 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 содержит:
idaggregate_typeaggregate_idevent_typepayloadheadersилиmetadatastatuscreated_atpublished_atretry_counterror_message
Часто добавляют:
topicpartition_keycorrelation_idtrace_idsaga_idnext_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.
Типичный сценарий:
- relay прочитал запись из outbox;
- успешно отправил ее в Kafka;
- упал до обновления статуса в БД.
После рестарта он увидит запись как еще не опубликованную и отправит ее снова.
Значит реальная семантика обычно такая:
at least once publish
Следствие:
- downstream consumer должен быть идемпотентным;
- event id должен быть стабильным;
- deduplication надо считать нормальной частью дизайна.
Главная мысль:
Outbox решает потерю исходящего события, но не отменяет дубликаты.
Outbox И Порядок Событий¶
Важно продумать не только надежность, но и ordering. Если события по одной сущности должны идти
последовательно, нужно использовать стабильный partition_key, например orderId, и следить,
чтобы relay не ломал ожидаемый порядок для одного aggregate.
Важно помнить:
- глобального порядка все равно нет;
- ordering обычно имеет смысл только в рамках одной сущности и одной partition.
То есть Outbox не снимает вопрос порядка событий, а просто переносит его в явный архитектурный дизайн.
Outbox, Inbox И Saga¶
Outbox отвечает на вопрос:
Inbox отвечает на вопрос:
Очень часто они используются вместе:
Service A:
local transaction
+ outbox record
Kafka
Service B:
check inbox / processed_messages
local transaction
mark processed
Именно эта связка обычно дает реальную надежность в event-driven flow.
Saga очень часто строится на событиях и командах. Если сервис сделал локальный commit, но
событие о следующем шаге потерялось, вся сага может зависнуть. Поэтому в реальных микросервисах
связка:
SagaOutbox- idempotent consumers
встречается постоянно.
Ограничения Outbox¶
Outbox не является глобальной distributed transaction, не дает exactly-once across the whole system,
не отменяет необходимость в idempotency, добавляет таблицу, relay и operational concerns, а между
COMMIT и publish почти всегда есть небольшой lag.
То есть Outbox решает конкретную проблему:
надежно зафиксировать намерение отправить событие вместе с локальным бизнес-commit.