GH GambleHub

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
💡 `FOR UPDATE SKIP LOCKED` исключает конкуренцию паблишеров.

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 поведение, снижая сложность распределенных транзакций и повышая устойчивость системы к сбоям и пиковым нагрузкам.

Contact

Свяжитесь с нами

Обращайтесь по любым вопросам или за поддержкой.Мы всегда готовы помочь!

Telegram
@Gamble_GC
Начать интеграцию

Email — обязателен. Telegram или WhatsApp — по желанию.

Ваше имя необязательно
Email необязательно
Тема необязательно
Сообщение необязательно
Telegram необязательно
@
Если укажете Telegram — мы ответим и там, в дополнение к Email.
WhatsApp необязательно
Формат: +код страны и номер (например, +380XXXXXXXXX).

Нажимая кнопку, вы соглашаетесь на обработку данных.