Kapitola 16 · Vzory · Outbox Pattern – spolehlivé publikování doménových eventů

Outbox Pattern – spolehlivé publikování doménových eventů

Typická chyba: zapíšete Order do databáze, vzápětí se rozbije RabbitMQ, ale order tam zůstane bez události OrderPlaced. Subscribeři se o objednávce nedozvědí. Outbox Pattern řeší tento dual-write problem na úrovni jedné DB transakce; jeho dvojče Inbox Pattern řeší deduplikaci na straně subscriberů. V Symfony 8 je to jeden Doctrine entity manager, jeden Messenger transport a zhruba 80 řádků kódu.

Autor M. Katuščák
Doba čtení ≈ 28 min
Náročnost náročná
Publikováno · Aktualizováno ·
Obsah kapitoly

V kapitolách o CQRS, Event Sourcingu a ságách jsme opakovaně narazili na stejný předpoklad: když agregát po commitu publikuje doménovou událost, tato událost se spolehlivě dostane do message brokeru a odtud k subscriberům. Tento předpoklad je ovšem zrádný. Mezi zápisem do databáze a dispatchem do Messenger transportu je síťový skok a dva nezávislé systémy – a každý z nich může selhat samostatně. Důsledkem je dual-write problem, jeden z nejčastějších zdrojů tichých nekonzistencí v event-driven architekturách.

Transactional Outbox Pattern je kanonické řešení dual-write problému; jeho symetrický protějšek Idempotent Inbox Pattern zajišťuje deduplikaci na straně subscriberů. V dalších sekcích si projdeme původ vzoru v práci Pata Hellanda Life Beyond Distributed Transactions (2007), schéma outbox tabulky s povinným indexem, kompletní implementaci s Doctrine ORM a Symfony Messenger, dvě varianty relay procesu (polling worker vs. CDC / Debezium) a operační aspekty – outbox lag, kompakce, dead-letter queue. V závěru přidáme migrační postup pro existující projekt a srovnání s alternativami.

16.01 Dual-write problem

Nejjednodušší implementace publikování doménové události vypadá nevinně: po dokončení doménové operace zapíšeme stav do databáze a pak rovnou dispatchneme událost na message bus. Code review takový kód projde bez poznámek – dokud se v produkci nezačnou hromadit ztracené události a stížnosti subscriberů typu „vidím v API objednávku 12345, ale event OrderPlaced mi nikdy nedorazil“.

Problém je v tom, že krok 1 a krok 2 jsou dvě nezávislé transakce ve dvou různých systémech. Jakmile mezi nimi dojde k jakékoliv chybě – síťový timeout, crash workeru, restart aplikace, výpadek brokera, OOM kill PHP procesu – skončíme v jednom ze dvou nesymetrických nekonzistentních stavů:

  • DB write succeeded, broker dispatch failed. Order existuje v databázi, ale event OrderPlaced se nikdy neodeslal. Subscriber kontext (Payment, Warehouse, Notifications) o objednávce neví. Zákazník ji vidí v API, ale platba se nestrhne, sklad nezarezervuje, e-mail nepřijde. Tichá ztráta doménové události. Nejhorší scénář, protože v logu nezůstane žádná stopa „chybějící“ události.
  • Broker dispatch succeeded, DB write failed. Vyskytne se, pokud někdo otočí pořadí (publish před commit) nebo pokud commit selže po dispatchi kvůli optimistickému locku. Subscribery dostanou event o objednávce, která fakticky neexistuje. Read model si přidá řádek, Payment se pokusí strhnout peníze za neexistující order, Notifications odešle e-mail s odkazem na 404. „Phantom event“, který se ve zdrojové DB nestal.

Oba scénáře jsou klasická porušení atomicity napříč dvěma systémy a v event-driven architekturách jsou pravidlem, ne výjimkou. Pat Helland v práci Life Beyond Distributed Transactions: An Apostate's Opinion (2007) tento problém formuloval explicitně: jakmile transakce přesahuje hranici jednoho úložiště, atomicita je iluze, kterou je nutné explicitně obnovit na úrovni aplikační logiky. Chris Richardson na něj navázal v knize Microservices Patterns (2018, kapitola 3), kde Outbox Pattern popisuje jako jediné doporučované řešení dual-write problému v mikroslužbách bez nadbytečné distribuované transakce.

