Architettura di dati e sistemi, dalle fondamenta Lezione 46 / 80

CDC (Change Data Capture) e il problema del dual-write

Debezium, Maxwell, AWS DMS. Il problema del dual-write e l'outbox pattern che lo risolve.

Le due lezioni precedenti hanno trattato gli stream come una cosa che esiste già. Gli eventi arrivano a Kafka, il processore li consuma, i watermark avanzano, le transazioni fanno commit. Nessuna di quelle lezioni ha posto la domanda ovvia a monte: da dove vengono gli eventi, in primo luogo?

Per alcuni sistemi la risposta è “un servizio produce eventi direttamente.” Un utente clicca “compra ora”, l’order service scrive un evento OrderPlaced su Kafka, i consumer a valle fanno il loro lavoro. Funziona quando l’unica conseguenza del click è l’evento. Smette di funzionare nel momento in cui l’ordine deve essere scritto anche su un database, che è la maggior parte delle volte, perché i servizi nei sistemi reali possiedono dello stato e lo stato vive nei database OLTP.

Nel momento in cui un servizio deve aggiornare il proprio database e pubblicare un evento, ti trovi davanti al problema del dual-write. È uno di quei problemi che a prima vista sembra banale e che in realtà è una piccola questione di transazioni distribuite travestita, con le stesse risposte (e gli stessi trade-off) di ogni altra questione di transazioni distribuite di questo corso.

Il problema del dual-write

La forma ingenua:

def place_order(order):
    db.insert(order)
    kafka.publish("orders", OrderPlaced(order))

Due operazioni. Due sistemi. Nessuna transazione condivisa. Non esiste un protocollo che renda atomica la coppia “scrivi su Postgres e scrivi su Kafka”, perché Postgres e Kafka hanno nozioni separate di commit e nessun coordinatore condiviso. La coppia di scritture o riesce in entrambe, o fallisce in entrambe, o una riesce mentre l’altra fallisce.

I primi due esiti vanno bene. Il terzo è il bug. Ne esistono due varianti.

DB riesce, Kafka fallisce. L’ordine è nel database. L’evento non è in Kafka. I consumer a valle (warehouse, billing, email di conferma) non sentono mai parlare dell’ordine. L’utente vede una pagina di conferma (perché la API ha restituito 200 dopo la scrittura su DB), ma il suo pacco non viene mai spedito. Lo stato del mondo è incoerente con quello che il resto del sistema crede sia lo stato del mondo.

Kafka riesce, DB fallisce. L’evento è in Kafka. L’ordine non è nel database. I consumer a valle elaborano un ordine fantasma. Il warehouse prepara un ordine che non esiste. Il billing addebita una carta per una riga che nessuno riesce a trovare. Il sistema ha raccontato una bugia al mondo.

Entrambe le varianti capitano in produzione. Capitano perché la rete tra il servizio e uno dei due sistemi ha un singhiozzo esattamente nel momento sbagliato, perché un processo viene ucciso tra le due scritture, perché un deploy atterra a metà operazione, perché il database è brevemente sotto carico pesante e la seconda scrittura va in timeout. La finestra è piccola, ma non è zero, e su scala “piccola probabilità” si trasforma in “succede ogni martedì.”

L’istinto è di racchiudere le due scritture in un try/catch e fare “rollback” se la seconda fallisce. Quell’istinto è sbagliato. Se la pubblicazione su Kafka fallisce, certo, puoi fare rollback della transazione del database. Se il commit del database riesce e poi la pubblicazione su Kafka fallisce, non puoi fare rollback del database, perché ha già fatto commit. Se la pubblicazione su Kafka riesce e il database fallisce, non puoi de-pubblicare da Kafka. Non esiste un rollback simmetrico disponibile, perché i due sistemi non si coordinano.

La modalità di fallimento che non puoi evitare con la difensività a livello di codice è quella in cui la rete si partiziona nel mezzo dell’operazione. La prima scrittura ha restituito successo. Il processo viene ucciso. La seconda scrittura non avviene mai. Non esiste un blocco finally al mondo che sistemi questo, perché il processo non è in esecuzione.

Il problema del dual-write va risolto a livello di architettura, non a livello di funzione. Due pattern lo risolvono bene, e prendono di mira forme leggermente diverse del problema. Entrambi sono comuni in produzione. Molti sistemi usano entrambi, per tipi di evento diversi.

Soluzione 1: Change Data Capture

CDC inverte il problema. Invece di chiedere all’applicazione di scrivere su due sistemi, lasci che l’applicazione scriva su uno solo, il database, e lasci che un processo separato trasformi il commit log del database in eventi.

