Dual write — это commit в базу и publish в брокер без общей транзакции: можно получить заказ без события или событие без заказа. Outbox пишет событие в той же транзакции, что и данные; публикатор читает таблицу или WAL после commit.
Надёжная доставка kafka rabbitmq собеседование
Сервис сохранил заказ в Postgres и шлёт order.created в Kafka или Rabbit. Между двумя шагами нет общей транзакции. Это dual write: два хранилища, один бизнес-факт, ноль общей атомарности.
Процесс упал после INSERT и до publish — заказ есть, обработчиков нет. Упал после publish и до commit — событие есть, заказа нет, консьюмер создаёт фантом. «Сначала брокер, потом БД» врёт симметрично.
Пример. HTTP-хендлер сделал INSERT заказа, потом Send в Kafka, потом упал. Заказ в БД есть, события нет. Наоборот: Send прошёл, COMMIT не прошёл — событие есть, заказа нет.
Transactional outbox не делает мир exactly-once. Он делает так, чтобы факт «заказ создан» и факт «событие должно уйти» появились вместе. Доставка до брокера — отдельный надёжный цикл. Получатель всё равно идемпотентен: публикатор может послать дважды.
Как устроен outbox
В одной транзакции с бизнес-строкой: INSERT в outbox(id, destination, payload, created_at). Идентификатор события стабильный — часто равен id агрегата или UUID. Потом он станет ключом Kafka и ключом inbox на той стороне.
После commit поллер читает неопубликованные строки. Или CDC читает WAL. Успех брокера — пометить published или удалить строку. Хендлер HTTP в брокер напрямую не ходит. Иначе вы снова в dual write, только из хендлера.
BEGIN;
INSERT INTO orders (id, status) VALUES ($1, 'new');
INSERT INTO outbox (id, destination, payload)
VALUES ($1, 'orders', '{"event":"created"}');
COMMIT;
-- воркер публикатора
BEGIN;
SELECT id, destination, payload
FROM outbox
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT 50;
-- publish в Kafka/Rabbit с тем же id как ключом
DELETE FROM outbox WHERE id = ANY($1);
COMMIT;
Событие и заказ в одной транзакции Postgres. Воркер после commit забирает outbox и публикует в брокер.
Пример. Заказ 15 и строка outbox с тем же id коммитятся вместе. Воркер читает outbox, шлёт в Kafka ключом 15, удаляет строку. Если воркер упал после publish и до DELETE — пошлёт ещё раз. Поэтому получатель смотрит на event_id.
Поллер, CDC и inbox
Поллер проще операционно. Легче упереться в локи и лаг. CDC ближе к «не забыли вставить в outbox»: событие берётся из WAL. Минус CDC: логический слот плюс медленный консьюмер растят WAL на primary.
Inbox на приёме — зеркало. UNIQUE(event_id): повтор публикации не создаёт второй заказ и не снимает деньги дважды. Outbox без inbox закрывает дыру «забыли послать» и не закрывает дыру «послали дважды». Пара идёт вместе.
Несколько реплик публикатора без SKIP LOCKED будут стоп-лочить одну голову очереди или читать одни и те же строки. «Транзакция Kafka + SQL» в обычном стеке без XA нет. Не обещайте её вместо outbox.
Типичная ошибка
Считать, что outbox делает consumer exactly-once. Дубль публикации всё равно возможен: получатель обязан быть идемпотентен. Поллер без SKIP LOCKED под несколькими репликами. CDC-слот, который никто не читает, и диск primary, который внезапно кончился.
На собеседовании
Сначала dual write: нет общей транзакции между БД и брокером. Outbox — INSERT события в той же TX, что бизнес-строка. Дальше поллер или CDC. И обязательно consumer с upsert по event_id: публикация может повториться.