Citace: Helland, P. Life Beyond Distributed Transactions: An Apostate's Opinion, CIDR (2007); Richardson, C. Microservices Patterns, Manning (2018), kapitola 3 – Transactional messaging; Microservices.io – Pattern: Transactional Outbox.

16.02 Transactional Outbox – princip

Princip Outbox Pattern je prostý: nepublikujeme událost přímo do brokera, ale zapíšeme ji do tabulky outbox ve stejné databázi, kde žije doménový stav, a to uvnitř stejné DB transakce, jako úprava agregátu. Buď se tedy úspěšně zapíše obojí (order i jeho event), nebo se nezapíše nic (rollback celé transakce). Atomicita je obnovena – protože jsou oba zápisy v jediném ACID kontextu jedné databáze, ne ve dvou různých systémech.

Samostatný proces (relay worker, někdy nazývaný publisher nebo dispatcher) pak asynchronně tabulku polluje, vybírá řádky se stavem pending a publikuje je do skutečného message brokeru. Po úspěšném publishi řádek označí jako sent. Tok má čtyři jasně oddělené fáze:

FIG. 16.2-A Transactional Outbox – čtyři fáze publikování
Pásmo mezi fází 1 a fází 3 je „outbox lag“ – typicky pod 1 sekundu při polling intervalu 100 ms. Atomicita patří do fáze 1: order i outbox řádek vznikají v jediném COMMIT.
  1. Fáze 1 – doménová transakce. Application handler v jedné Doctrine transakci uloží agregát i odpovídající outbox řádky. Buď oboje, nebo nic.
  2. Fáze 2 – polling outboxu. Relay worker periodicky (např. každých 100 ms) selectuje pending řádky z outboxu, seřazené podle occurred_at, aby zachoval kauzální pořadí uvnitř jedné DB.
  3. Fáze 3 – publish do brokeru. Pro každý řádek relay publishne event do brokera a po obdržení ACK řádek označí jako sent. Obě operace nejsou v jedné transakci – pokud crashne mezi nimi, řádek zůstane pending a po restartu se publishne znovu. Z toho plyne základní garance:
  4. Fáze 4 – konzumace subscriberem. Subscriber dostane delivery, zpracuje ji idempotentně (typicky přes Inbox Pattern) a ackne brokerovi.

16.03 Schéma outbox tabulky a Doctrine mapping

Outbox tabulka má málo sloupců, ale každý z nich je nezbytný. Vynechání kteréhokoli z nich vede k provozním problémům, které se projeví až pod zátěží. Začneme schématem entity, projdeme význam jednotlivých sloupců a vysvětlíme, proč musí existovat kompozitní index (status, occurred_at).

Význam jednotlivých sloupců

Sloupec Typ Účel
id ULID (16 B) Unikátní identifikátor řádku – slouží zároveň jako event_id pro deduplikaci na straně subscribera (viz Inbox).
message_type VARCHAR(255) FQCN doménové události (např. App\Ordering\Domain\Event\OrderPlaced). Relay podle něj namapuje payload zpět na PHP třídu.
payload JSON / JSONB Serializovaný stav události. JSONB v Postgresu je preferovaný – umožňuje indexovat jednotlivá pole pro debugging.
status VARCHAR(16) Stavový enum: pending (čeká na publish), sent (úspěšně publishnuto), failed (po N pokusech vzdáno, vyžaduje manuální resolve).
occurred_at TIMESTAMPTZ Čas vzniku události v doménové transakci. Slouží pro řazení v relayi (FIFO uvnitř jedné DB) a pro výpočet outbox lagu.
attempts INT Počet neúspěšných pokusů o publish. Po překročení prahu (typicky 5) řádek přechází do failed a opouští hot path.
sent_at TIMESTAMPTZ NULL Vyplněno při přechodu do sent. Používá se pro kompakci (mazání starších sent řádků).
last_error TEXT NULL Poslední chyba publishe – důležité pro postmortem.

