Lecțiile precedente au tratat stream-urile ca pe ceva care există deja. Evenimentele ajung la Kafka, procesorul le consumă, watermark-urile avansează, tranzacțiile se commit-uiesc. Niciuna din acele lecții n-a pus întrebarea evidentă din amonte: de unde vin evenimentele, în primul rând?
Pentru unele sisteme răspunsul e „un serviciu produce evenimente direct”. Un utilizator dă click pe „cumpără acum”, serviciul de comenzi scrie un eveniment OrderPlaced în Kafka, consumatorii din aval își fac treaba. Asta merge când singura consecință a click-ului e evenimentul. Încetează să meargă în momentul în care comanda trebuie scrisă și într-o bază de date, ceea ce se întâmplă mai mereu, pentru că serviciile din sistemele reale dețin stare și starea trăiește în baze de date OLTP.
În clipa în care un serviciu trebuie să-și actualizeze baza de date și să publice un eveniment, te confrunți cu problema dual-write. E una din acele probleme care la prima vedere par triviale și de fapt sunt o mică problemă de tranzacții distribuite deghizată, cu aceleași răspunsuri (și aceleași compromisuri) ca orice altă problemă de tranzacții distribuite din acest curs.
Problema dual-write
Forma naivă:
def place_order(order):
db.insert(order)
kafka.publish("orders", OrderPlaced(order))
Două operații. Două sisteme. Nicio tranzacție comună. Nu există niciun protocol care să facă „scrie în Postgres și scrie în Kafka” atomic, pentru că Postgres și Kafka au noțiuni separate de commit și niciun coordonator comun. Perechea de scrieri fie reușesc amândouă, fie eșuează amândouă, fie una reușește și cealaltă eșuează.
Primele două rezultate sunt ok. Al treilea e bug-ul. Are două variante.
DB reușește, Kafka eșuează. Comanda e în baza de date. Evenimentul nu e în Kafka. Consumatorii din aval (depozit, facturare, email de confirmare) nu aud niciodată despre comandă. Utilizatorul vede o pagină de confirmare (pentru că API-ul a returnat 200 după scrierea în DB), dar coletul nu pleacă niciodată. Starea lumii e inconsistentă cu ceea ce restul sistemului crede că e starea lumii.
Kafka reușește, DB eșuează. Evenimentul e în Kafka. Comanda nu e în baza de date. Consumatorii din aval procesează o comandă fantomă. Depozitul pregătește o comandă care nu există. Facturarea încarcă un card pentru un rând pe care nimeni nu-l poate căuta. Sistemul i-a spus lumii o minciună.
Ambele variante apar în producție. Apar pentru că rețeaua dintre serviciu și unul din cele două sisteme are un mic glitch exact în momentul nepotrivit, pentru că un proces e ucis între cele două scrieri, pentru că un deploy aterizează la mijloc, pentru că baza de date e scurt sub încărcare mare și a doua scriere face timeout. Fereastra e mică, dar nu e zero, iar la scară „probabilitate mică” devine „se întâmplă în fiecare marți”.
Instinctul e să înfășori cele două scrieri într-un try/catch și să faci „rollback” dacă a doua eșuează. Instinctul ăla e greșit. Dacă publicarea în Kafka eșuează, poți face rollback la tranzacția din baza de date, sigur. Dacă commit-ul în baza de date reușește și apoi publicarea în Kafka eșuează, nu poți face rollback la baza de date, pentru că deja a făcut commit. Dacă publicarea în Kafka reușește și DB-ul eșuează, nu poți „de-publica” din Kafka. Nu există rollback simetric disponibil, pentru că cele două sisteme nu se coordonează.
Modul de eșec pe care nu-l poți evita prin defensivitate la nivel de cod e cel în care rețeaua se partiționează la mijlocul drumului. Prima scriere a returnat succes. Procesul e ucis. A doua scriere nu se întâmplă niciodată. Nu există niciun bloc finally în lume care să rezolve asta, pentru că procesul nu rulează.
Problema dual-write trebuie rezolvată la nivelul arhitecturii, nu la nivelul funcției. Două pattern-uri o rezolvă bine și țintesc forme ușor diferite ale problemei. Ambele sunt comune în producție. Multe sisteme le folosesc pe amândouă, pentru tipuri diferite de evenimente.
Soluția 1: Change Data Capture
CDC inversează problema. În loc să ceri aplicației să scrie în două sisteme, lași aplicația să scrie într-unul singur, baza de date, și lași un proces separat să transforme log-ul de commit-uri al bazei de date în evenimente.
Fiecare bază de date tranzacțională ține un write-ahead log al fiecărei modificări pe care a făcut-o vreodată. Postgres îi spune WAL. MySQL îi spune binary log. SQL Server, Oracle și bazele de date din cloud au toate echivalenții lor. Log-ul e sursa din care baza de date însăși reconstruiește starea la recovery după crash și replicează către read replicas. E durabil, ordonat și include fiecare schimbare commit-uită.
Tooling-ul CDC citește acel log, transformă fiecare modificare de rând într-un eveniment structurat și publică evenimentul în Kafka. Serviciul aplicație scrie în baza de date normal. Nu știe că CDC există. Commit-ul în baza de date și publicarea evenimentului nu mai sunt două scrieri pe care aplicația trebuie să le coordoneze; publicarea evenimentului e o consecință din aval a commit-ului, derivată citind același log pe care baza de date îl folosește pentru replicare.
Tool-ul dominant în lumea open-source e Debezium, un framework CDC construit pe Kafka Connect. Debezium are connectori pentru Postgres, MySQL, SQL Server, MongoDB, Oracle și câțiva alții. Fiecare connector parsează formatul de log specific bazei de date și emite o formă normalizată de eveniment (starea rândului înainte/după, tipul operației, metadate de sursă) către un topic Kafka, un topic per tabel sursă în mod implicit.
Alte tool-uri acoperă teren similar. Maxwell e un tool CDC long-running doar pentru MySQL. AWS DMS oferă CDC gestionat peste majoritatea bazelor de date majore, cu output către Kinesis sau direct către S3 sau Redshift. Flink CDC împachetează connectorii Debezium ca să poată fi folosiți direct ca surse Flink. Confluent Cloud vinde un echivalent Debezium găzduit. Alegerea dintre ele e o funcție de din ce bază de date citești, dacă vrei gestionat sau self-hosted și ce faci pe partea de consumator.
flowchart LR
App[Application service] -->|INSERT/UPDATE/DELETE| DB[(Postgres)]
DB -->|WAL| CDC[Debezium connector]
CDC -->|row-change events| Kafka[(Kafka topics)]
Kafka --> C1[Warehouse consumer]
Kafka --> C2[Search index consumer]
Kafka --> C3[Analytics consumer]
CDC are două proprietăți puternice. Prima, captează fiecare modificare a bazei de date, inclusiv modificări făcute de alte servicii, de SQL manual, de migrări de date, de orice atinge baza de date. Stream-ul de evenimente e un log complet al schimbărilor de stare, ceea ce e exact ce vrea de obicei un index de căutare din aval, un invalidator de cache sau un warehouse de analiză. A doua, codul aplicației nu se schimbă deloc. Adăugarea CDC într-un sistem e o schimbare la nivelul deployment-ului, nu la nivelul codului.
Are și limite. Evenimentele CDC descriu schimbări la nivel de rând („tabela orders are acum acest rând în această stare”), nu intenția la nivel de business („o comandă a fost plasată și expediată la adresa X cu codul de discount Y”). Reconstruirea intenției din schimbările la nivel de rând e uneori ușoară și uneori un coșmar. Dacă cinci rânduri se schimbă într-o singură tranzacție, stream-ul CDC are cinci evenimente. Consumatorul din aval trebuie să știe că merg împreună. Unele configurații Debezium păstrează granițele tranzacției, dar consumarea lor cere atenție.
CDC expune și schema bazei de date direct. Fiecare redenumire de coloană, fiecare coloană ștearsă, fiecare schimbare de tip, devine o schimbare incompatibilă a schemei evenimentului. Echipele din aval de stream-ul CDC se trezesc cuplate la schema bazei de date din amonte într-un mod în care nu erau atunci când serviciul din amonte emitea evenimente de business. Cuplajul e real și trebuie gestionat, adesea cu un strat de traducere care convertește evenimentele CDC în evenimente de business stabile înainte să traverseze granițele între echipe.
Soluția 2: outbox-ul tranzacțional
Pattern-ul outbox păstrează aplicația la cârma evenimentelor pe care le emite, rezolvând în același timp problema dual-write.
Forma: în aceeași tranzacție de bază de date care scrie starea de business, aplicația scrie un rând într-un tabel dedicat outbox. Rândul outbox conține payload-ul evenimentului, un ID de eveniment, topic-ul destinație și un timestamp. Tranzacția face commit la ambele rânduri împreună. Există o singură scriere, către un singur sistem, iar garanțiile tranzacționale ale bazei de date se ocupă de atomicitate.
Un proces separat, outbox poller-ul, scanează periodic tabela outbox pentru rânduri nepublicate, publică pe fiecare în Kafka și le marchează ca publicate (sau le șterge). Poller-ul e at-least-once: ar putea publica un rând, să crape înainte să-l marcheze publicat, iar la restart să publice același rând a doua oară. E ok, pentru că consumatorii sunt idempotenți (lecția 16 și lecția 45) și deduplichează după ID-ul de eveniment pe care aplicația l-a generat când a scris rândul outbox.
flowchart LR
App[Application service] -->|tx: insert order + outbox row| DB[(Postgres)]
DB -->|both committed atomically| DB
Poller[Outbox poller] -->|SELECT FROM outbox WHERE published=false| DB
Poller -->|publish event| Kafka[(Kafka)]
Poller -->|UPDATE outbox SET published=true| DB
Atomicitatea e ce face pattern-ul să meargă. Fie comanda și rândul outbox sunt amândouă commit-uite, fie niciuna nu e. Nu există niciun mod de eșec în care comanda să existe fără o intrare outbox corespunzătoare. Publicarea în Kafka e o preocupare din aval căreia i se permite să fie at-least-once, pentru că consumatorul absoarbe duplicatele.
Poller-ul poate fi un mic serviciu separat, un job condus de cron sau, tot mai des, un connector Debezium îndreptat spre tabela outbox. Ultima opțiune e combinația elegantă: outbox-ul rezolvă problema dual-write la nivelul aplicației, iar CDC-ul Debezium pe tabela outbox furnizează mecanismul de publicare fără ca cineva să scrie un poller custom.
Costul principal al pattern-ului outbox e tabela în plus și mica suprasarcină operațională a poller-ului. Poller-ul trebuie monitorizat (un poller blocat înseamnă că evenimentele nu curg, iar tabela outbox crește nelimitat). Tabela outbox are nevoie de un index pe coloana de nepublicat, ca polling-ul să fie ieftin. Poller-ul trebuie să se ocupe atent de ordonare dacă ordinea contează per entitate. Niciuna din astea nu e grea. Sunt genul de muncă pe care o faci o singură dată per serviciu.
Când să folosești care
CDC și outbox rezolvă probleme suprapuse cu forme diferite, iar un sistem matur le folosește adesea pe ambele.
CDC e potrivit când: scopul e să captezi fiecare modificare a unei baze de date, indiferent ce cod a cauzat-o. Sincronizarea indexului de căutare, warehousing-ul de analiză, invalidarea de cache, replicarea către o regiune sau stack diferit. Consumatorul vrea un log complet al schimbărilor de stare; nu-i pasă de intenția de business.
Outbox e potrivit când: scopul e să emiți evenimente cu sens de business, doar la momentele alese de aplicație, cu payload-uri proiectate pentru consumatorii din aval. OrderPlaced, PaymentRefunded, UserDeactivated. Consumatorului îi pasă de ce s-a întâmplat în termeni de business, nu de care rânduri s-au schimbat în termeni de bază de date. Aplicația deține vocabularul evenimentelor.
Un peisaj tipic de microservicii ajunge să le aibă pe amândouă. Debezium captează fiecare schimbare în fiecare bază de date operațională și o scrie într-un nivel de topic-uri „data plane” pe care le consumă warehouse-ul de analiză și sistemul de căutare. Serviciile aplicație scriu în plus în tabele outbox și emit evenimente de business către un nivel de topic-uri „service plane” pe care le consumă alte servicii. Cele două niveluri au audiențe diferite, scheme diferite și politici de management al schimbării diferite, și coexistă curat.
Firul
Problema dual-write e, în esență, o problemă de tranzacție distribuită. Lecțiile Modulului 2 se aplică: nu există un 2PC gata de raft între o bază de date de aplicație și o magistrală de mesaje, pattern-urile care merg sunt cele care ocolesc imposibilitatea, iar proprietatea pe care o urmărești e consistența end-to-end, nu commit-la-sursă-și-roagă-te.
CDC ocolește imposibilitatea prin colapsarea celor două scrieri într-una și citirea rezultatului din log-ul propriu al bazei de date. Outbox o ocolește prin colapsarea celor două scrieri într-o singură tranzacție, cu publicarea ca o consecință din aval. Ambele sunt pragmatice, ambele sunt larg folosite și ambele înlocuiesc un pattern fundamental nesigur (încearcă să scrii în două sisteme și speră) cu unul sigur (scrie într-un singur sistem și lasă un proces separat să derive restul).
Modulul 6 a acoperit acum streaming-ul end to end: motoarele, topologiile, starea, timpul, semantica de livrare și puntea de la bazele de date OLTP în stratul de streaming. Lecția următoare se mută la ordonare, partiționare și back-pressure, care sunt realitățile operaționale în care intră fiecare pipeline de streaming odată ce a fost live mai mult de un trimestru și încărcarea a crescut peste ce presupunea sizing-ul inițial.
Citări și lecturi suplimentare
- Documentația Debezium, „Debezium architecture” și „Outbox event router”,
https://debezium.io/documentation/(consultat 2026-05-01). Referința atât pentru mecanica CDC, cât și pentru SMT-ul outbox al Debezium. - Gunnar Morling, „Reliable Microservices Data Exchange With the Outbox Pattern”, Red Hat Developer (consultat 2026-05-01). Articolul canonic despre pattern-ul outbox în termeni de Java/Postgres.
- Confluent, „Patterns for streaming microservices” (consultat 2026-05-01). Acoperă dual-write, outbox și CDC în contextul microserviciilor de streaming.
- Documentația AWS Database Migration Service, „Working with change data capture (CDC)”,
https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Source.html(consultat 2026-05-01). - Chris Richardson, „Microservices Patterns” (Manning, 2018). Capitolul despre mesajele tranzacționale și pattern-ul outbox. Consultat 2026-05-01.