Сервис сохранил заказ и должен сообщить об этом складу. Между двумя действиями упал процесс. В базе заказ есть, а склад о нём не знает. Если поменять действия местами, появится обратная проблема: сообщение ушло, но транзакция заказа откатилась.
Transactional outbox переносит намерение отправить событие в ту же базу, где происходит бизнес-изменение. Документация Debezium описывает outbox как способ согласовать внутреннее состояние сервиса с событиями для других систем. Debezium Outbox Event Router 2.7
Что именно попадает в транзакцию
Представим учебный сервис бронирования. В одной транзакции он создаёт бронь и строку события. Сам сетевой вызов брокера в эту транзакцию не входит.
BEGIN;
INSERT INTO reservations (id, room_id, status)
VALUES ('r-demo-17', 'room-demo-2', 'confirmed');
INSERT INTO outbox (id, aggregate_id, event_type, payload)
VALUES (
'event-demo-91',
'r-demo-17',
'reservation.confirmed.v1',
'{"reservation_id":"r-demo-17","room_id":"room-demo-2"}'
);
COMMIT;
Названия и схема здесь учебные, это не конфигурация Debezium. Структура конкретной outbox-таблицы зависит от выбранного доставщика. Важное свойство примера: либо обе записи фиксируются, либо не фиксируется ни одна. Из этого не следует, что получатель уже увидел событие.
После фиксации отдельный процесс читает outbox и отправляет сообщения. Это может быть опрос таблицы или чтение изменений через CDC. В обоих случаях нужно заранее ответить на вопросы: кто забирает запись, как несколько исполнителей делят работу и по какому признаку запись можно считать доставленной.
Почему дубль всё равно возможен
Исполнитель отправил событие. Брокер подтвердил приём, но процесс упал до сохранения подтверждения у себя. После перезапуска запись выглядит недоставленной и уходит повторно.
Стабильный идентификатор события позволяет получателю узнавать такой повтор. В Debezium у outbox-события предусмотрен отдельный ID, а ключ агрегата используется при маршрутизации сообщений. Это полезные элементы контракта, но не автоматическая гарантия однократного бизнес-эффекта у потребителя. Описание полей outbox
Для учебной брони получатель может в одной своей транзакции записать ID обработанного события и обновить локальную проекцию. Если эти два действия разнести, окно потери появится уже на принимающей стороне. Внешний эффект вроде отправки письма потребует отдельного решения: запись в локальной таблице сама по себе не делает почтовый API идемпотентным.
Порядок — отдельная задача
События «бронь подтверждена» и «бронь отменена» относятся к одному объекту. Параллельная доставка и повторная обработка не должны вернуть его в старое состояние. Для этого в контракте можно использовать версию агрегата и правило применения изменений. Конкретное решение зависит от того, передаётся полное состояние или последовательность операций.
В учебном варианте получатель принимает состояние только с большей версией. Пропуск версии тогда нужно обнаружить отдельно: возможно, потерянное событие содержало важный самостоятельный эффект. Поэтому «брать самое новое» подходит не для любого процесса.
Проверять границы падения
Вместо одного теста успешной доставки полезно пройти четыре точки: до COMMIT, после COMMIT, после отправки брокеру и после применения потребителем. На каждом шаге процесс условно останавливают и запускают повторную обработку на синтетических данных.
Ожидания формулируются через состояние: у подтверждённой брони есть событие, у откатившейся его нет, повтор не создаёт второй бизнес-эффект, отставание доставщика заметно. Возраст самой старой необработанной записи часто полезнее одного счётчика ошибок: очередь может расти, пока каждый отдельный запрос формально успешен.
Outbox оправдан там, где цена потерянной связи между записью и событием выше стоимости дополнительного механизма доставки. Он устраняет конкретное окно несогласованности, но требует честно спроектировать повторы, порядок и восстановление.