Po migraci spusťte php bin/console doctrine:migrations:migrate a ověřte, že index existuje: SHOW INDEXES FROM outbox WHERE Key_name = 'idx_outbox_status_time' (MySQL) nebo SELECT * FROM pg_indexes WHERE indexname = 'idx_outbox_status_time' (PostgreSQL). V CI doporučujeme přidat regresní test, který tento index kontroluje – snadno se totiž ztratí při refactoringu schématu.

16.04 Aggregate publikuje, handler ukládá do outboxu

Princip DDD říká, že agregát nezná infrastrukturu – neví nic o Doctrine, RabbitMQ ani outbox tabulce. Agregát pouze produkuje seznam doménových událostí, které jsou důsledkem doménové operace. Application handler je pak vezme a zařadí do outbox tabulky v téže transakci, ve které ukládá agregát samotný.

Pozornost si zaslouží volání $this->em->wrapInTransaction(...). Tato metoda Doctrine EntityManageru otevře transakci, vykoná callback, na konci flushne a commitne; pokud kdekoliv uvnitř callbacku letí výjimka, transakci automaticky rollbackne. Stejně funguje i Symfony Messenger middleware doctrine_transaction, který zabalí celý handler do jedné transakce – pokud ho v messenger.yaml máte, můžete wrapInTransaction vynechat.

Implementace rozhraní pomocí Doctrine je triviální – konstruktor přijímá EntityManagerInterface, store() volá persist() (NIKOLI flush() – flush patří aplikačnímu transakčnímu wrapperu), fetchPending() sestaví DQL SELECT m FROM OutboxMessage m WHERE m.status = 'pending' ORDER BY m.occurredAt ASC a omezí výsledek voláním $query->setMaxResults($limit); markSent() a markFailed() volají $m->markSent(), respektive $m->markFailed() a následně flushnou. Plný výpis vynecháváme – jde o mechanickou adapter třídu.

16.05 Relay process – dvě varianty

Outbox tabulka sama o sobě nic nepublikuje – potřebuje relay proces, který v určité kadenci vybírá pending řádky a posílá je do brokera. V praxi se používají dvě varianty: polling worker v aplikačním procesu (jednodušší, vhodné pro 99 % projektů) a CDC / Debezium (mimo aplikaci, vhodné pro masivní škálu nebo polyglot infrastrukturu).

Varianta A: Polling worker (Symfony Console command)

Polling worker je obyčejný Symfony Console command, který v nekonečné smyčce volá fetchPending(), publishne řádky a označí je jako sent. Spouští se z supervisord, systemd timeru nebo Kubernetes Deploymentu jako trvale běžící proces.

Varianta B: CDC / Debezium

Change Data Capture (CDC) je výrazně odlišné řešení: místo aplikačního polleru číst Postgres WAL (Write-Ahead Log) nebo MySQL binlog a streamovat každý INSERT do outbox tabulky přímo do Kafky. Standardním nástrojem je Debezium – Kafka Connect plugin, který funguje jako logický replikační odběratel databáze.

Tok je následující: aplikace zapíše do outbox jako obvykle (žádný kód se nemění). Debezium vidí INSERT v WAL, vytvoří Kafka record a pošle do odpovídajícího topicu (typicky outbox.event.OrderPlaced). Outbox řádek v DB není ničím updatován – je čistě immutable log a kompakce probíhá řízeně cron jobem.

Aspekt Polling worker (A) Debezium / CDC (B)
Latence 50–500 ms (polling interval) 1–50 ms (push z WAL)
Operační složitost 1× console command + supervisor Kafka + Kafka Connect + Debezium konektor + monitoring 4 procesů
Volba brokera Libovolný (RabbitMQ, SQS, Redis, Doctrine async) Pouze Kafka (resp. Pulsar, Kinesis přes adaptér)
Scale-out Až ~30k events/s (1 worker, SKIP LOCKED) Statisíce events/s (dáno Kafkou)
Garance pořadí Per-tabulka (ORDER BY occurred_at) Per-partition (Debezium routuje podle PK)
Doporučeno pro 99 % Symfony projektů Multi-tenant SaaS, finanční systémy, IoT

