Outbox-паттерн
Outbox — это архитектурный паттерн, при котором доменный сервис записывает бизнес-изменение и соответствующее событие в одной локальной транзакции в свое хранилище. Публикация события во внешнюю шину/очередь выполняется асинхронно отдельным безопасным процессом (publisher), читающим таблицу `outbox` и ретранслирующим записи. Такой подход устраняет гонку «сначала в БД, потом в шину» и обеспечивает надежную доставку даже при сбоях.
1) Когда применять
Подходит:- Микросервисы и модульные монолиты с событиями между контекстами.
- Требуется гарантировать, что «состояние зафиксировано ↔ событие потеряться не может».
- Нужны идемпотентность и контролируемая повторная доставка.
- Критичны жесткие глобальные транзакции на нескольких ресурсах (лучше TCC/саги с явными контрактами).
- Нет выделенного источника истины (state хранится не там, где генерится событие).
2) Цели и свойства
Atomic write: доменная запись + outbox — в одной транзакции.
At-least-once публикация: допускаем повтор, исключаем потерю.
Идемпотентность потребителей: защита от дублей на стороне подписчиков.
Эффективное exactly-once: достигается комбинацией outbox + idempotent consumer + dedup.
Четкая телеметрия: корреляция бизнес-операций и событий.
3) Схема данных (пример)
sql
-- Domain table (example: orders)
CREATE TABLE orders (
id UUID PRIMARY KEY,
tenant_id TEXT NOT NULL,
status TEXT NOT NULL,
total_amount NUMERIC(12,2) NOT NULL,
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
-- Outbox
CREATE TABLE outbox (
id UUID PRIMARY KEY, -- event_id aggregate_type TEXT NOT NULL, -- 'order'
aggregate_id UUID NOT NULL, -- order_id tenant_id TEXT NOT NULL,
type TEXT NOT NULL, -- 'OrderCreated'
payload JSONB NOT NULL, -- serialized headers event JSONB NOT NULL DEFAULT '{}':: jsonb,
occurred_at TIMESTAMP NOT NULL, -- time in domain transaction available_at TIMESTAMP NOT NULL, -- earliest publish time (backoff)
published_at TIMESTAMP, - is filled by the attempts INT NOT NULL DEFAULT 0,
error TEXT
);
CREATE INDEX ON outbox (available_at) WHERE published_at IS NULL;
CREATE INDEX ON outbox (tenant_id, available_at) WHERE published_at IS NULL;
4) Транзакционный шаблон (application layer)
pseudo begin tx domainChange () # INSERT/UPDATE in domain table insert into outbox (event) # event with aggregate/tenant commit tx keys
Если коммит успешен — событие в outbox гарантированно существует. Если приложение упадет после коммита — паблишер догонит.
5) Паблишер (reader → publisher)
Задачи:- Периодически читать непубликованные события (`published_at IS NULL` и `available_at <= now()`), батчами.
- Пытаться публиковать в шину/очередь; при успехе — отмечать `published_at`.
- При ошибке — увеличивать `attempts`, ставить `available_at` на будущее (exponential backoff), писать `error`.
- Уважать лимиты по тенантам/ключам (fairness), не блокировать продуктив.
pseudo loop:
events = select from outbox where published_at is null and available_at <= now()
order by occurred_at limit BATCH_SIZE for update skip locked
for e in events:
try:
broker. publish(topicFor(e), serialize(e. payload), headers(e))
markPublished(e. id, now())
except Retryable:
backoff = computeBackoff(e. attempts)
reschedule(e. id, now()+backoff, attempts+1, last_error)
except NonRetryable:
moveToDLQ (e) or markError (e) # by sleep (POLL_INTERVAL) policy
6) Идемпотентность и дедупликация
На стороне потребителя (Inbox/Idempotency store):sql
CREATE TABLE inbox (
consumer_name TEXT,
event_id UUID,
processed_at TIMESTAMP NOT NULL,
PRIMARY KEY (consumer_name, event_id)
);
Алгоритм: при получении события — сначала попытка `INSERT` в `inbox`; если конфликт ключа — событие уже обработано → «no-op». Далее — бизнес-логика.
На стороне паблишера: `Idempotency-Key` в headers (например, `event_id`), чтобы шина/брокер/прокси могли фильтровать дубликаты.
7) Порядок и причинность
Локальный порядок по `aggregate_id` обеспечивается сортировкой `occurred_at` и публикацией «по ключу».
Для лог-шин с партиционированием — партиционируйте ключом `aggregate_id`/`tenant_id`, чтобы события одного агрегата были в одном партишене.
Если порядок критичен, избегайте межпоточных гонок паблишера по одному ключу.
8) CDC (Change Data Capture)
Вместо активного паблишера можно использовать CDC: движок читает журнал транзакций БД и транслирует строки `outbox` в шину. Плюсы — минимальная нагрузка на БД, точная последовательность, отсутствие поллинга. Минусы — усложнение оперирования и завязка на специфику СУБД. Оба подхода валидны; выбирайте по компетенциям и SLO.
9) Ошибки, DLQ и редрайв
Retryable (сеть, лимиты) — увеличиваем `attempts`, откладываем `available_at` (exponential backoff + джиттер).
Non-retryable (невалидная схема/контракт) — переносим в DLQ/Dead-Letter Topic с богатыми метаданными.
Безопасный редрайв: батчи, rate-limit, валидация схемы, приоритет ниже прод-трафика.
10) Мульти-тенантность и лимиты
Обязательные теги: `tenant_id`, `plan`, `region` — в `outbox.headers`.
Per-tenant fairness: паблишер распределяет «окна» публикаций и лимиты попыток по арендаторам.
Residency: храните outbox в том же регионе, где доменные данные; межрегиональная публикация — только агрегаты/сводки.
11) Безопасность и соответствие
PII-редакция в payload/headers по политике тенанта/региона.
Подпись/шифрование полезной нагрузки, если шина «чужая».
Аудит всех переходов состояния: создано, опубликовано, ошибка, редрайв.
12) Наблюдаемость
Метрики:- Лаг публикации (`now - occurred_at` p50/p95/p99).
- Доля успехов, доля ошибок, распределение причин.
- Размер outbox (кол-во непубликованных), попытки/сек.
- Пер-тенантные графики throughput и lag.
- Корреляция `event_id`/`aggregate_id`/`saga_id`; спаны «db-tx», «publish», «retry».
- Аннотации: `attempt`, `backoff_ms`, `dlq=true`.
- Краткие записи на успех; полные детали на ошибку/редрайв.
13) Тестирование и хаос
Atomicity тест: искусственно «падаем» после коммита доменной транзакции до публикации — событие обязано выйти позже.
Duplicate тест: публикуем один и тот же event несколько раз — консьюмер выполняет ровно один эффект (inbox).
Order тест: пачка событий по одному агрегату — проверка последовательности/идемпотентности.
Chaos: отказ брокера, рост латентности БД, split-brain паблишеров, clock-skew.
14) Конфигурационные шаблоны (пример)
yaml outbox:
poll_interval_ms: 200 batch_size: 200 order_by: occurred_at backoff:
strategy: exponential_full_jitter initial_ms: 250 max_ms: 10_000 max_attempts: 20 fairness:
per_tenant_parallelism: 4 per_key_serial: true
publisher:
rate_limit_per_sec: 500 headers:
idempotency_key: event_id schema_version: v3 dlq:
enabled: true topic: myapp. events. dlq include_metadata:
- error
- attempts
- source_table
- tenant_id
- aggregate_id
15) Интеграция с сагами и ретраями
Outbox — «транспорт безопасности» для шагов саги: локальная транзакция пишет эффект и команду/событие; публикация — надежная и дозируемая.
Политики повторов и backoff должны быть согласованы с `Retry-After` и Circuit Breaker; избегайте «шторма ретраев».
16) Типичные ошибки
Пишут событие после коммита доменного состояния — возможна потеря при падении.
Нет индексов/архива в `outbox` → рост задержки публикации.
Паблишер без `SKIP LOCKED` или без шардирования — конкуренция и блокировки.
Отсутствие идемпотентности у потребителей — дубли и побочные эффекты.
Смешение PII без маскировки в DLQ/логах.
Единая глобальная очередь публикации без fairness — «шумный» тенант тормозит всех.
Отсутствие мониторинга лага → скрытые деградации.
17) Быстрый выбор стратегии
Стартовый уровень: поллинг из БД, батчи по 100–500, full-jitter backoff, inbox у консьюмеров.
Высокая нагрузка: CDC из журнала транзакций, шардирование по `tenant_id/aggregate_id`, WFQ по арендаторам.
Строгий порядок по агрегату: серийная публикация per key (mutex), партиционирование топика ключом.
Комплаенс/PII: шифрование payload, редакция в DLQ, региональные outbox.
18) Чек-лист перед продом
- Доменные изменения и запись в `outbox` происходят в одной транзакции.
- Паблишер обрабатывает батчи, использует `SKIP LOCKED`, backoff с джиттером и лимиты.
- Консьюмеры идемпотентны (таблица `inbox`/дедуп-журнал).
- Настроены DLQ и безопасный редрайв.
- Метрики лага/ошибок и алерты по порогам p95/p99.
- Порядок по ключу гарантирован (партиции/серийность).
- Архив/ретеншн `outbox` и очистка опубликованных записей.
- PII-политики и аудит переходов состояний.
- Тесты на падение между коммитом и публикацией, дубликаты и порядок.
- Документация контрактов события (схемы/версии/совместимость).
Заключение
Outbox-паттерн превращает «хрупкую» связку «БД ↔ шина» в надежный конвейер: атомарная фиксация состояния, гарантированная (пусть и «как минимум один раз») публикация, идемпотентные подписчики и контролируемый редрайв. При правильной телеметрии, лимитах и дисциплине схем он дает практическое exactly-once поведение, снижая сложность распределенных транзакций и повышая устойчивость системы к сбоям и пиковым нагрузкам.