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.
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, spolehlivě dorazí do message brokeru a odtud k subscriberům. Jenže ten předpoklad neplatí. Mezi zápisem do databáze a dispatchem do Messenger transportu stojí síťový skok a dva nezávislé systémy. 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 standardní řešení dual-write problému. Katalogovou definici vzoru formuloval Chris Richardson; Pat Helland k němu v práci Life Beyond Distributed Transactions (2007) dodává rámec: odmítnutí distribuovaných transakcí a požadavek na idempotentního příjemce. Protějšek na straně subscriberů se v katalozích jmenuje Idempotent Consumer, starším názvem Idempotent Receiver; tato kapitola pro něj používá pracovní jméno Idempotent Inbox, protože stojí symetricky proti outboxu.
Dál projdeme schéma outbox tabulky s povinným indexem a implementaci s Doctrine ORM a Symfony Messenger. Pak dvě kanonické varianty relay procesu, Polling Publisher a Transaction Log Tailing, a nakonec provozní stránku věci: outbox lag, kompakci a dead-letter queue. Závěr patří migračnímu postupu pro existující projekt a srovnání s alternativami.
15.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. Takový kód projde code review bez poznámek. Do chvíle, než se v produkci zač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. Stačí mezi nimi jakákoliv chyba: síťový timeout, pád 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
OrderPlacedse 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 pojmenoval. Jakmile transakce přesahuje hranici jednoho úložiště, atomicita je iluze; obnovit ji musí aplikační logika. Slovo outbox ale v paperu nepadne. Tabulku a relay proces popsal až Chris Richardson v knize Microservices Patterns (2018, kapitola 3) a v katalogu microservices.io. Jeho formulace řešení zní: odesílatel nejdřív uloží zprávu do databáze ve stejné transakci, která mění doménové entity, a samostatný proces ji teprve pak posílá do brokera. Jako alternativu katalog uvádí event sourcing.
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.
15.02 Transactional Outbox – princip#
Místo dispatchu do brokera zapíšeme událost do tabulky outbox ve stejné databázi,
kde žije doménový stav. Zápis proběhne uvnitř stejné DB transakce jako úprava agregátu.
Buď se tedy zapíše obojí (order i jeho event), nebo se nezapíše nic (rollback
celé transakce). Atomicitu tím získáváme zpátky: oba zápisy leží 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) tabulku asynchronně 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:
- 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.
- Fáze 2 – polling outboxu. Relay worker periodicky (např. každých
100 ms) selectuje pending řádky z outboxu, seřazené podle
occurred_at. Výsledkem je best-effort FIFO, ne garantované pořadí. Proč, rozebírá sekce 15.05. - Fáze 3 – publish do brokeru. Pro každý řádek relay publikuje event
do brokera a po obdržení ACK řádek označí jako
sent. Obě operace neběží v jedné transakci. Pokud relay spadne mezi nimi, řádek zůstanependinga po restartu se publikace zopakuje. Z toho plyne garance at-least-once delivery, rozebraná níže. - Fáze 4 – konzumace subscriberem. Subscriber dostane delivery, zpracuje ji idempotentně (typicky přes Inbox Pattern) a ackne brokerovi.
15.03 Schéma outbox tabulky a Doctrine mapping#
Outbox tabulka má deset sloupců; každý řeší konkrétní provozní problém, který se bez něj projeví až pod produkční zátěží.
Entita níže nese Doctrine atributy a sedí v namespace App\Outbox\Domain. Je to
pragmatická zkratka. Outbox je infrastrukturní vzor; kdo drží přísné vrstvení
podle kapitoly Architektonické styly,
umístí tabulkovou entitu do Infrastructure.
Význam jednotlivých sloupců
| Sloupec | Typ | Účel |
|---|---|---|
id |
UUID v7 (16 B) | Primární klíč a pořadí řádků pro polling. Deduplikaci nenese – tu zajišťuje eventId v payloadu události (viz Inbox). |
message_type |
VARCHAR(255) | FQCN integrační události (např. App\Ordering\Application\IntegrationEvent\OrderPlacedIntegrationEvent). Relay podle něj namapuje payload zpět na PHP třídu. |
aggregate_type |
VARCHAR(255) | Typ agregátu, který událost vydal (Order, Invoice). Debezium podle tohoto sloupce routuje do Kafka topiců, viz 15.05. |
aggregate_id |
VARCHAR(64) | ID konkrétní instance agregátu. Slouží jako klíč zprávy: události jednoho agregátu skončí ve stejné partition, a tím ve správném pořadí. |
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ě publikováno), 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 (best-effort FIFO) a pro výpočet outbox lagu. |
attempts |
INT | Počet neúspěšných pokusů o publish. Po dosaž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 rozbor incidentu. |
available_at |
TIMESTAMPTZ | Čas, odkdy relay smí řádek znovu vzít. markFailed() ho posouvá exponenciálně, takže trvale selhávající zpráva nepálí pokusy v každém cyklu. |
Migrace cílí na MySQL/MariaDB. PostgreSQL varianta nahradí BINARY(16)
typem UUID, DATETIME(6) typem TIMESTAMPTZ a JSON typem JSONB;
klauzule ENGINE a CHARSET odpadají. Na SQLite jde BINARY(16) na BLOB
a JSON na CLOB. Ruční přepisování je přesně ten druh práce, kterou
doctrine:migrations:diff udělá spolehlivěji. Ukázka slouží k pochopení
struktury, ne ke kopírování napříč platformami. Pozor také na SQL komentáře
uvnitř CREATE TABLE: introspekci SQLite rozhodí a schéma se pak hlásí
jako rozejité.
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.
Při refaktoringu schématu se totiž často ztratí.
15.04 Aggregate publikuje, handler ukládá do outboxu#
Agregát v DDD nezná infrastrukturu: neví nic o Doctrine, RabbitMQ ani outbox tabulce. Vydává jen seznam doménových událostí, které z právě provedené operace plynou. Application handler ten seznam vezme a zařadí do outbox tabulky v téže transakci, ve které ukládá samotný agregát.
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. Kanonický messenger.yaml z kapitoly o CQRS
ho na command.bus má, takže tam wrapInTransaction v handleru přebývá. Zůstane
z něj vnořený savepoint a nepřehledná odpověď na otázku, kde se vlastně commituje.
Ukázka ho drží proto, že samotný vzor musí být čitelný i bez znalosti konfigurace
sběrnic; ve svém projektu si vyberte jedno místo.
Doctrine adapter je krátký, ale dvě místa v něm přehlédne skoro každý. store()
nesmí flushovat, protože transakci drží aplikační wrapper. fetchPending()
musí filtrovat i podle availableAt, jinak backoff z markFailed() nic neznamená.
1<?php2 3declare(strict_types=1);4 5namespace App\Outbox\Infrastructure;6 7use App\Outbox\Application\OutboxRepository;8use App\Outbox\Domain\OutboxMessage;9use Doctrine\ORM\EntityManagerInterface;10use Symfony\Component\Uid\Uuid;11 12final readonly class DoctrineOutboxRepository implements OutboxRepository13{14 public function __construct(private EntityManagerInterface $em) {}15 16 public function store(OutboxMessage $message): void17 {18 // Žádný flush. Zpráva musí odejít do DB ve stejné transakci19 // jako změna agregátu – o commit se stará volající.20 $this->em->persist($message);21 }22 23 public function fetchPending(int $limit = 100): array24 {25 return $this->em->createQuery(26 "SELECT m FROM " . OutboxMessage::class . " m27 WHERE m.status = 'pending' AND m.availableAt <= :now28 ORDER BY m.occurredAt ASC"29 )30 ->setParameter('now', new \DateTimeImmutable())31 ->setMaxResults($limit)32 ->getResult();33 }34 35 public function markSent(Uuid $id): void36 {37 $this->em->find(OutboxMessage::class, $id)?->markSent(new \DateTimeImmutable());38 $this->em->flush();39 }40 41 public function markFailed(Uuid $id, string $error): void42 {43 // Tady flush naopak patří: relay běží mimo doménovou transakci44 // a výsledek pokusu musí být vidět, i když další zpráva spadne.45 $this->em->find(OutboxMessage::class, $id)?->markFailed($error);46 $this->em->flush();47 }48}
15.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. Katalog microservices.io pro to zná dva pojmenované vzory. Polling Publisher čte outbox tabulku dotazem a jeho jediná, zato podstatná přednost zní: funguje nad libovolnou SQL databází. Transaction Log Tailing místo dotazu čte transakční log databáze, tedy Postgres WAL nebo MySQL binlog. První se realizuje jako Symfony Console command, druhý jako Debezium konektor nad Kafkou.
Varianta A: Polling Publisher (Symfony Console command)
Polling worker je obyčejný Symfony Console command, který ve vnitřní smyčce volá
fetchPending(), publikuje řádky a označí je jako sent.
Spouští se ze supervisord, systemd nebo Kubernetes
Deploymentu jako trvale běžící proces. Smyčka má časový limit. Po jeho
doběhnutí se proces čistě ukončí a process manager ho nastartuje znovu.
Stejný vzor používá messenger:consume --time-limit; periodický restart
drží pod kontrolou paměť dlouho běžícího PHP procesu.
Zpětný převod obstará OutboxMessageFactory. Není to čistě mechanický opak
serializeru: denormalizace potřebuje znát cílovou třídu, a proto se opírá o whitelist.
Ten je zároveň bezpečnostní opatření. Bez něj by o tom, jakou třídu aplikace vytvoří,
rozhodoval message_type z databáze:
1<?php2 3declare(strict_types=1);4 5namespace App\Outbox\Application;6 7use App\Ordering\Application\IntegrationEvent\OrderPlacedIntegrationEvent;8use App\Outbox\Domain\OutboxMessage;9use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;10 11final readonly class OutboxMessageFactory12{13 /**14 * Whitelist typů, které smí relay vytvořit. Nový integrační event15 * znamená nový řádek tady – jinak skončí v dead-letter, ne v aplikaci.16 *17 * @var array<string, class-string>18 */19 private const ALLOWED = [20 OrderPlacedIntegrationEvent::class => OrderPlacedIntegrationEvent::class,21 ];22 23 public function __construct(24 private DenormalizerInterface $denormalizer,25 ) {}26 27 public function reconstitute(OutboxMessage $message): object28 {29 $class = self::ALLOWED[$message->messageType] ?? null;30 31 if ($class === null) {32 throw new \RuntimeException(33 sprintf('Neznámý message_type "%s" v outboxu.', $message->messageType),34 );35 }36 37 return $this->denormalizer->denormalize($message->payload, $class, 'json');38 }39}
Integrační událost se denormalizuje bez potíží právě proto, že nese samé primitivy.
Doménová událost s hodnotovými objekty by tu skončila hláškou
„Cannot create an instance of OrderId from serialized data because its constructor
requires the following parameters to be present: $value“. To je další důvod, proč se
přes hranici posílá integrační tvar.
Varianta B: CDC / Debezium
Change Data Capture (CDC) je kanonicky Transaction Log Tailing: místo
aplikačního polleru čte Postgres WAL (Write-Ahead Log) nebo MySQL binlog
a streamuje každý INSERT do outbox tabulky přímo do Kafky. Standardním nástrojem
je Debezium, plugin pro Kafka Connect, který
funguje jako logický replikační odběratel databáze.
Tok je následující: aplikace zapíše řádek do outbox, Debezium ten INSERT uvidí
v transakčním logu, vytvoří Kafka record a pošle ho do odpovídajícího topicu.
Řádek se pak už nemění, tabulka funguje jako append-only log.
| Aspekt | Polling Publisher (A) | Transaction Log Tailing / Debezium (B) |
|---|---|---|
| Latence | 50–500 ms (polling interval) | jednotky až desítky 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 | jednotky tisíc zpráv/s na worker, lineárně s replikami přes SKIP LOCKED | dáno Kafkou, o dva řády výš |
| Garance pořadí | Best-effort podle occurred_at |
Per-partition podle aggregate_id |
| Provozní riziko | Zaseknutý worker = rostoucí lag | Zaseknutý konektor drží replikační slot a WAL se hromadí na disku primární databáze |
| Doporučeno pro | Běžný Symfony projekt | Multi-tenant SaaS, finanční systémy, IoT |
V této knize budeme dál pracovat s variantou A. Pro typický Symfony projekt vyváží spolehlivost a operační režii v poměru, který nepřidává Kafka stack jen kvůli outboxu. 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.
Konfiguračně jde o Kafka Connect konektor (REST API, nebo deklarativně přes
Strimzi operator). Jádrem je transformace Outbox Event Router
(io.debezium.transforms.outbox.EventRouter). Ta má vlastní představu
o schématu tabulky a stojí za to ji znát dřív, než konektor nasadíte:
- Routuje podle sloupce
aggregatetype, ne podle typu události. Výchozí topic jeoutbox.event.<hodnota aggregatetype>, pro hodnotuOrdertedyoutbox.event.Order, nikoli topic pojmenovaný po tříděOrderPlaced. - Klíčem Kafka zprávy je
aggregateid. Právě odtud plyne pořadí uvnitř partition. - Sloupec
idcestuje jako hlavička zprávy a dokumentace ho nabízí přímo k deduplikaci na straně konzumenta. - SMT automaticky odfiltruje
DELETEoperace nad outbox tabulkou. Kanonický Debezium model proto řádek vloží a hned smaže; sloupecstatusv něm vůbec nefiguruje.
Tvrzení „na aplikační straně se nic nemění“ tedy neplatí. Schéma z 15.03
má sloupce aggregate_type a aggregate_id s podtržítkem a navíc stavový model,
takže přechod na variantu B znamená buď přejmenovat sloupce podle výchozího
očekávání SMT, nebo přemapovat volby route.by.field a table.field.event.*.
Rozhodnout se musíte i u stavu: buď status ponecháte kvůli auditní stopě
a smíříte se s tím, že ho konektor ignoruje, nebo přejdete na insert-and-delete
model, který nepotřebuje kompakci.
Pro Postgres se k tomu přidá logická replikace. Výchozí plugin.name konektoru
je decoderbufs, který vyžaduje serverové rozšíření; pgoutput je v Postgresu
od verze 10 nativní, a proto v praxi častější volba. Vyžaduje wal_level = logical
a pro uživatele konektoru privilegium CREATE kvůli vytvoření publikace.
Citace: Debezium dokumentace – Outbox Event Router (Red Hat, 2019+).
Doctrine transport jako outbox bez vlastní tabulky
Symfony Messenger nabízí třetí cestu, která nevyžaduje vlastní outbox tabulku
ani relay command. Transport doctrine://default ukládá zprávy do tabulky
messenger_messages ve stejné databázi, kde žije doménový stav. Atomicitu
zajišťuje middleware doctrine_transaction na command busu: transakce,
kterou middleware otevře kolem command handleru, obalí uložení agregátu
i dispatch eventu na doctrine transport. Podmínkou je, že transport používá
totéž DB spojení jako doménový stav, tedy default entity manager. Dual-write
problém tím mizí: buď se commitne order i zpráva, nebo nic. Worker
messenger:consume async_events pak zprávu vyzvedne a zpracuje, případně přepošle dál.
Symfony dokumentace tuhle konfiguraci nikde nenazývá outboxem; slovo v ní nepadne.
Atomicita ale reálně platí. DoctrineTransactionMiddleware otevře transakci nad
spojením entity manageru, spustí handler, pak flushne a commitne. Connection::send()
doctrine transportu je prostý INSERT nad týmž spojením a vlastní izolovanou
transakci neotevírá. Zápis zprávy i flush agregátu proto commitnou společně.
Daň za pohodlí je trojí. Formát uložené zprávy je svázaný s Messengerem: payload
serializuje envelope i se stampy, takže ho mimo Symfony nikdo rozumně nepřečte.
Auditovatelnost a retence jsou horší než u vlastní outbox tabulky: zpracované
řádky worker maže, žádný stav sent, žádné last_error, žádná historie pro
rozbor incidentu. A nad schématem tabulky nemáte kontrolu. Definuje ho
Messenger, ne vaše migrace.
Pro menší systémy je to přesto nejjednodušší správná volba: dual-write je vyřešený, kód se omezí na konfiguraci a jeden worker. Vlastní outbox tabulka se vyplatí, až když potřebujete auditní stopu, řízenou retenci nebo publish do brokera mimo Messenger.
Pořadí zpráv: best-effort, ne garance
ORDER BY occurred_at sugeruje víc, než dokáže splnit. Katalog microservices.io
u Polling Publisheru uvádí drawback „tricky to publish events in order“ a v této
implementaci se sejdou hned tři důvody. Hodnota occurred_at vzniká v PHP procesu,
takže napříč instancemi podléhá odchylce hodin. Při shodné hodnotě není pořadí
definované vůbec. A relay publikuje řádek po řádku, takže selhání uprostřed batche
pustí pozdější událost před dřívější.
Spolehlivé pořadí lze držet jen per agregát a jen tehdy, když ho nese klíč zprávy.
Proto je ve schématu aggregate_id. V Kafce z něj plyne partition, uvnitř které
je pořadí garantované. Napříč agregáty žádné globální pořadí nečekejte a nestavte
na něm doménovou logiku.
Filtru status = 'pending' se naopak netýká gap problém, který popisuje kapitola
Event Sourcing. Ten trápí relay,
který si drží checkpoint na auto-increment ID: transakce s nižším ID může commitnout
později a relay ji za posunutým checkpointem už nepřečte. Outbox tabulka se stavovým
sloupcem checkpoint nemá. Řádek je viditelný teprve po commitu a zůstane pending,
dokud ho relay nepublikuje. Opožděný commit se prostě objeví v některém dalším cyklu.
15.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 vedlejší efekt 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 účtu místo 100 Kč, počet
objednávek je dvojnásobný, e-mail dorazí 2×.
Řešení má v katalozích dvě jména. microservices.io vede vzor jako Idempotent
Consumer a doporučuje tabulku zpracovaných zpráv s kompozitním klíčem
(subscriberId, messageID). Starší je Idempotent Receiver z Enterprise
Integration Patterns (Hohpe & Woolf, 2003): příjemce navržený tak, aby tutéž
zprávu snesl vícekrát. Dál v kapitole používáme pracovní jméno Idempotent Inbox,
protože stojí symetricky proti outboxu.
Realizace je doplněk k outboxu: tabulka inbox v databázi subscribera
s kompozitním UNIQUE constraintem na dvojici (event_id, consumer). Před
zpracováním eventu handler zkontroluje, zda je daná dvojice 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.
Sloupec consumer v inbox tabulce má svůj důvod. 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.
Retence inbox tabulky
Inbox roste stejně jako outbox, jen o něm nikdo nemluví. Každá zpracovaná zpráva v něm nechá řádek a nic ho nemaže. Po roce provozu je z pojistky proti duplicitám největší tabulka v databázi subscribera.
Horní hranici retence určuje doba, po kterou může broker zprávu ještě doručit: maximální TTL zprávy plus nejdelší retry okno relay procesu. Řádek starší než tento součet už nemá co deduplikovat. Obvykle se drží 30 dní – bezpečně nad běžným nastavením obou lhůt. Maže se stejným batch cronem jako outbox. Kdo retenci zvolí kratší než reálné retry okno, otevře si díru: opožděná zpráva projde jako nová.
15.07 Provozní aspekty#
Outbox ve vývojovém prostředí funguje, jak má. 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.
Tyto metriky exportujte do Prometheu (outbox_pending_seconds,
outbox_pending_count) a v Grafaně postavte alert: kritický
práh 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ů. Ty zpomalují i indexované dotazy
a zbytečně okupují 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 a blokuje
produkční INSERT z handlerů. Cron ho spouští každých 5 minut a 10 000 řádků
za běh stačí na realistické workloady (cca 3 mil. eventů/den).
Nabízející se zápis DELETE … WHERE sent_at < NOW() - INTERVAL 30 DAY LIMIT 10000
je kratší, ale je to specifikum MySQL a MariaDB. Postgres i SQLite ho odmítnou.
A protože příkaz běží jednou za měsíc, chyba se ukáže dávno po nasazení. Hranice
se proto počítá v PHP a batch se vymezuje poddotazem nad id.
Dead-letter queue pro permanentní selhání
Některé eventy se nikdy nepublikují: schema změna v subscriberu, kterou nikdo
nevyř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ší publikovat.
- Hlasitě upozornit – alert
outbox_failed_total > 0. - Mít na ně CLI nástroj –
app:outbox:retry-failednebo ruční SQL update statusu zpět napendingpo opravě subscribera. - Nikdy nemazat automaticky – failed řádek je důkaz nedoručeného doménového eventu a chcete ho mít evidovaný i po týdnu.
Vacuum a index bloat (PostgreSQL)
Outbox má specifický I/O profil: vysoký INSERT rate, krátký životní cyklus (řádek vznikne →
během sekund se UPDATE na sent → po N dnech DELETE), nikdy se nečte historie.
Standardní autovacuum tuning PostgreSQL na takový profil není dimenzovaný
a po několika dnech provozu narážíte na index bloat:
- INSERT vytváří mrtvé řádky v tabulce i v indexech (kvůli MVCC).
- UPDATE statusu vytváří další verze řádku.
- Standardní autovacuum threshold (
autovacuum_vacuum_scale_factor = 0.2) čeká, než se nasbírá 20 % mrtvých řádků. Při tisících zápisů za sekundu je to řád minut. - Mezitím index
(status, occurred_at)nabobtná na 10× původní velikost, selecty pomalují, lag stoupá.
Standardní opatření: per-table vacuum tuning.
Partitioning při vysokém objemu (PostgreSQL)
Při trvale vysokém objemu, tedy v řádu tisíců událostí za sekundu, se single-table
outbox stává provozním úzkým hrdlem. PostgreSQL declarative partitioning podle
occurred_at umožňuje:
- Rychlé mazání starých dat přes
DROP PARTITIONmístoDELETE– nemá zámky na celé tabulce, runtime O(1) místo O(n). - Cílené vacuum – autovacuum operuje per-partition, takže staré (read-only) partice se nevakuují vůbec.
- Index lokalita – aktivní partition obsahuje jen poslední hodiny eventů, index je malý a vlézá do RAM.
Provozní automatizace: rozšíření pg_partman
spravuje vznik nových partitions i mazání starých přes cron. Pro MySQL existuje
nativní PARTITION BY RANGE se stejným efektem, ale bez pg_partman ekvivalentu –
správa je manuální.
Distributed relay – multi-instance
Singleton polling worker (replicas: 1 v Kubernetes) je nejjednodušší
konfigurace. Má ale dvě slabiny. První je single point of failure: worker
spadne a lag roste, dokud ho livenessProbe nerestartuje. Druhá je omezená
propustnost, protože jeden PHP proces odbaví řádově jednotky tisíc zpráv
za sekundu.
Pro produkci s vyšším objemem nebo vyšším HA požadavkem se nabízí dvě cesty:
Cesta 1 – leader election přes Redis/etcd. Více workerů běží, ale jen jeden je „leader“ a publikuje. Když leader spadne, do 5 s ho nahradí jiný. Důsledek: HA bez double publish, ale pořád jen jeden worker dispatchuje (nezvyšuje propustnost).
Worker volá acquireOrRenew() každé 3 sekundy (TTL 10 s dává buffer pro síťové
zpoždění). Když vrátí false, worker stojí. Když ji při následujícím tiku vrátí true,
začne dispatchovat – nový leader. Pozor: processing batch musí doběhnout dřív,
než TTL lease vyprší, nebo si worker musí lease během batche průběžně obnovovat.
Jinak lease převezme nový leader a začne dispatchovat řádky, které starý worker
ještě publikuje → double publish.
Cesta 2 – SELECT … FOR UPDATE SKIP LOCKED. Více workerů paralelně, každý
si zarezervuje vlastní batch řádků. Žádný leader, žádný single point of failure,
škáluje se lineárně s počtem worker replik.
Propustnost pak roste zhruba lineárně s počtem workerů a at-least-once garance
zůstává zachovaná. PostgreSQL od verze 9.5 (SKIP LOCKED) i MySQL 8
to podporují. Cena: nutnost koordinace pořadí (eventy ze stejného agregátu
se mohou publikovat out-of-order, pokud workery zpracovávají různé batche).
Pokud subscriber pořadí potřebuje, partition outbox na aggregate_id a každý
worker řízeně zpracovává jen vlastní partition.
Backpressure – co když broker nestíhá
Když Kafka/RabbitMQ nestíhá přijímat (síťová chyba, broker disk full, partition
leader election), relay worker dostává timeout/error na publish. Outbox řádky
zůstávají pending, kupí se. Nezasahujte do produkčních INSERTů: jakmile
začnete blokovat aplikační vrstvu, šíříte výpadek brokera do core domény.
Standardní vzor má čtyři složky. Worker po failed publish přechází na
exponential backoff a čeká 1 s, 2 s, 4 s, maximálně 30 s. Mezitím loguje
outbox_publish_errors_total. Alert hlídá rychlost růstu pending:
delta(outbox_pending_count[5m]) > 10000 signalizuje, že produce převyšuje
consume a broker nestíhá. Kapacitně musí databáze absorbovat 30 minut
brokerového výpadku; při 1k events/s to je 1,8 mil. řádků navíc, tedy rozpočet
na disk a vacuum. A u low-priority eventů (audit, metrics), které jsou
tolerantní ke ztrátě, lze při sustained backpressure zvážit řízený sampling.
Doménové eventy (OrderPlaced) ale zahodit nelze, ty musí dorazit.
15.08 Anti-vzory#
Outbox má jednoduché schéma, a právě proto kolem něj v code review padají stále stejné chyby, které ruší jeho garance a vrací systém k dual-write problému. Níže jsou ty nejčastější.
15.09 Migrace existujícího projektu – krok za krokem#
Jak na Outbox, když máte 18 měsíců starý Symfony projekt, sto handlerů a publish-after-flush už běží někde v útrobách? Postup je inkrementální, ne big-bang refaktor. 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 15.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::fromIntegrationEvent($integrationEvent)).
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 15.05
a nasaďte pod supervisorem. V tomto bodě může worker už publikovat eventy
z outboxu – pokud máte legacy publish dál aktivní, broker dostane obě verze.
Subscribery ale ještě nemají Inbox, takže duplicitu nikdo neodchytí.
Krok 4: Přidat inbox subscriberům jeden po druhém
Pro každý subscriber kontextu vytvořte inbox tabulku, refaktorujte handler
podle sekce 15.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 doručení doménových eventů zůstává jen na outboxu. Jde o riskantní krok – během
prvních dnů sledujte outbox lag a inbox dedupy. Pokud něco selhává, revert pull requestu
vrátí změnu během pěti minut.
Krok 6: Měřit a tunit
Po měsíci provozu projděte metriky: jaký je medián lagu, jakým tempem 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.
15.10 Shrnutí#
Outbox Pattern stojí na tabulce navíc, jednom Symfony commandu a úpravě jednoho application handleru. Výměnou vyřadí celou třídu chyb (ztracené eventy, fantom eventy), které byste jinak ladili reaktivně ve tři ráno z logů. 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 poskytuje exactly-once efekt – každý event se v read modelu projeví právě jednou, i když broker dodá zprávu vícekrát.
Srovnání s alternativami
Outbox není jediná odpověď na dual-write. Ostatní cesty mají užší záběr nebo vyšší cenu.
| Řešení | Jak řeší dual-write | Kdy dává smysl |
|---|---|---|
| Transactional Outbox | Zápis události do téže DB transakce, publikuje relay | Výchozí volba všude, kde agregát žije v ACID databázi |
| Event Sourcing | Událost je stav, druhý zápis neexistuje | Když se pro doménu vyplatí i zbytek modelu, ne jen kvůli doručení |
| Listen-to-yourself | Aplikace nejdřív publikuje, DB zapíše až konzument vlastní zprávy | Když je broker spolehlivější než vlastní DB a čtení smí být opožděné |
| Synchronní volání | Dual-write nevzniká, kontexty se volají přímo | Malý systém bez asynchronní integrace; platí se autonomií kontextů |
| 2PC / XA | Distribuovaná transakce nad DB i brokerem | Prakticky nikdy – viz 15.01 |
Kombinace se nevylučují. Event-sourcovaný kontext outbox tabulku nepotřebuje, protože event store ji zastane, ale Inbox na straně konzumenta potřebuje pořád.
Hlavní body pro praxi:
- Outbox je tabulka v téže DB jako doménový stav – jinak nedává smysl.
- Doctrine entita potřebuje
#[ORM\Index(columns: ['status', 'occurred_at'])], bez něj relay dělá full table scan při každém pollingu. $em->wrapInTransaction(...)v handleru garantuje atomicitu order + outbox řádky.- Polling Publisher pod supervisorem stačí pro téměř každý Symfony projekt; Transaction Log Tailing přes Debezium pouze pro Kafka-native systémy s vysokým objemem, a i tam za cenu jiného schématu tabulky.
- 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 běžný Symfony projekt zvolte Polling Publisher (varianta A). Operační režie je minimální (jeden Symfony command pod supervisorem) a latence pod 1 sekundou je 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 15.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?
Standardně 100 řádků za polling cyklus s intervalem 100 ms. Sama kadence tedy dovolí 1 000 zpráv za sekundu na jeden worker; skutečné číslo určí latence brokera a databáze. 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 mikroservisami, 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 mikroservis: 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 mikroservisá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 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 nesmí mazat pending řádky, mazat lze jen sent starší než N dní. 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 UUID v7 (případně ULID), ne AUTO_INCREMENT. Důvody: (1) UUID v7 je globálně unikátní napříč instancemi DB – nehrozí kolize při replikaci, restore z backupu nebo migraci. (2) Nese časový komponent, takže ID koreluje s pořadím vytvoření – užitečné pro debugging a pro indexové scany. (3) Klient ho může vygenerovat předem a poslat jako event_id v Idempotency-Key headeru. (4) AUTO_INCREMENT komplikuje sharding a multi-region nastavení. Symfony Uid komponenta poskytuje pohodlné API: Uuid::v7() v entitě stačí.