V této knize budeme dál pracovat s variantou A (polling worker) – pro typický Symfony projekt je to ten správný kompromis mezi spolehlivostí a operační režií. Debezium se vyplatí teprve tehdy, když máte už pět produkčních Kafka konzumentů a outbox lag začíná být úzkým hrdlem.

Pro úplnost ukázka, jak vypadá konfigurace Debezium konektoru pro Postgres outbox tabulku. Nasazuje se do Kafka Connectu jako JSON přes REST API, ekvivalentní YAML pro deklarativní deploy (Strimzi operator, ArgoCD) je:

Hlavní části jsou transforms.outbox (Debezium Outbox Event Router, DBZ-1063), která čte řádky outbox tabulky a směruje je do Kafka topiců podle message_type, a plugin.name: pgoutput pro Postgres logickou replikaci. Žádný kód na aplikační straně se proti variantě A nemění – handler dál zapisuje do outbox tabulky v DB transakci, jen dispatcher je nahrazen Debezium konektorem.

Citace: Debezium dokumentace – Outbox Event Router (Red Hat, 2019+).

16.06 Idempotent Inbox – strana subscribera

Outbox dává at-least-once delivery, takže subscriber musí počítat s tím, že stejný event dostane víckrát. Pokud je side-effect handleru ne-idempotentní (typicky UPDATE counter SET value = value + 1), duplicita se okamžitě projeví jako chybný stav read modelu – zákazník vidí 200 Kč na účtě místo 100 Kč, počet objednávek je dvojnásobný, e-mail dorazí 2×.

Idempotent Inbox Pattern řeší tuto situaci doplňkem k outboxu – tabulkou inbox v databázi subscribera se sloupcem event_id a UNIQUE constraintem. Před zpracováním eventu handler zkontroluje, zda je daný event_id už v inboxu; pokud ano, ackne brokerovi a skončí. Pokud ne, zpracuje doménovou logiku a v téže transakci vloží nový řádek do inboxu. UNIQUE constraint je pojistka proti race condition.

FIG. 16.6-A Idempotent Inbox – deduplikace na straně subscribera
Podstatné je, že INSERT do inboxu a update read modelu jsou v jediné DB transakci. Dva paralelní workery selžou na UNIQUE constraintu – Messenger jeden z nich retry-uje a podruhé už narazí na branch „found“.

Sloupec consumer v inbox tabulce není zanedbatelný: jeden a tentýž event_id mohou zpracovávat různí subscribery (Reporting, Notifications, Search index) a každý si potřebuje vést vlastní stav „už jsem to zpracoval“. Bez sloupce consumer by druhý subscriber narazil na UNIQUE constraint prvního a nikdy by event nezpracoval. UNIQUE proto definujeme jako kompozitní (event_id, consumer), ne jen event_id.

16.07 Idempotency Key v HTTP API

Outbox řeší idempotenci uvnitř systému (broker → subscriber); ale stejný problém vzniká i o úroveň výš, na hranici HTTP API. Klient (mobilní aplikace, JS frontend, partnerská integrace) může request retry-ovat při timeoutu – a server tak může dostat dva identické POST /orders a vytvořit dvě objednávky.

Stripe popularizoval Idempotency Key jako standardní řešení a jeho specifikace je dnes de-facto referencí pro REST API (převzala ji např. PayPal, Shopify, Square, IETF draft draft-ietf-httpapi-idempotency-key-header). Klient pošle v hlavičce Idempotency-Key UUID, server si první request uloží do cache (Redis nebo DB tabulka http_idempotency) spolu s odpovědí, a všechny další requesty se stejným klíčem vrátí cached response – bez znovuvytvoření objednávky.

Detaily, na které se snadno zapomíná:

  • TTL idempotency klíče typicky 24–48 h. Delší okno znamená větší riziko, že klient po náhodné kolizi UUID dostane jinou response, než čeká. Stripe používá 24 h.
  • Cache key nesmí být jen klíč sám – kombinujeme ho s cestou ($path . '|' . $key), aby tentýž klient s tímtéž klíčem na různých endpointech (/orders vs. /refunds) nesdílel cache.
  • 5xx odpovědi necachujeme. 500 znamená server-side chybu, klient má právo zkusit znovu. Caching 500 by zablokoval recovery na 24 hodin.
  • Hash z payloadu (volitelně). Striktní implementace porovnává ještě body requestu – pokud klient pošle stejný klíč s jiným tělem, je to programátorská chyba a server vrací 422. Pro většinu projektů stačí klíč + cesta.