Ogni database transazionale tiene un write-ahead log di ogni cambiamento che ha mai fatto. Postgres lo chiama WAL. MySQL lo chiama binary log. SQL Server, Oracle, e tutti i database cloud hanno i loro equivalenti. Il log è la sorgente da cui il database stesso ricostruisce lo stato in fase di crash recovery e replica verso le read replica. È durevole, ordinato, e include ogni cambiamento committato.

Gli strumenti di CDC leggono quel log, trasformano ogni cambiamento di riga in un evento strutturato, e pubblicano l’evento su Kafka. Il servizio applicativo scrive sul database normalmente. Non sa che CDC esiste. Il commit del database e la pubblicazione dell’evento non sono più due scritture che l’applicazione deve coordinare; la pubblicazione dell’evento è una conseguenza a valle del commit, derivata leggendo lo stesso log che il database stesso usa per la replicazione.

Lo strumento dominante nel mondo open-source è Debezium, un framework di CDC costruito su Kafka Connect. Debezium ha connettori per Postgres, MySQL, SQL Server, MongoDB, Oracle, e diversi altri. Ogni connettore fa il parsing del formato di log specifico del database ed emette una forma di evento normalizzata (stato della riga before/after, tipo di operazione, metadati di sorgente) verso un topic Kafka, di default un topic per ogni tabella sorgente.

Altri strumenti coprono terreno simile. Maxwell è uno strumento di CDC long-running solo per MySQL. AWS DMS offre CDC gestito sulla maggior parte dei database principali, con output verso Kinesis o direttamente verso S3 o Redshift. Flink CDC avvolge i connettori Debezium in modo che possano essere usati direttamente come sorgenti Flink. Confluent Cloud vende un equivalente di Debezium ospitato. La scelta tra di loro è funzione di quale database stai leggendo, se vuoi gestito o self-hosted, e di cosa stai facendo lato consumer.

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 ha due proprietà forti. Primo, cattura ogni cambiamento al database, inclusi i cambiamenti fatti da altri servizi, da SQL manuale, da migrazioni dati, da qualunque cosa colpisca il database. Lo stream di eventi è un log completo dei cambiamenti di stato, che è esattamente quello che di solito vuole un search index a valle, un cache invalidator, o un analytics warehouse. Secondo, il codice applicativo non cambia per niente. Aggiungere CDC a un sistema è un cambiamento a livello di deployment, non a livello di codice.

Ha anche dei limiti. Gli eventi CDC descrivono cambiamenti a livello di riga (“la tabella orders ora ha questa riga in questo stato”), non l’intento a livello di business (“è stato piazzato un ordine e spedito all’indirizzo X con il codice sconto Y”). Ricostruire l’intento dai cambiamenti a livello di riga a volte è facile e a volte è un incubo. Se cinque righe cambiano dentro una singola transazione, lo stream CDC ha cinque eventi. Il consumer a valle deve sapere che vanno insieme. Alcune configurazioni di Debezium preservano i confini transazionali, ma consumarle richiede attenzione.

CDC espone anche lo schema del database direttamente. Ogni rinominazione di colonna, ogni colonna eliminata, ogni cambiamento di tipo, diventa un cambiamento breaking dello schema dell’evento. I team a valle dello stream CDC si trovano accoppiati allo schema del database a monte in un modo in cui non lo erano quando il servizio a monte emetteva eventi di business. L’accoppiamento è reale e va gestito, spesso con un translation layer che converte gli eventi CDC in eventi di business stabili prima che attraversino i confini di team.

Soluzione 2: il transactional outbox

L’outbox pattern lascia all’applicazione il controllo di quali eventi emette, risolvendo comunque il problema del dual-write.

La forma: nella stessa transazione del database che scrive lo stato di business, l’applicazione scrive una riga in una tabella outbox dedicata. La riga di outbox contiene il payload dell’evento, un event ID, il topic di destinazione, e un timestamp. La transazione fa commit di entrambe le righe insieme. C’è una singola scrittura, su un singolo sistema, e le garanzie transazionali del database si occupano dell’atomicità.

Un processo separato, l’outbox poller, fa periodicamente la scansione della tabella outbox cercando righe non pubblicate, pubblica ognuna su Kafka, e la marca come pubblicata (o la elimina). Il poller è at-least-once: potrebbe pubblicare una riga, andare in crash prima di marcarla come pubblicata, e al restart pubblicare la stessa riga una seconda volta. Va bene così, perché i consumer sono idempotenti (lezione 16 e lezione 45) e fanno dedupe in base all’event ID che l’applicazione ha generato quando ha scritto la riga di 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

L’atomicità è ciò che fa funzionare il pattern. O l’ordine e la riga di outbox sono entrambi committati, o nessuno dei due lo è. Non esiste una modalità di fallimento in cui l’ordine esiste senza una corrispondente entry di outbox. La pubblicazione su Kafka è una preoccupazione a valle a cui è permesso essere at-least-once, perché il consumer assorbe i duplicati.

