Arhitectura datelor și a sistemelor, de la zero Lecția 15 / 80

Two-phase commit și problemele lui

Protocolul de manual pentru tranzacții distribuite, problema de coordinator failure care îl bântuie și de ce sistemele moderne se sprijină pe pattern-ul Saga în schimb.

Lecțiile anterioare din Modulul 2 au trecut prin fallacies of distributed computing, teorema CAP și diversele arome de consistență pe care le poți cere unui data store distribuit să ți le ofere. Fiecare a avut aceeași formă incomodă: în momentul în care încetezi să ai o singură mașină, proprietățile pe care obișnuiai să le iei de bune încetează să fie gratuite.

Tranzacțiile atomice, multi-record sunt cel mai dureros exemplu. O singură instanță Postgres îți dă, gratis, o garanție că fie ambele rânduri se actualizează, fie niciunul. Codul de aplicație nu trebuie să se gândească la asta. BEGIN, faci treaba, COMMIT, iar baza de date ține linia. În momentul în care scrierea ta atinge două baze de date, sau două servicii, sau chiar doar două shard-uri ale aceluiași store logic, acea garanție dispare. Fie o reconstruiești singur, în user space, fie accepți că lumea va fi uneori actualizată pe jumătate pentru o vreme.

Pentru cea mai mare parte a anilor ‘90 și 2000, răspunsul de manual a fost un protocol numit two-phase commit, prescurtat de obicei 2PC. E o idee frumoasă pe hârtie. În practică are un mod de eșec destul de rău încât majoritatea arhitecturilor moderne de microservicii îl evită complet și apelează în schimb la pattern-ul Saga. Lecția asta e despre de ce.

Problema pentru a cărei rezolvare a fost inventat 2PC

Ai o tranzacție care atinge doi participanți. Participanții pot fi două baze de date (un Postgres și un Oracle, în scenarii clasice de integrare enterprise), două servicii care își dețin fiecare propria bază de date sau două shard-uri ale aceluiași store logic gestionate de procese diferite. Vrei aceeași proprietate a stării finale pe care ți-o dă o tranzacție pe o singură bază de date: ori schimbarea se întâmplă la ambii participanți, ori la niciunul. Fără stări de mijloc. Fără „banii au plecat din contul A dar nu au ajuns niciodată la contul B”.

Abordarea naivă nu funcționează. Dacă scrii la participantul A primul și apoi la B, iar B e jos sau respinge scrierea, ai rămas cu o scriere reușită la A pe care acum trebuie să o dai înapoi. Dacă mesajul tău de „rollback” către A eșuează și el, ești acum într-o stare mai rea decât dacă nu ai fi început niciodată. Fiecare reîncercare poate eșua în moduri noi și creative. Nu există un punct la care poți spune „am terminat” cu încredere.

2PC promite să rezolve asta introducând o a treia parte, un coordinator, a cărui singură slujbă e să ia decizia de commit după ce fiecare participant a fost de acord că poate. Coordinator-ul e sursa adevărului despre dacă tranzacția globală a reușit.

Protocolul în cuvinte simple

Două faze, de unde și numele. Vocabularul e „prepare” pentru faza unu și „commit or abort” pentru faza doi.

Phase 1, prepare. Coordinator-ul întreabă fiecare participant aceeași întrebare: „poți face commit la această tranzacție?” Fiecare participant face orice lucrare locală îi e necesară ca să poată răspunde (achiziționează lock-uri, validează constrângeri, alocă resurse) și apoi scrie un răspuns durabil în propriul log. Răspunsul e fie „da, sunt gata să commit-uiesc și am scris acea promisiune pe disc”, fie „nu, nu pot commit-ui această tranzacție”. Participantul îi spune coordinator-ului. Critic, un participant care a răspuns da e acum legat: a promis că dacă i se cere să commit-uiască, va putea. Nu se poate retrage. Nu poate să crash-uiască și să uite. Da-ul e pe disc.

Phase 2, commit or abort. Coordinator-ul colectează toate răspunsurile. Dacă fiecare participant a spus da, coordinator-ul scrie „decision: commit” în propriul log și apoi trimite mesaje de commit către fiecare participant. Fiecare participant face commit la tranzacția locală și confirmă. Dacă măcar un participant a spus nu sau nu a răspuns la timp, coordinator-ul scrie „decision: abort” și trimite în schimb mesaje de abort. Fiecare participant face rollback.