Citace: Stripe API Reference – Idempotent Requests (kanonická specifikace); IETF draft-ietf-httpapi-idempotency-key-header (probíhající standardizace HTTP header).

16.08 Provozní aspekty

Outbox v development prostředí funguje sám od sebe a je snadné mít pocit, že je „hotov“. V produkci ale narazíte na čtyři operační otázky: jak měřit lag, jak držet tabulku malou, co s permanentně failovanými řádky a jak monitorovat, že se na něco nezapomnělo.

Outbox lag

Outbox lag je čas, který stráví průměrný event ve stavu pending, než ho relay pošle do brokera. Definujeme ho jako:

Tyto metriky exportujte do Prometheu (outbox_pending_seconds, outbox_pending_count) a v Grafaně postavte alert: kritický prah typicky 30 sekund. Pokud lag překročí tuto hranici, něco se zaseklo – relay worker padl, broker je nedostupný, DB má 100% CPU. Při normálním provozu je medián lagu pod 1 sekundou.

Kompakce outbox tabulky

Outbox tabulka roste lineárně s počtem doménových eventů. Bez kompakce po roce provozu obsahuje miliony historických řádků, což zpomaluje i indexované dotazy a zbytečně okupuje disk. Standardní strategie: mažeme řádky, které jsou ve stavu sent a starší než N dní – kde N je obvykle 7 až 30 podle compliance požadavků.

LIMIT 10000 je tam záměrně – chceme batch delete, ne DELETE FROM outbox jediným SQL příkazem. Velký delete drží zámky na celé tabulce, což blokuje produkční INSERT z handlerů. Cron ho spouští každých 5 minut – 10 000 řádků za běh stačí na realistické workloady (cca 3 mil. eventů/den).

Dead-letter queue pro permanentní selhání

Některé eventy se nikdy nepublishnou: schema změna v subscriberu, kterou nikdo nepořešil, broken payload (NaN v JSON), poison message, který shodí libovolného consumera. Po N attempts (typicky 5) je OutboxMessage::markFailed() přepne do stavu failed. Tyto řádky chceme:

  • Vyčlenit z hot pathy – relay je už nezkouší publishovat.
  • Hlasitě upozornit – alert outbox_failed_total > 0.
  • Mít na ně CLI nástrojapp:outbox:retry-failed nebo ruční SQL update statusu zpět na pending po opravě subscribera.
  • Nikdy nemazat automaticky – failed řádek je důkaz nedoručeného doménového eventu a chce ho mít evidovaný i po týdnu.

16.09 Anti-vzory

Outbox vypadá triviálně, ale provázejí ho klasické chyby, které ruší jeho garance a vrací systém zpět k dual-write problému. Následující seznam shrnuje ty, které se v reálných code review opakují.

16.10 Migrace existujícího projektu – krok za krokem

Jak na Outbox, když máte 18 měsíců starý Symfony projekt, sto handlerů a jakési publish-after-flush už tam někde je? Postup je inkrementální, ne big-bang refactor. Outbox přidáváte handler po handleru, vedle stávajícího chování, a starý kód odstraňujete teprve když nový jistě funguje.

Krok 1: Přidat outbox tabulku a entitu

Vytvořte migraci podle sekce 16.03, spusťte doctrine:migrations:migrate, nasaďte do produkce. Tabulka zatím nikdo nepoužívá – žádné riziko regresí. Důležité: ověřte, že migrace skutečně vytvořila kompozitní index idx_outbox_status_time, ne jen single-column.

Krok 2: Refactor jednoho handleru

Vyberte jeden hlavní handler – typicky PlaceOrderHandler nebo cokoli, kde dual-write nejvíc bolí. Přidejte do něj wrapInTransaction a místo $bus->dispatch($event) volejte $outbox->store(OutboxMessage::fromDomainEvent($event)). Nemažte ještě staré $bus->dispatch() – pokud máte legacy subscribery, kteří poslouchají na sync transportu, ti by přestali fungovat.

