awesome-everything EN
↑ Обратно к восхождению

Архитектура бэкенда

Outbox и inbox: effectively-once через dual-write границу

Суть Прямой dual-write в БД + Kafka ломается в любом порядке. Outbox пишет событие внутри транзакции БД; inbox дедуплицирует на стороне консумера. Вместе дают effectively-once доставку.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 16 min

Order service записывает заказ в Postgres, потом публикует событие “order.created” в Kafka. Между коммитом и публикацией pod падает. Заказ есть в базе. Downstream сервис инвентаря никогда не увидит событие. Заказ завис.

Почему dual-write всегда ломается

Любая последовательность “записать в базу, потом опубликовать в брокер” имеет crash-окно между двумя операциями:

ПоследовательностьCrash точкаРезультат
Запись в БД → публикация в KafkaПосле БД, до KafkaБД обновлена, Kafka молчит — консумеры пропускают событие
Публикация в Kafka → запись в БДПосле Kafka, до БДФантомное событие в Kafka — консумеры видят запись, которой нет

Обе последовательности сломаны. Не существует версии “два отдельных write”, которая атомарна.

Outbox-паттерн: используем базу как брокер

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

BEGIN;
  -- Бизнес-запись
  INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3);

  -- Outbox-запись (та же транзакция)
  INSERT INTO outbox (id, event_type, payload, published)
  VALUES (gen_random_uuid(), 'order.created', $payload, false);
COMMIT;
-- Публикация в Kafka происходит ПОСЛЕ коммита транзакции

Отдельный outbox-relay процесс опрашивает таблицу outbox (или хвостит Write-Ahead Log через Debezium) и публикует неопубликованные строки в Kafka, помечая их published после broker ACK.

Crash-сценарии теперь безопасны:

  • Crash до COMMIT → транзакция откатывается. Ни заказа, ни outbox-строки. Фантомного события нет.
  • Crash после COMMIT, до публикации relay → заказ существует, outbox-строка с published=false. Relay подберёт при следующем опросе.
  • Kafka недоступна → relay ретраит. Outbox накапливается. Заказ цел в БД.
ПодходCrash-safe?Kafka обязательна?Паттерн
БД потом KafkaНет — тихая потеря событияДаАнтипаттерн
Kafka потом БДНет — фантомное событиеДаАнтипаттерн
Outbox (только БД)ДаНет (async relay)Правильный

Реализации relay

Poll-based relay: запрашивает WHERE published = false ORDER BY id LIMIT 1000 каждые несколько секунд. Просто, добавляет latency равную интервалу опроса (обычно 1–5 с).

Debezium (CDC relay): хвостит Postgres Write-Ahead Log через logical replication. Публикует сразу после коммита строки. Sub-second lag. Без overhead опроса. Production-стандарт для высоконагруженных сервисов.

AWS SAM вариант: DynamoDB Streams → Lambda → EventBridge — managed CDC без запуска relay процесса.

Inbox-паттерн: дедупликация на консумере

Relay доставляет at-least-once (может опубликовать дважды, если перезапустился после публикации, но до пометки строки). Консумер должен быть идемпотентным.

Inbox-паттерн: перед применением бизнес-эффекта события запиши его id в таблицу processed_events внутри той же транзакции что бизнес-запись.

BEGIN;
  -- Проверяем, не обрабатывали ли уже
  INSERT INTO processed_events (event_id) VALUES ($1)
    ON CONFLICT (event_id) DO NOTHING;

  -- Если 0 строк затронуто, событие уже обработано — пропускаем
  IF found THEN
    UPDATE inventory SET reserved = reserved - $qty WHERE sku = $sku;
  END IF;
COMMIT;

Если событие придёт второй раз, вторая попытка упрётся в UNIQUE constraint на event_id и бизнес-запись пропускается. Effectively-once поведение с точки зрения консумера.

Обслуживание inbox-таблицы: партиционируй по event_timestamp и удаляй партиции старше retention window брокера (7–14 дней). Без очистки inbox растёт бесконечно.

Dead-letter queue: обработка невосстановимых сбоев

Не каждый сбой восстановим retry. Некорректные данные, нарушение схемы, невыполнимые бизнес-инварианты — это poison messages — никакое количество retry их не починит.

После N попыток обработки (обычно 3–5) перемести сообщение в dead-letter queue (DLQ). Основной конвейер продолжает работу; люди разбирают DLQ.

Основная очередь → [N попыток retry] → DLQ → human review
                                             → ручной replay (после фикса)
                                             → формальный reject (бизнес-правило)

Production-настройки: N = 3–5 попыток, DLQ retention 7–30 дней, алерт на рост DLQ depth. Растущий DLQ — compliance-обязательство: каждая запись — это операция с потенциальными частичными эффектами.

Почему это работает

Почему at-least-once доставка от outbox-relay приемлема даже для платёжных событий? Потому что консумер запускает inbox-паттерн — дедупликацию по event ID до применения любого бизнес-эффекта. Комбинация: атомарность Postgres (гарантирует существование outbox-строки тогда и только тогда, когда бизнес-запись закоммичена) + at-least-once relay + идемпотентный консумер = effectively-once. Ни один компонент не должен быть exactly-once; гарантия возникает из композиции.

Викторина

Зачем нужен outbox-паттерн, если приложение может публиковать в Kafka напрямую из API-обработчика?

Расставь шаги по порядку

Поставь шаги outbox-публикации в правильном порядке:

  1. 1 Приложение открывает транзакцию базы
  2. 2 Приложение делает бизнес-запись (например, вставляет строку заказа)
  3. 3 Та же транзакция вставляет outbox-строку с событием к публикации
  4. 4 Приложение коммитит — бизнес-запись и outbox-строка атомарно видны
  5. 5 Outbox-relay сканирует неопубликованные строки и публикует в Kafka
  6. 6 Relay помечает строку published после получения broker ACK
Викторина

Outbox relay падает после публикации в Kafka, но до пометки outbox-строки как published. Что происходит при перезапуске relay?

Вспомните перед уходом
  1. 01
    Команда хочет exactly-once доставки в Kafka из записи Postgres. Варианты: (a) запись Postgres потом публикация, (b) публикация потом запись, (c) outbox. Почему (c) правильный?
  2. 02
    Что такое inbox-паттерн и от чего он защищает?
  3. 03
    Когда сообщение должно идти в dead-letter queue вместо retry?
Итог

Dual-write проблему — атомарную запись в две системы — нельзя решить последовательностью записей. Outbox-паттерн решает её, записывая событие в базу как outbox-строку в той же транзакции что бизнес-запись — атомарность становится ответственностью базы. Relay (poll-based или Debezium CDC) доставляет события в Kafka at-least-once. Консумер использует inbox-паттерн — дедупликацию по event ID внутри своей транзакции — для effectively-once обработки. Poison messages, которые не могут быть обработаны, идут в dead-letter queue после N попыток; основной конвейер продолжает работу.

Связанные уроки
встречается в179
Продолжить восхождение ↑Конкурентность и архитектура кеша для идемпотентности на масштабе
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.