sequenceDiagram
    participant C as Coordinator
    participant A as Participant A
    participant B as Participant B
    Note over C,B: Phase 1 (prepare)
    C->>A: prepare?
    C->>B: prepare?
    A->>A: lock, validate, log "yes"
    B->>B: lock, validate, log "yes"
    A-->>C: yes
    B-->>C: yes
    Note over C,B: Phase 2 (commit)
    C->>C: log "decision: commit"
    C->>A: commit
    C->>B: commit
    A-->>C: ack
    B-->>C: ack

Asta e calea fericită. Citește-o de două ori. E scurtă, e simetrică și aproape funcționează.

Problema coordinator-failure

Iată defectul faimos și e motivul pentru care 2PC are reputația pe care o are.

Imaginează-ți că coordinator-ul a terminat phase 1. Fiecare participant a spus da. Fiecare participant ține acum lock-uri, în starea prepared, așteptând următorul mesaj. Coordinator-ul scrie „decision: commit” în log-ul său și e pe cale să trimită mesajele de commit. Exact în acel moment, mașina coordinator-ului face power-cycle.

Ce poate face fiecare participant? Nimic util. Nu pot face commit, pentru că nu li s-a spus. Nu pot face abort, pentru că au promis că pot face commit. Sunt in doubt. Lock-urile pe care le-au achiziționat în phase 1 sunt încă ținute. Orice altă tranzacție care vrea acele rânduri e blocată. Orice citire care intră în conflict cu acele lock-uri e blocată. Participanții vor sta acolo, ținând tranzacții deschise, blocând lumea afară, până când coordinator-ul revine și își reia log-ul de decizii.

Într-un manual, asta sună tolerabil. Într-un incident de producție e catastrofal. Coordinatorii revin când oamenii paginează oameni, iar oamenii consumă timp. Între timp, lock-urile participanților nu sunt metaforice. Cereri reale către clienți sunt blocate. Workers reali sunt blocați în pool-urile lor de conexiuni așteptând acele lock-uri. Sistemul din jurul tranzacției in-doubt se degradează, apoi se degradează mai repede, apoi se prăbușește.

Asta nu e ipotetic. Fiecare inginer care a rulat tranzacții XA pe bune are o poveste despre un coordinator care a dispărut și a lăsat o bază de date participantă cu tranzacții prepared vechi de o oră înțepenite în lock manager-ul ei. Procedura de recuperare implică de obicei un administrator de bază de date și o comandă manuală de a forța rezolvarea tranzacției blocate într-un fel sau altul. Uneori răspunsul corect e „commit-uiește-l și cere-ți scuze”. Uneori e „abort-uiește-l și explică finance-ului mai târziu”. Nu există o cale automată de a ști.

Celelalte puncte de durere ale 2PC

Problema coordinator-failure e titlul, dar nu e singurul motiv pentru care 2PC a căzut din grații pentru arhitecturi greenfield.

Coordonarea sincronă omoară latența. Fiecare tranzacție cross-participant necesită acum două round-trip-uri către fiecare participant înainte să se poată termina. Dacă ai trei participanți și un round-trip de 5 ms la fiecare, pragul tranzacției tale e 30 ms înainte să fi făcut vreo muncă reală. Pentru sisteme cu throughput mare, ăsta e un buget pe care nu ți-l permiți.

Un singur participant lent îi blochează pe toți. Coordinator-ul nu poate termina phase 1 până când fiecare participant nu a răspuns. Un singur participant care e lent astăzi, poate pentru că face un vacuum sau un backup sau un episod de noisy-neighbour, trage latența fiecărei tranzacții la nivelul lui. Patologia clasică a sistemelor distribuite a „celui mai lent nod care setează viteza” se aplică aici la cel mai prost moment posibil.

High availability pentru coordinator nu te salvează complet. Răspunsul evident la „coordinator-ul e un single point of failure” e „fă coordinator-ul un cluster”. Manager-ele moderne de tranzacții fac exact asta, cu consensus între replicile coordinator-ului asupra log-ului de decizii. Asta ajută mult. Nu elimină problema in-doubt, pentru că participanții pot fi totuși izolați de quorum-ul coordinator-ului. Adaugă de asemenea complexitate operațională pe care majoritatea echipelor o subestimează până prima dată când trebuie să debug-uieze un coordinator susținut de Raft care refuză să aleagă un nou leader.

Raza de explozie a unei partiții e mai mare decât pare. În termeni CAP, 2PC alege explicit consistența peste availability. În timpul unei partiții, tranzacțiile nu pot face commit. Asta e prin design. E de asemenea ce vor să spună oamenii când zic „2PC nu supraviețuiește rețelei”. Protocolul e corect. Costul e că, în modul de eșec în care majoritatea tranzacțiilor trebuie să aterizeze, acest protocol refuză să aterizeze vreuna.

Unde 2PC e încă potrivit

Merită să fim corecți cu 2PC. Există workload-uri unde e răspunsul potrivit.