Krok 3: Nasadit relay command

Implementujte OutboxDispatchCommand ze sekce 16.05 a deploynete pod supervisorem. V tomto bodě může worker už publishovat eventy z outboxu – pokud máte legacy publish dál aktivní, broker dostane obě verze. Subscribery ale ještě nemají Inbox, takže duplicitu řeší... přesně, neřeší.

Krok 4: Přidat inbox subscriberům jeden po druhém

Pro každý subscriber kontextu vytvořte inbox tabulku, refactor handler podle sekce 16.06. Toto je nejdelší krok migrace (typicky týdny), ale paralelizovatelný napříč týmy – každý kontext si Inbox přidává nezávisle.

Krok 5: Vypnout legacy publish

Až mají všichni subscribery inbox, smažete v handleru původní $bus->dispatch() a spoléháte výhradně na outbox. Jde o riskantní krok – během prvních dnů sledujte outbox lag a inbox dedupy. Pokud něco selhává, pull-request reverter má návrat zpět během 5 minut.

Krok 6: Měřit a tunit

Po měsíci provozu projděte metriky: jaký je medián lagu, jak rychle roste tabulka, kolik řádků skončilo ve failed, kolik duplicit Inbox odchytil. Z těchto čísel se dá vyladit polling interval relay procesu, batch limit, cleanup retention a alert prahy. Outbox není „set-and-forget“ – vyžaduje občasnou provozní údržbu.

16.11 Shrnutí

Outbox Pattern má jednoduchou implementaci a velký provozní přínos: vyřeší celou třídu chyb (ztracené eventy, fantom eventy), které jinak musíte ladit reaktivně ve tři ráno z logů. Cena je tabulka navíc, jeden Symfony command a úprava jednoho application handleru. Garance, kterou tím získáte, je at-least-once delivery doménových událostí napříč libovolným message brokerem – bez závislosti na XA, bez 2PC, bez speciální cloud služby.

Idempotent Inbox je nutný protějšek na straně subscribera. Bez něj se duplikace z outboxu propíše do read modelů a side-effectů, čímž ztratíme to, co jsme outboxem získali. Kombinace Outbox + Inbox dohromady poskytují exactly-once efekt – každý event se v read modelu projeví právě jednou, i když broker dodá zprávu vícekrát.

Hlavní body pro praxi:

  • Outbox je tabulka v téže DB jako doménový stav – jinak nedává smysl.
  • Doctrine entita s #[ORM\Index(columns: ['status', 'occurred_at'])] je nepostradatelný detail.
  • $em->wrapInTransaction(...) v handleru garantuje atomicitu order + outbox řádky.
  • Polling worker pod supervisorem stačí pro 99 % Symfony projektů; CDC/Debezium pouze pro Kafka-native systémy s vysokým objemem.
  • Inbox tabulka má UNIQUE (event_id, consumer) – sloupec consumer je klíč pro multi-subscriber scénáře.
  • Monitoring outbox lagu, dispatched/failed counters a inbox duplicit je nezbytné.
  • Migrace existujícího projektu je inkrementální – handler po handleru, kontext po kontextu, nikdy big-bang.

Outbox Pattern přirozeně navazuje na vzory z předchozích kapitol. V CQRS řeší spolehlivost publishu eventů z command side do read side. V Event Sourcingu je jeho rozšíření čisté – event store funguje jako outbox, projekce čte jako relay. V ságách garantuje doručení doménových eventů i příkazů mezi kontexty, takže sága se nikdy „nezasekne“ kvůli ztracené zprávě.

Doporučená literatura k prohloubení: Helland, P. – Life Beyond Distributed Transactions, CIDR (2007); Richardson, C. – Microservices Patterns, Manning (2018), kap. 3 a 4; Kleppmann, M. – Designing Data-Intensive Applications, O'Reilly (2017), kap. 11 (Stream Processing); microservices.io – Pattern: Transactional Outbox.

Časté otázky

Outbox vs. CDC / Debezium – co kdy?

