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’ов.
Существуют три устоявшихся подхода:
- Dual write (record-and-publish из application).
- CDC (Change Data Capture, например Debezium читает PG WAL).
- 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_idper 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.