Cazul cel mai clar e o tranzacție care trebuie să se întindă pe exact două baze de date, unde ambele sunt în același data center, unde latența de round-trip e în microsecunde și unde aplicația poate tolera incidentul rar de „a murit coordinator-ul, te rog sună DBA-ul”. Multe scenarii de integrare enterprise din anii 2000 arată așa. Tranzacțiile XA peste un transaction manager JTA erau, pentru acea formă de workload, irecuzabile.

E de asemenea acceptabil în cadrul unui singur produs de bază de date care folosește 2PC intern pentru a coordona între propriile shard-uri. CockroachDB, YugabyteDB și Spanner folosesc toate protocoale care arată ca un 2PC îmbunătățit sub capotă. Ce e diferit e că coordinator-ul și participanții sunt operați de aceeași echipă, pe același țesut hardware, cu aceeași cadență de release. Scenariul coordinator-failure e ceva la care vendor-ul bazei de date s-a gândit, l-a întărit și l-a testat, iar recuperarea e automatizată în loc de manuală. Asta e diferența între „2PC în interiorul bazei de date” și „2PC între aplicații”.

Pentru arhitecturi noi care se întind pe servicii deținute de echipe diferite, care vorbesc peste o rețea reală cu partiții reale, unde echipa operațională pentru serviciul A nu e echipa operațională pentru serviciul B, 2PC e aproape niciodată decizia corectă.

Ce folosesc arhitecturile moderne în schimb

Pattern-ul care a deplasat 2PC pentru tranzacțiile cross-service e Saga.

Ideea e structurală. O Saga reformulează o tranzacție distribuită nu ca o singură operație atomică, ci ca o secvență de tranzacții locale, fiecare având o compensation definită. O compensation e operația care anulează semantic tranzacția locală. Dacă ai taxat un card, compensation-ul e un refund. Dacă ai rezervat un loc, compensation-ul e să eliberezi locul. Dacă ai trimis un email de confirmare, compensation-ul e să trimiți un email de „rezervarea ta a fost anulată”. Fiecare pas commit-uie local, cu garanțiile locale ale propriei baze de date.

Saga are apoi o regulă simplă. Dacă pasul N eșuează, rulează compensations pentru pasul N-1, N-2, până înapoi la pasul 1, în ordine inversă. Starea finală e fie „fiecare pas a făcut commit”, fie „fiecare pas care a făcut commit a fost compensat”. Sistemul e eventually consistent: există o fereastră în care unii pași au făcut commit și alții nu, iar aplicația trebuie să fie proiectată să tolereze acea fereastră.

Există două arome de implementare Saga, iar diferența e cine orchestrează.

Într-o Saga choreographed, fiecare serviciu ascultă evenimente și reacționează. Serviciul de booking emite „booking created”. Serviciul de payment îl consumă, taxează cardul și emite „payment captured”. Serviciul de seat consumă asta, rezervă locul și emite „seat reserved”. Dacă vreun pas eșuează, acel serviciu emite un eveniment de eșec, iar serviciile anterioare îl consumă și își rulează compensation-ul. Nu există un coordinator central. Fluxul e implicit în topologia evenimentelor.

Într-o Saga orchestrated, un singur serviciu orchestrator știe pașii și spune fiecărui serviciu ce să facă, în ordine. Orchestrator-ul e el însuși doar o aplicație, adesea construită pe un workflow engine ca Temporal sau AWS Step Functions, cu stare durabilă pentru progresul saga-ei. Orchestrator-ul e recuperabil: dacă crash-uiește, reia de unde a rămas, citind din propriul log.

Ambele își au locul lor. Choreography e slab cuplată și strălucește când saga e scurtă și pașii sunt evident independenți. Orchestration e mai ușor de raționat când saga e lungă, când ordinea pașilor contează subtil sau când gestionarea erorilor implică mai mult decât doar „compensează totul”. Pentru majoritatea echipelor care încep de la zero, un orchestrator construit pe un workflow engine e opțiunea cu costul cognitiv mai mic, pentru că engine-ul gestionează problemele de durabilitate, retry și timeout pe care o saga bazată pe evenimente le-ar reinventa altfel.

Un exemplu funcțional: plata unui zbor

Imaginează-ți un flux de booking de zbor. Utilizatorul dă click pe „buy”. Trei lucruri trebuie să se întâmple, în ordine:

  1. Taxează cardul utilizatorului prin serviciul de payment.
  2. Rezervă un loc în serviciul de inventory.
  3. Trimite o confirmare de booking prin serviciul de notification.

