Le lezioni precedenti del Modulo 2 hanno passato in rassegna le fallacie del distributed computing, il teorema CAP, e i vari sapori di consistency che puoi chiedere a un data store distribuito. Ognuna di esse aveva la stessa scomoda forma: nel momento in cui smetti di avere una sola macchina, proprietà che davi per scontate smettono di essere gratis.
Le transazioni atomiche multi-record sono l’esempio più doloroso. Una singola istanza di Postgres ti dà, gratis, la garanzia che o entrambe le righe vengono aggiornate o nessuna delle due. Il codice applicativo non ci deve nemmeno pensare. BEGIN, fai il lavoro, COMMIT, e il database tiene la linea. Nel momento in cui la tua write tocca due database, o due servizi, o anche solo due shard dello stesso store logico, quella garanzia se ne va. O la ricostruisci tu, in user space, oppure accetti che il mondo, a volte, resterà aggiornato a metà per un po’.
Per la maggior parte degli anni ‘90 e 2000 la risposta da manuale era un protocollo chiamato two-phase commit, di solito abbreviato in 2PC. Sulla carta è un’idea bellissima. In pratica ha una failure mode così brutta che la maggior parte delle architetture moderne a microservizi lo evita del tutto e si rivolge invece al pattern Saga. Questa lezione spiega perché.
Il problema che 2PC è stato inventato per risolvere
Hai una transazione che tocca due partecipanti. I partecipanti potrebbero essere due database (un Postgres e un Oracle, nei classici scenari di enterprise integration), due servizi che possiedono ciascuno il proprio database, o due shard dello stesso store logico gestiti da processi diversi. Vuoi la stessa proprietà di stato finale che ti dà una transazione su un singolo database: o il cambiamento avviene su entrambi i partecipanti, o non avviene su nessuno. Niente stati intermedi. Nessun “il denaro ha lasciato il conto A ma non è mai arrivato sul conto B.”
L’approccio ingenuo non funziona. Se scrivi prima sul partecipante A e poi sul partecipante B, e B è giù o rifiuta la write, ti ritrovi con una write andata a buon fine su A che ora devi rollbackare. Se anche il messaggio di “rollback” verso A fallisce, ti trovi in uno stato peggiore di se non avessi mai cominciato. Ogni retry può fallire in modi nuovi e creativi. Non c’è un punto in cui puoi dire “abbiamo finito” con confidenza.
2PC promette di risolvere il problema introducendo una terza parte, un coordinator, il cui unico compito è prendere la decisione di commit dopo che ogni partecipante ha confermato di poterlo fare. Il coordinator è la fonte di verità sul fatto che la transazione globale sia riuscita o meno.
Il protocollo a parole semplici
Due fasi, da cui il nome. Il vocabolario è “prepare” per la fase uno e “commit or abort” per la fase due.
Fase 1, prepare. Il coordinator pone a ogni partecipante la stessa domanda: “puoi fare il commit di questa transazione?” Ogni partecipante svolge il lavoro locale necessario per poter rispondere (acquisisce lock, valida i constraint, alloca le risorse) e poi scrive una risposta durabile sul proprio log. La risposta è o “sì, sono pronto a fare commit e ho scritto questa promessa su disco”, oppure “no, non posso fare commit di questa transazione”. Il partecipante comunica la risposta al coordinator. Aspetto cruciale: un partecipante che ha risposto sì è ora vincolato, ha promesso che, se gli verrà chiesto di fare commit, sarà in grado di farlo. Non può tirarsi indietro. Non può crashare e dimenticarselo. Il sì è su disco.
Fase 2, commit o abort. Il coordinator raccoglie tutte le risposte. Se ogni partecipante ha detto sì, il coordinator scrive “decision: commit” sul proprio log e poi invia messaggi di commit a ogni partecipante. Ogni partecipante esegue il commit della propria transazione locale e fa ack. Se anche solo un partecipante ha detto no, o non ha risposto in tempo, il coordinator scrive “decision: abort” e invia invece messaggi di abort. Ogni partecipante esegue il 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
Quello è l’happy path. Rileggilo due volte. È breve, è simmetrico, e quasi funziona.
Il problema del coordinator failure
Ecco il difetto famoso, ed è la ragione per cui 2PC ha la reputazione che ha.
Immagina che il coordinator abbia finito la fase 1. Ogni partecipante ha detto sì. Ogni partecipante sta ora trattenendo lock, nello stato “prepared”, in attesa del prossimo messaggio. Il coordinator scrive “decision: commit” sul proprio log e sta per inviare i messaggi di commit. In quel preciso istante, la macchina del coordinator viene spenta e riaccesa.
Cosa può fare ogni partecipante? Niente di utile. Non possono fare commit, perché non gli è stato detto di farlo. Non possono fare abort, perché hanno promesso di essere in grado di fare commit. Sono in doubt. I lock acquisiti in fase 1 sono ancora trattenuti. Qualsiasi altra transazione che voglia quelle righe è bloccata. Qualsiasi read che entri in conflitto con quei lock è bloccata. I partecipanti se ne staranno lì, con transazioni aperte, escludendo il resto del mondo, finché il coordinator non torna e replica il proprio decision log.
In un manuale questa cosa suona tollerabile. In un incidente di produzione è catastrofica. I coordinator tornano quando un essere umano fa il page a un altro essere umano, e gli umani impiegano tempo. Nel frattempo, i lock dei partecipanti non sono metaforici. Vere query rivolte ai clienti sono bloccate. Veri worker sono incagliati nei loro connection pool in attesa di quei lock. Il sistema attorno alla transazione in-doubt si degrada, poi si degrada più velocemente, poi collassa.
Non è un caso ipotetico. Ogni ingegnere che ha fatto girare seriamente XA transactions ha una storia su un coordinator scomparso che ha lasciato un database partecipante con prepared transactions vecchie di un’ora incastrate nel suo lock manager. La procedura di recovery di solito coinvolge un database administrator e un comando manuale per forzare la risoluzione della transazione bloccata in un senso o nell’altro. A volte la risposta giusta è “fai commit e scusati”. A volte è “fai abort e spieghilo al financial più tardi”. Non c’è un modo automatico di saperlo.
Gli altri pain point di 2PC
Il problema del coordinator failure è il titolo da prima pagina, ma non è l’unica ragione per cui 2PC è caduto in disgrazia per le architetture greenfield.
La coordinazione sincrona uccide la latency. Ogni transazione cross-participant richiede ora due round trip verso ogni partecipante prima di poter terminare. Se hai tre partecipanti e un round trip di 5 ms verso ognuno, il floor della tua transazione è 30 ms prima di aver fatto un solo lavoro reale. Per sistemi ad alto throughput è un budget che non ti puoi permettere.
Un partecipante lento blocca tutti. Il coordinator non può finire la fase 1 finché ogni partecipante non ha risposto. Un singolo partecipante che oggi è lento, magari perché sta facendo un vacuum o un backup o vive un episodio da noisy neighbour, trascina la latency di ogni transazione fino al suo livello. La classica patologia dei sistemi distribuiti del “il nodo più lento detta la velocità” si applica qui, nel momento peggiore possibile.
L’high availability del coordinator non ti salva del tutto. La risposta ovvia a “il coordinator è un single point of failure” è “rendi il coordinator un cluster”. I transaction manager moderni fanno esattamente questo, con consensus tra le repliche del coordinator riguardo al decision log. Aiuta molto. Non elimina il problema in-doubt, perché i partecipanti possono comunque finire partizionati via dal quorum del coordinator. Aggiunge anche complessità operativa che la maggior parte dei team sottovaluta finché non si trova per la prima volta a dover debuggare un coordinator backed da Raft che si rifiuta di eleggere un nuovo leader.
Il blast radius di una partition è più grande di come sembra. In termini CAP, 2PC sceglie esplicitamente consistency su availability. Durante una partition, le transazioni non possono fare commit. È by design. È anche ciò che si intende quando si dice “2PC non sopravvive alla rete”. Il protocollo è corretto. Il costo è che, nella failure mode in cui la maggior parte delle transazioni dovrebbe atterrare, questo protocollo si rifiuta di farne atterrare anche solo una.
Dove 2PC è ancora appropriato
Vale la pena essere onesti con 2PC. Ci sono workload in cui è la risposta giusta.
Il caso più chiaro è una transazione che deve coprire esattamente due database, dove entrambi sono dentro lo stesso data centre, dove la latency di round trip è in microsecondi, e dove l’applicazione può tollerare il raro incidente “il coordinator è morto, per favore chiamate il DBA”. Molti scenari di enterprise integration degli anni 2000 hanno questa forma. Le XA transactions sopra un transaction manager JTA erano, per quel tipo di workload, inattaccabili.
È accettabile anche dentro un singolo prodotto database che usa 2PC internamente per coordinare i propri shard. CockroachDB, YugabyteDB e Spanner usano tutti protocolli che sotto il cofano assomigliano a un 2PC potenziato. La differenza è che il coordinator e i partecipanti sono operati dallo stesso team, sullo stesso hardware fabric, con la stessa cadenza di rilascio. Lo scenario di coordinator failure è qualcosa a cui il vendor del database ha pensato, che ha hardenato e testato, e la recovery è automatizzata invece che manuale. Quella è la differenza fra “2PC dentro il database” e “2PC tra applicazioni”.
Per le architetture nuove che spaziano servizi posseduti da team diversi, che parlano su una rete vera con partition vere, dove il team operativo del servizio A non è il team operativo del servizio B, 2PC non è quasi mai la scelta giusta.
Cosa usano invece le architetture moderne
Il pattern che ha rimpiazzato 2PC per le transazioni cross-service è la Saga.
L’idea è strutturale. Una Saga riformula una transazione distribuita non come una singola operazione atomica ma come una sequenza di transazioni locali, ciascuna delle quali ha una compensation definita. Una compensation è l’operazione che disfa semanticamente la transazione locale. Se hai addebitato una carta, la compensation è un refund. Se hai prenotato un posto, la compensation è rilasciare il posto. Se hai mandato un’email di conferma, la compensation è mandare un’email “la tua prenotazione è stata cancellata”. Ogni step fa commit localmente, con le garanzie locali del proprio database.
La Saga ha poi una regola semplice. Se lo step N fallisce, esegui le compensation per lo step N-1, N-2, fino allo step 1, in ordine inverso. Lo stato finale è o “ogni step ha fatto commit” oppure “ogni step che ha fatto commit è stato compensato”. Il sistema è eventually consistent: c’è una finestra durante la quale alcuni step hanno fatto commit e altri no, e l’applicazione deve essere progettata per tollerare quella finestra.
Ci sono due sapori di implementazione di Saga, e la differenza è chi orchestra.
In una Saga choreographed, ogni servizio ascolta eventi e reagisce. Il booking service emette “booking created”. Il payment service lo consuma, addebita la carta, ed emette “payment captured”. Il seat service lo consuma, prenota il posto, ed emette “seat reserved”. Se uno qualsiasi degli step fallisce, quel servizio emette un evento di failure, e i servizi precedenti lo consumano e fanno girare la propria compensation. Non c’è un coordinator centrale. Il flusso è implicito nella topologia degli eventi.
In una Saga orchestrated, un singolo servizio orchestrator conosce gli step e dice a ogni servizio cosa fare, in ordine. L’orchestrator è di per sé solo un’applicazione, spesso costruita su un workflow engine come Temporal o AWS Step Functions, con stato durabile per il progresso della saga. L’orchestrator è recuperabile: se crasha, riprende da dove era rimasto, leggendo dal proprio log.
Entrambe hanno il loro posto. La choreography è loosely coupled e brilla quando la saga è breve e gli step sono ovviamente indipendenti. L’orchestration è più facile su cui ragionare quando la saga è lunga, quando l’ordine degli step conta in modo sottile, o quando l’error handling implica più del semplice “compensa tutto”. Per la maggior parte dei team che partono da zero, un orchestrator costruito su un workflow engine è l’opzione a minor costo cognitivo, perché l’engine si occupa dei problemi di durabilità, retry e timeout che una saga basata su eventi dovrebbe altrimenti reinventare.
Un esempio lavorato: pagare un volo
Immagina un flow di prenotazione voli. L’utente clicca “compra”. Tre cose devono accadere, in ordine:
- Addebitare la carta dell’utente tramite il payment service.
- Riservare un posto nell’inventory service.
- Mandare una conferma di prenotazione tramite il notification service.
La versione 2PC ingenua coordinerebbe tutti e tre sotto una transazione globale. Se qualcosa fallisce, fai abort ovunque. Si applicano gli svantaggi del protocollo: lock trattenuti per tutta la durata del servizio più lento, un coordinator che deve restare su, stati in-doubt se crasha.
La versione Saga rende ogni step una transazione locale con una 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"
Se la prenotazione del posto fallisce, l’orchestrator esegue la compensation di refund contro il payment service. La carta dell’utente è addebitata per qualche secondo, poi rimborsata. Il posto non è mai stato riservato. La notifica non è mai stata mandata. Lo stato finale è consistente nel senso eventuale: ogni step che ha fatto commit è stato compensato.
L’applicazione deve gestire la finestra di eventual consistency. Nello specifico, fra l’addebito e il rimborso l’utente ha brevemente un addebito sulla carta senza una prenotazione corrispondente. Per un flow di prenotazione voli questo è accettabile, perché la finestra è breve e l’utente sta fissando uno schermo di “stiamo elaborando la tua prenotazione”. Per altri flow la finestra può essere inaccettabile e il design deve cambiare. Quella è la conversazione architetturale che la Saga ti costringe ad avere.
Quando ti servono ancora write atomiche cross-service
A volte la finestra di eventual consistency di una Saga è davvero troppo dolorosa. L’esempio classico è il pattern del transactional outbox, in cui un servizio deve atomicamente (a) scrivere una riga nel proprio database e (b) pubblicare un evento su un message broker. Se quelle due operazioni non sono atomiche, puoi pubblicare eventi per cambiamenti di stato che non sono accaduti, oppure persistere cambiamenti di stato i cui eventi non vengono mai pubblicati. Sono entrambi bug veri.
Il pattern outbox risolve questo senza 2PC. Il servizio scrive la riga dell’evento in una tabella locale “outbox” dentro la stessa transazione locale che aggiorna il suo stato di business. Un worker separato legge la tabella outbox e pubblica sul broker, poi cancella la riga. L’atomicità è locale. La pubblicazione è at-least-once e idempotente sul lato consumer. Questo pattern, e suo fratello il transactional inbox, sono i cavalli da soma delle architetture event-driven moderne e dedicheremo loro un’intera lezione nel Modulo 4.
La raccomandazione nel 2026
Per architetture nuove, preferisci le Saga, preferisci l’orchestration alla choreography finché non hai un buon motivo per invertire la scelta, e preferisci il pattern del transactional outbox per il caso specifico di “aggiorna lo stato in modo atomico ed emetti un evento”. Ricorri a 2PC solo quando entrambi i partecipanti sono dentro un singolo confine di trust e di operations, il budget di latency può assorbirlo, e la storia di recovery è stata pensata fino in fondo.
Cosa più importante, progetta l’applicazione per tollerare la finestra di eventual consistency. Quello è lo shift architetturale. 2PC cerca di far sembrare le transazioni distribuite uguali a quelle locali. Le Saga no. Ti dicono, esplicitamente, che ci sarà una finestra in cui il mondo è aggiornato a metà, e ti costringono a dire, nel codice e nella copy di prodotto, cosa vede l’utente durante quella finestra. Quell’onestà è una feature, non un difetto.
La prossima lezione, l’ultima del Modulo 2, parla di come far funzionare tutto questo in presenza di messaggi duplicati, di retry, e dell’impossibilità della delivery exactly-once. È la lezione che chiude il modulo dandoti lo strumento architetturale che, più di ogni altro, fa comportare bene i sistemi distribuiti: l’idempotenza.
Citazioni e ulteriori letture
- Butler Lampson, “How to Build a Highly Available System Using Consensus” (1996). Il classico riferimento che espone 2PC e i suoi limiti accanto ai protocolli di consensus precedenti.
- Hector Garcia-Molina e Kenneth Salem, “Sagas” (1987). Il paper originale che introduce il pattern Saga come alternativa alle transazioni ACID a lunga durata.
- Chris Richardson, “Microservices Patterns” (Manning, 2018), capitoli su Saga e transactional messaging. Trattazione in linguaggio piano delle Saga orchestrated contro choreographed con esempi Java lavorati.
- Documentazione di Temporal sull’orchestrazione delle saga basata su workflow,
https://docs.temporal.io/(consultata 2026-05-01).