Il poller può essere un piccolo servizio separato, un job pilotato da cron, oppure, sempre più comune, un connettore Debezium puntato sulla tabella outbox. Quest’ultima opzione è la combinazione elegante: l’outbox risolve il problema del dual-write a livello applicativo, e il CDC di Debezium sulla tabella outbox fornisce il meccanismo di pubblicazione senza che nessuno scriva un poller custom.

Il costo principale dell’outbox pattern è la tabella in più e il piccolo overhead operativo del poller. Il poller va monitorato (un poller bloccato significa che gli eventi non scorrono, e la tabella outbox cresce illimitatamente). La tabella outbox ha bisogno di un indice sulla colonna unpublished così che il polling sia economico. Il poller deve gestire l’ordinamento con cura se l’ordine conta per entità. Niente di tutto questo è difficile. È il tipo di lavoro che devi fare una volta sola per servizio.

Quando usare quale

CDC e outbox risolvono problemi sovrapposti con forme diverse, e un sistema maturo spesso usa entrambi.

CDC è la scelta giusta quando: l’obiettivo è catturare ogni cambiamento a un database, indipendentemente da quale codice lo abbia causato. Sincronizzazione di un search index, warehousing analitico, cache invalidation, replicazione verso una region o uno stack diversi. Il consumer vuole un log completo dei cambiamenti di stato; non gli importa dell’intento di business.

L’outbox è la scelta giusta quando: l’obiettivo è emettere eventi significativi a livello di business, solo nei momenti che l’applicazione sceglie, con payload progettati per i consumer a valle. OrderPlaced, PaymentRefunded, UserDeactivated. Al consumer importa di cosa è successo in termini di business, non di quali righe sono cambiate in termini di database. L’applicazione possiede il vocabolario degli eventi.

Un panorama tipico di microservizi finisce per avere entrambi. Debezium cattura ogni cambiamento in ogni database operativo e lo scrive in un livello di topic “data plane” che l’analytics warehouse e il sistema di search consumano. I servizi applicativi inoltre scrivono su tabelle outbox ed emettono eventi di business in un livello di topic “service plane” che altri servizi consumano. I due livelli hanno audience diverse, schemi diversi, e policy di change-management diverse, e coesistono in modo pulito.

Il filo conduttore

Il problema del dual-write è, al suo nucleo, un problema di transazione distribuita. Le lezioni del Modulo 2 si applicano: non esiste un 2PC pronto all’uso tra un database applicativo e un message bus, i pattern che funzionano sono quelli che aggirano l’impossibilità, e la proprietà che cerchi è la consistency end-to-end, non commit-at-the-source-and-pray.

CDC aggira l’impossibilità collassando le due scritture in una sola e leggendo il risultato dal log del database stesso. L’outbox la aggira collassando le due scritture in una sola transazione, con la pubblicazione come conseguenza a valle. Entrambi sono pragmatici, entrambi sono ampiamente usati, ed entrambi sostituiscono un pattern fondamentalmente non sicuro (provare a scrivere su due sistemi e sperare) con uno sicuro (scrivere su un sistema e lasciare che un processo separato derivi il resto).

Il Modulo 6 ha ora coperto lo streaming dall’inizio alla fine: i motori, le topologie, lo stato, il tempo, le semantiche di delivery, e il ponte dai database OLTP verso il livello di streaming. La prossima lezione si sposta su ordering, partitioning, e back-pressure, che sono le realtà operative in cui ogni pipeline di streaming si imbatte una volta che è stata in produzione per più di un trimestre e il workload è cresciuto oltre quello che il sizing iniziale assumeva.

Citazioni e letture di approfondimento

  • Documentazione Debezium, “Debezium architecture” e “Outbox event router”, https://debezium.io/documentation/ (consultato 2026-05-01). Il riferimento sia per la meccanica del CDC sia per l’SMT outbox di Debezium.
  • Gunnar Morling, “Reliable Microservices Data Exchange With the Outbox Pattern”, Red Hat Developer (consultato 2026-05-01). La trattazione canonica dell’outbox pattern in termini Java/Postgres.
  • Confluent, “Patterns for streaming microservices” (consultato 2026-05-01). Copre dual-write, outbox, e CDC nel contesto streaming-microservices.
  • Documentazione AWS Database Migration Service, “Working with change data capture (CDC)”, https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Source.html (consultato 2026-05-01).
  • Chris Richardson, “Microservices Patterns” (Manning, 2018). Capitolo su transactional messaging e l’outbox pattern. Consultato 2026-05-01.
Cerca