Versiunea naivă 2PC ar coordona toate trei sub o tranzacție globală. Dacă ceva eșuează, abort peste tot. Dezavantajele protocolului se aplică: lock-uri ținute peste cel mai lent serviciu, un coordinator care trebuie să rămână sus, stări in-doubt dacă crash-uiește.

Versiunea Saga face fiecare pas o tranzacție locală cu o compensation:

sequenceDiagram
    participant O as Orchestrator
    participant P as Payment
    participant I as Inventory
    participant N as Notification
    O->>P: charge card
    P-->>O: charged (txn id 8821)
    O->>I: reserve seat
    I-->>O: failure, seat already taken
    Note over O,P: compensation
    O->>P: refund txn 8821
    P-->>O: refunded
    O-->>O: saga ends with state "aborted"

Dacă rezervarea locului eșuează, orchestrator-ul rulează compensation-ul de refund împotriva serviciului de payment. Cardul utilizatorului e taxat pentru câteva secunde, apoi rambursat. Locul nu a fost niciodată rezervat. Notificarea nu a fost niciodată trimisă. Starea finală e consistentă în sens eventual: fiecare pas care a făcut commit a fost compensat.

Aplicația trebuie să gestioneze fereastra de eventual consistency. Mai exact, între taxare și rambursare utilizatorul are pentru scurt timp o taxare pe card fără o rezervare de arătat. Pentru un flux de booking de zbor asta e acceptabil, pentru că fereastra e scurtă, iar utilizatorul se uită la un ecran de „processing your booking”. Pentru alte fluxuri fereastra poate fi inacceptabilă, iar designul trebuie să se schimbe. Asta e conversația arhitecturală pe care Saga te forțează să o porți.

Când totuși ai nevoie de scrieri atomice cross-service

Uneori fereastra de eventual-consistency a unei Saga e cu adevărat prea dureroasă. Exemplul clasic e pattern-ul transactional outbox, unde un serviciu trebuie să atomic (a) scrie un rând în propria bază de date și (b) publice un eveniment către un message broker. Dacă cele două operații nu sunt atomice, poți publica evenimente pentru schimbări de stare care nu s-au întâmplat sau să persiști schimbări de stare ale căror evenimente nu sunt niciodată publicate. Oricare e un bug real.

Pattern-ul outbox rezolvă asta fără 2PC. Serviciul scrie rândul evenimentului într-un tabel local „outbox” în cadrul aceleiași tranzacții locale care actualizează starea sa de business. Un worker separat citește tabelul outbox și publică către broker, apoi șterge rândul. Atomicitatea e locală. Publicația e at-least-once și idempotentă pe partea consumatorului. Acest pattern, și fratele său transactional inbox, sunt caii de povară ai arhitecturilor moderne event-driven și vom petrece o lecție întreagă cu ei în Modulul 4.

Recomandarea în 2026

Pentru arhitecturi noi, preferă Saga, preferă orchestration peste choreography până când ai un motiv bun să inversezi asta și preferă pattern-ul transactional outbox pentru cazul specific de „actualizează atomic starea și emite un eveniment”. Apelează la 2PC doar când ambii participanți sunt în interiorul unui singur perimetru de încredere și operații, bugetul de latență o poate absorbi, iar povestea de recuperare a fost gândită până la capăt.

Cel mai important, proiectează aplicația să tolereze fereastra de eventual-consistency. Asta e schimbarea arhitecturală. 2PC încearcă să facă tranzacțiile distribuite să se simtă ca cele locale. Saga nu o face. Îți spune, explicit, că va exista o fereastră în care lumea e actualizată pe jumătate și te pune să spui, în cod și în textul produsului, ce vede utilizatorul în timpul acelei ferestre. Acea cinste e o feature, nu un defect.

Lecția următoare, ultima din Modulul 2, e despre cum să faci toate astea să funcționeze în prezența mesajelor duplicate, retry-urilor și imposibilității livrării exactly-once. E lecția care închide modulul oferindu-ți unealta arhitecturală care, mai mult decât oricare alta, face sistemele distribuite să se comporte: idempotency.

Citations and further reading

  • Butler Lampson, “How to Build a Highly Available System Using Consensus” (1996). The classic reference that lays out 2PC and its limitations alongside earlier consensus protocols.
  • Hector Garcia-Molina and Kenneth Salem, “Sagas” (1987). The original paper that introduces the Saga pattern as an alternative to long-running ACID transactions.
  • Chris Richardson, “Microservices Patterns” (Manning, 2018), chapters on Saga and transactional messaging. Plain-language treatment of orchestrated vs choreographed Sagas with worked Java examples.
  • Temporal documentation on workflow-based saga orchestration, https://docs.temporal.io/ (retrieved 2026-05-01).
Caută