ADR-0020: Паттерн outbox для публикации событий в Kafka

Status: accepted Date: 2026-04-17 Deciders: команда проекта

Контекст

В P5 принципов и в event-sourcing.md зафиксировано: запись события в event_store и публикация в Kafka должны быть атомарны относительно записи состояния агрегата. Без этого мы получаем классические аномалии:

  • A: данные записаны в PG, событие не опубликовано → проекции (ES/CH) рассинхронизированы.
  • B: событие опубликовано, транзакция отменена → “phantom event” у consumer’ов.

Существуют три устоявшихся подхода:

  1. Dual write (record-and-publish из application).
  2. CDC (Change Data Capture, например Debezium читает PG WAL).
  3. Outbox pattern (запись события в outbox-таблицу в одной транзакции, отдельный relay читает таблицу и публикует в Kafka).

Решение требует ADR — это фундаментальный архитектурный выбор, влияющий на:

  • латентность propagation events;
  • операционную сложность (запуск Debezium + Kafka Connect vs in-process relay);
  • семантику доставки (at-least-once vs exactly-once);
  • обработку schema evolution.

Решение

Outbox pattern с in-process Outbox Relay (Go).

Контракт записи

Все мутации event-sourced агрегатов выполняются через единственный код-путь:

BEGIN;
  INSERT INTO event_store (...) VALUES (...);
  -- опционально: UPDATE projection ...;
  INSERT INTO outbox (event_id, topic, partition_key, payload, headers)
         VALUES (...);
COMMIT;

Прямые INSERT/UPDATE в обход event_store для event-sourced таблиц запрещены (проверяется CI-линтером + grants на роль app_writer).

Outbox Relay

  • Реализация: in-process worker в cmd/outbox-relay.
  • Лидерство: distributed lock в Redis (tracium:outbox-relay:leader), TTL 30s, refresh каждые 10s. Только лидер публикует.
  • Чтение: SELECT ... FROM outbox WHERE published_at IS NULL ORDER BY id LIMIT 500 FOR UPDATE SKIP LOCKED.
  • Публикация: batch в Kafka producer (acks=all, enable.idempotence=true).
  • Подтверждение: после ACK от Kafka — UPDATE outbox SET published_at = now(), attempts = attempts + 1.
  • Failure: при ошибке — attempts++, exponential backoff (base 1s, max 5min). После attempts ≥ 10 — алерт outbox_stuck_total{topic}.

Гарантии и ordering

  • At-least-once delivery. Consumer’ы должны быть идемпотентны (ключ event_id в headers).
  • Per-aggregate ordering: partition_key = aggregate_id, что гарантирует порядок в рамках одного агрегата.
  • Cross-aggregate ordering не гарантируется — это явное архитектурное ограничение.
  • Consumer dedup: рекомендация — хранить last_seen_event_id per aggregate_id или использовать Kafka transactions на consumer side.

Эволюция схемы

  • Поле payload — JSONB. Schema привязана к топику через schemas/events/<topic>.json.
  • Эволюция: см. ADR-0003 + event-sourcing.md (optional add — non-breaking, drop/rename — .v2 топик с двойной записью).

NFR

МетрикаЦель
Outbox lag (enqueued_at → published_at)p95 < 1 сек, p99 < 5 сек
Outbox stuck rows (attempts ≥ 5)алерт > 0 в течение 5 мин
Outbox table size (unpublished)алерт > 10 000 строк
Throughput (single relay)≥ 5 000 events/sec на одной реплике

При недостаточном throughput — переход на partitioned outbox (несколько таблиц, sharding по hash(aggregate_id)) и несколько relay-реплик с partition-affinity.

Последствия

Плюсы

  • Атомарность записи и публикации без двухфазного коммита.
  • Полностью внутри стека Go + PG + Kafka (нет дополнительных компонент типа Debezium / Kafka Connect).
  • Прямой контроль над форматом сериализации, headers, partition key — без CDC-плагинов.
  • Простая локальная разработка: docker-compose up — всё работает, не нужен Debezium на dev-машине.
  • Удобно для replay: outbox хранит payload, при потере Kafka топика можно republish.

Минусы

  • Outbox relay — отдельный процесс, который надо мониторить (но это копеечно по сравнению с Debezium operations).
  • При выпадении лидера relay — задержка в propagation на TTL distributed lock (≤ 30s) — приемлемо.
  • Outbox-таблица растёт; нужна процедура архивирования/очистки published строк (cleanup job старше 7 дней).

Нейтральные последствия

  • При очень большой нагрузке (десятки тысяч events/sec) можем переключиться на CDC (Debezium) без изменения доменного кода — outbox продолжит писаться, Debezium может стать дополнительным/альтернативным publisher’ом.

Рассмотренные альтернативы

Dual write (publish из application)

Не атомарно. Гарантированно теряем события или порождаем фантомные. Категорически нет.

CDC через Debezium

  • Плюсы: нет outbox-таблицы, нет relay-процесса, события вытаскиваются из WAL.
  • Минусы:
    • Operational cost: Debezium + Kafka Connect — отдельный кластер, отдельная эксплуатация.
    • Контроль формата: Debezium шлёт row-as-is; для доменных событий нужны дополнительные SMT (single message transforms) или intermediate transformer.
    • Сложность local dev: нужен Debezium в docker-compose, что увеличивает порог входа.
    • Schema control: завязан на schema row’а в PG, а не на доменное событие.
  • На текущем масштабе — выгоды не оправдывают сложность. Может быть пересмотрено в Phase 5+.

Transactional Outbox через PG LISTEN/NOTIFY

  • Плюсы: меньше latency, нет polling.
  • Минусы: NOTIFY не гарантирует доставку при разрыве соединения, нет персистентного буфера. Требует всё равно polling-fallback. Усложняет код без существенной выгоды.

Kafka Transactions (exactly-once с distributed XA)

PostgreSQL и Kafka не участвуют в общей XA-транзакции реалистично; псевдо-XA через outbox + idempotent consumer достигает практически того же эффекта проще.

Ссылки

  • ADR-0002 (Kafka as event bus).
  • ADR-0003 (Event Sourcing для canonical).
  • ADR-0004 (PostgreSQL as primary store and event store).
  • ../event-sourcing.md — секция “Транзакция записи”.
  • Principles P5.