Pro 99 % Symfony projektů zvolte polling worker (varianta A). Operační režie je minimální (jeden Symfony command pod supervisorem) a latence pod 1 sekundou je naprosto dostatečná pro typické obchodní scénáře (objednávky, platby, notifikace). Debezium / CDC se vyplatí, až když máte (a) Kafkovou infrastrukturu už nasazenou, (b) latenční požadavek pod 50 ms, (c) objem nad 10 000 events/s, (d) tým, který má zkušenost s Kafka Connect. Jinak zaplatíte multinásobnou operační složitost za marginální benefit. Detail v sekci 16.05.

Co když používáme NoSQL databázi (MongoDB, Cassandra, DynamoDB)?

Pokud váš agregát žije v NoSQL bez ACID transakcí napříč více dokumenty (Cassandra, raná verze MongoDB), klasický Outbox Pattern nefunguje – atomicita zápisu order + event mezi dvěma collections není garantovaná. Možnosti: (1) MongoDB 4.0+ má multi-document transakce, takže Outbox lze, (2) DynamoDB nabízí TransactWriteItems, takže Outbox jde, (3) Cassandra nemá multi-row atomicitu – používá se Change Data Capture nebo jednodokumentové event sourcing s eventy embedded v agregátu. Volba úložiště pro doménový stav rozhoduje, zda lze Outbox vůbec implementovat.

Jak velký dělat batch v relayi?

Defaultně 100 řádků za polling cyklus s intervalem 100 ms. To dává teoretický throughput 1 000 events/s na jeden worker, což pokryje drtivou většinu workloadů. Pokud lag stoupá nad 5 sekund a CPU brokera má rezervu, zvyšte limit na 500 nebo zkraťte interval na 50 ms. U batch nad 1 000 narazíte na DB serializaci updateů – místo jednoho velkého batche pak rozdělte na víc workerů s SELECT ... FOR UPDATE SKIP LOCKED. Hlavní pravidlo: měřte před tunováním, ne tunujte „na cit“.

Vyplatí se Outbox v monolitu?

Ano, vyplatí – protože dual-write problem nevzniká až mezi mikroslužbami, ale mezi libovolnými dvěma transakčními systémy. Monolitická aplikace publikující eventy do RabbitMQ/Redis Streams má přesně stejný problém jako mikroslužba: DB ACID je oddělený od ACK message brokera. Pokud váš monolit už má event-driven kontexty (Symfony Messenger s async transportem, Spatie Laravel events, ...), Outbox se vyplatí stejně jako v mikroslužbách. Jediný případ, kdy ho nepotřebujete, je striktně synchronní monolit, kde publish neexistuje a všechno teče v jedné HTTP transakci.

Co dělat při dlouhodobém výpadku brokera?

Outbox jako celek je self-healing: když broker leží 30 minut, relay worker dostává timeout/connection refused, řádky zůstávají ve stavu pending, počet vzroste, lag exploduje – ale aplikační handlery dál bez problémů zapisují doménové eventy (jen do DB). Po obnovení brokera relay během několika minut vyšle backlog, lag se vrátí k normálu, subscribery dohrabou stav. Co je třeba: (a) alert na lag > 30 s aby tým o výpadku věděl, (b) dostatek místa v DB na nahromaděné pending řádky (typicky není problém – řádky jsou malé), (c) kompakce ne-mazat pending stará než N dní, jen sent. Pokud broker chybí déle než N dní, máte dost času škálovat dispatch capacity nebo migrovat na alternativní broker.

Musím použít UUID/ULID, nebo stačí AUTO_INCREMENT?

Použijte ULID (nebo UUIDv7), ne AUTO_INCREMENT. Důvody: (1) ULID je globálně unikátní napříč instancemi DB – nehrozí kolize při replikaci, restore z backupu nebo migraci. (2) ULID nese časový komponent, takže ID koreluje s pořadím vytvoření – užitečné pro debugging a pro indexové scany. (3) ULID je předvídatelný klientem, který může poslat event_id v Idempotency-Key headeru. (4) AUTO_INCREMENT komplikuje sharding a multi-region setupy. Symfony Uid komponenta poskytuje pohodlné API: new Ulid() v entitě stačí.