Outbox-патерн
Outbox - це архітектурний патерн, при якому доменний сервіс записує бізнес-зміну і відповідну подію в одній локальній транзакції в своє сховище. Публікація події в зовнішню шину/чергу виконується асинхронно окремим безпечним процесом (publisher), що читає таблицю'outbox'і ретранслює записи. Такий підхід усуває гонку «спочатку в БД, потім в шину» і забезпечує надійну доставку навіть при збоях.
1) Коли застосовувати
Підходить:- Мікросервіси та модульні моноліти з подіями між контекстами.
- Потрібно гарантувати, що «стан зафіксовано ↔ подія загубитися не може».
- Потрібні ідемпотентність і контрольована повторна доставка.
- Критичні жорсткі глобальні транзакції на декількох ресурсах (краще ТСС/саги з явними контрактами).
- Немає виділеного джерела істини (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 поведінку, знижуючи складність розподілених транзакцій і підвищуючи стійкість системи до збоїв і пікових навантажень.