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

Semantica exactly-once negli stream

Cosa garantiscono davvero le Kafka transactions, il problema della source-sink coordination, i limiti, e perché exactly-once tra servizi è difficile.

La lezione 16, nel Modulo 2, sosteneva che la consegna exactly-once è impossibile attraverso una rete inaffidabile, e che la risposta architetturale è la consegna at-least-once più processing idempotente. Quella lezione parlava di messaging in generale. Questa parla di una forma specifica del problema dentro le streaming pipeline, dove i vendor in effetti rilasciano qualcosa che chiamano “exactly-once”, e dove quell’etichetta significa qualcosa di più ristretto e più utile di quanto le parole suggeriscano.

La promessa di exactly-once è allettante: ogni evento in input influisce su ogni output esattamente una volta. Non zero, perché il sistema è affidabile. Non due, perché il sistema deduplica. La ragione per cui è difficile è la stessa di prima, e la ragione per cui talvolta è realizzabile nello streaming è che gli streaming engine controllano una porzione del percorso più ampia rispetto a un generico messaging client.

Perché exactly-once è difficile, in immagini

Un job di stream-processing ha una forma. C’è un input source, un processor, e un output sink. Ogni evento attraversa tutti e tre. Ognuno può fallire. Quando uno stage fallisce, l’orchestrazione fa retry.

Senza coordinazione, è il retry a causare i duplicati. Il processor legge un evento da Kafka, calcola una trasformazione, scrive il risultato su Kafka o su un database, e fa l’acknowledge della source avanzando il consumer offset. Ognuno di quei passi è un’operazione separata. Se il processor crasha tra la scrittura dell’output e l’avanzamento dell’offset, il tentativo successivo rilegge lo stesso evento e scrive l’output una seconda volta. At-least-once. I duplicati sono reali e non hai modo di individuarli a posteriori, a meno che il tuo sink non sappia deduplicare.

Il fallimento opposto (avanzare l’offset, poi crashare prima di scrivere l’output) ti dà at-most-once, anch’esso un male. Il comportamento corretto è che l’avanzamento dell’offset e la scrittura dell’output o accadono entrambi o non accade nessuno dei due. Quella è una transazione, distribuita tra l’input source, il processor, e l’output sink.

Per la maggior parte delle combinazioni di source e sink, quella transazione non esiste. Non c’è un protocollo pronto all’uso che renda atomico “avanzare l’offset Kafka e fare insert di una riga in Postgres”. I due sistemi hanno nozioni separate di commit, semantiche di failure separate, nessun coordinator condiviso. È il cuore del problema.

Kafka exactly-once semantics, cosa copre davvero

Kafka rilascia una feature chiamata exactly-once semantics, abbreviata EOS. Non è un singolo interruttore. Sono tre meccanismi che cooperano e insieme ti danno una transazione attraverso topic Kafka.

Idempotent producer. Ogni istanza di producer ha un producer ID univoco, e ogni batch di record che invia porta un sequence number. Il broker ricorda, per partizione, l’ultimo sequence che ha accettato da ogni producer. Se un blip di rete fa sì che il producer faccia retry di un batch che il broker ha già scritto, il broker riconosce il sequence duplicato e restituisce l’ack originale invece di scrivere due volte. Il retry lato producer, che normalmente sarebbe una sorgente di duplicati, diventa un no-op al broker. Si abilita impostando enable.idempotence=true ed è il pavimento su cui si costruisce tutto il resto.

Transactions. Un producer può aprire una transaction, scrivere su molteplici partizioni attraverso molteplici topic, e fare commit. Dal punto di vista del consumer, le scritture o diventano tutte visibili insieme o nessuna lo fa. L’implementazione usa un transaction coordinator (uno dei broker) e marker di tipo two-phase-commit nei log delle partizioni. Il producer ha anche un transactional ID che sopravvive ai restart, il che impedisce a due istanze zombie dello stesso producer di scrivere entrambe dentro quella che ognuna pensa sia la stessa transazione.

Read-committed isolation. I consumer possono essere configurati con isolation.level=read_committed. Salteranno qualsiasi record che faccia parte di una transazione aperta o abortita e vedranno solo i record che appartengono a una transazione committata. Senza questo, i consumer vedono tutto ciò che è stato scritto, abort inclusi, e l’atomicità della transazione è invisibile.

Offset commit dentro la transazione. Questo è il pezzo che chiude il cerchio. Quando una pipeline Kafka Streams o un transactional consumer-producer fanno commit del loro lavoro, l’avanzamento dell’offset per il topic in input è incluso come scrittura dentro la stessa transazione delle scritture in output. O committano entrambi, gli offset e gli output, o entrambi abortiscono.

Messo tutto insieme, questo ti dà una garanzia forte, ma solo dentro un confine specifico: input da Kafka, processing, output a Kafka, tutto dentro lo stesso cluster Kafka (o, con feature più recenti, cluster federati che condividono un transaction coordinator). Dentro quel confine, nessun record di input produce un output più di una volta.

sequenceDiagram
    participant Consumer
    participant Producer as Transactional producer
    participant TC as Transaction coordinator
    participant P1 as Output partition 1
    participant P2 as Output partition 2
    participant Off as Consumer offset topic
    Consumer->>Producer: read records (offset 100 to 105)
    Producer->>TC: beginTransaction
    Producer->>P1: write records
    Producer->>P2: write records
    Producer->>Off: send offsets (advance to 106)
    Producer->>TC: commitTransaction
    TC->>P1: write commit marker
    TC->>P2: write commit marker
    TC->>Off: write commit marker
    Note over P1,Off: read_committed consumers see all three together

Kafka Streams usa questa macchineria automaticamente quando è impostato processing.guarantee=exactly_once_v2. Il runtime apre una transazione per task per intervallo di commit, scrive output e offset al suo interno, e committa. Finché la topology resta dentro Kafka, la garanzia tiene.

Dove si ferma

Il confine, in parole semplici, è “input e output che il Kafka transaction coordinator può includere in un commit”. Cioè i topic Kafka, più il consumer offset topic. Non è Postgres. Non è S3. Non è una HTTP API. Non è Elasticsearch. Non è un sistema di metriche. Nessuno di questi partecipa alla transazione.

Un job Kafka Streams che legge da un topic, fa un po’ di processing, e scrive su un altro topic: exactly-once è reale e ci puoi contare. Un job Kafka Streams che legge da un topic e spinge i risultati su un database Postgres tramite un sink connector: exactly-once è una finzione parziale. La transazione fa commit dell’output su Kafka (il connector legge da un topic Kafka di output), ma la scrittura del connector dentro Postgres avviene fuori dalla transazione Kafka. Se il connector crasha tra la scrittura su Postgres e il commit del proprio offset, al riavvio riscrive. Duplicati in Postgres.

La stessa cosa succede per qualsiasi sink esterno. La transazione Kafka si ferma al bordo di Kafka. Qualsiasi cosa stia oltre il bordo deve gestire i duplicati per conto proprio.

I rimedi per i sink esterni

Ci sono tre opzioni, in ordine decrescente di quanto spesso funzionino davvero in pratica.

Idempotent sink. Questa è la risposta giusta in quasi ogni caso. Progetta il sink in modo che riscrivere lo stesso output produca lo stesso end-state. I pattern dalla lezione 16 si applicano direttamente. Upsert in Postgres su una chiave univoca. POST a una HTTP API con un header Idempotency-Key che il ricevente ricorda. Scrittura su S3 con una object key deterministica derivata dall’input, in modo che la seconda scrittura o sostituisca la prima o sia respinta come duplicato. La garanzia exactly-once dello streaming engine copre tutto fino al sink, e l’idempotenza del sink copre il resto. Insieme ti danno correttezza end-to-end.

La disciplina è che l’idempotenza del sink dev’essere progettata, non assunta. Un INSERT Postgres ingenuo non è idempotente. Un INSERT ... ON CONFLICT (key) DO UPDATE lo è. Un POST HTTP ingenuo non lo è. Un POST con una dedup table lato server con chiave su Idempotency-Key lo è. Lo streaming engine non può rendere ben educato un sink non idempotente; sei tu che devi sistemare il sink.

Coordinazione di tipo two-phase commit. Alcuni sink supportano un protocollo write-then-commit che lo streaming engine può guidare. Flink li chiama “two-phase commit sink”. L’engine scrive l’output durante la streaming transaction, tiene i dati in stato pending, e dice al sink di fare commit solo dopo che il checkpoint dell’engine è riuscito. Se l’engine fallisce, dice al sink di abortire. Il sink deve supportare scritture pending e chiamate esplicite di commit o abort, cosa che la maggior parte non fa. JDBC e Kafka sink hanno implementazioni. La maggior parte degli altri sink no. Quando ce l’hai, funziona. Quando non ce l’hai, l’opzione è chiusa e ricadi sulle scritture idempotenti.

At-least-once più dedup a valle. L’opzione pragmatica quando nessuna delle due sopra è praticabile. Lo streaming engine è at-least-once. Il sink accetta duplicati. Uno step batch a valle o un’aggregazione query-time deduplica per event ID. È la stessa forma del pattern append-with-dedup della lezione 38. Funziona, ed è operativamente più pesante dell’opzione idempotent-sink, perché ogni read o ogni step batch deve pagare il costo della dedup. Ricorrici quando il sink davvero non può essere reso idempotente.

Cosa significa in pratica

Il riassunto onesto è breve. Exactly-once è una proprietà a livello di sistema, non una checkbox in un file di config. Kafka EOS è eccellente per la parte di sistema che copre, cioè Kafka-to-Kafka. Per qualsiasi altra cosa, lo streaming engine ti dà una garanzia forte fino al sink, e tu, l’ingegnere, sei responsabile del comportamento del sink.

La regola architetturale che ne deriva è la stessa del Modulo 2, riformulata per lo streaming: progetta ogni sink per essere idempotente, e tratta le etichette exactly-once con scetticismo. Identifica il confine a cui l’etichetta si applica. Identifica i sink fuori da quel confine. Rendi quei sink idempotenti per conto proprio.

Ci sono due trappole specifiche da segnalare.

La prima trappola è abilitare EOS senza consumer read-committed. Il lato producer è transazionale, il lato consumer no, e il consumer legge le scritture abortite insieme a quelle committate. L’output sembra a posto nel topic ma è sbagliato quando viene consumato. EOS è il producer più l’isolation level del consumer più l’offset commit, tutti insieme. Mezza configurazione è nessuna configurazione.

La seconda trappola è assumere che i Kafka Connect sink ereditino EOS. Non lo fanno, di default. Un connector che scrive su un sistema non-Kafka è at-least-once al bordo del sink, a meno che il connector specifico per il sink non implementi two-phase commit e la destinazione lo supporti. La documentazione di Confluent è esplicita su quali connector siano exactly-once-capable e sotto quale configurazione. Leggi quella pagina prima di promettere la proprietà a uno stakeholder.

Dove ci lascia tutto questo

Il Modulo 6 ha ora coperto, in ordine, gli engine, le topology, lo state, il time, e la delivery semantics dello streaming. La prossima lezione passa a un problema diverso ma adiacente: come i cambiamenti di stato nei tuoi database arrivano dentro il livello di streaming in primo luogo, senza violare le garanzie di consistency né del database né del message bus. Quello è change data capture e l’outbox pattern, e sono il ponte tra il mondo OLTP in cui il resto della compagnia scrive codice e il mondo streaming che questo modulo ha descritto.

Il filo che attraversa tutte queste lezioni è lo stesso che attraversava il Modulo 2: la correttezza distribuita non è gratis, e i pattern che la rendono trattabile sono un piccolo insieme, applicato in modo consistente, finché smettono di essere tecniche e diventano il modo in cui costruisci di default. Exactly-once è uno di quei pattern. La disciplina sta nel sapere esattamente cosa garantisce il tuo engine, esattamente dove la garanzia si ferma, ed esattamente quali righe di codice al sink fanno la differenza.

Citazioni e letture di approfondimento

  • Confluent, “Exactly-Once Semantics Are Possible: Here’s How Kafka Does It”, https://www.confluent.io/blog/exactly-once-semantics-are-possible-heres-how-apache-kafka-does-it/ (consultato 2026-05-01). L’annuncio originale, con scoping accurato.
  • Apache Kafka documentation, “Transactions”, https://kafka.apache.org/documentation/#transactions (consultato 2026-05-01).
  • Confluent, “Patterns for streaming microservices” (consultato 2026-05-01). Lo scritto sui streaming-microservices che copre lo scope di EOS, l’idempotenza dei sink, e il pattern two-phase-commit sink.
  • Tyler Akidau, Slava Chernyak, Reuven Lax, “Streaming Systems” (O’Reilly, 2018). Capitoli sui modelli di consistency nello streaming e il ruolo dei side effect. Consultazione 2026-05-01.
  • Apache Flink documentation, “End-to-End Exactly Once Processing in Apache Flink with Apache Kafka”, https://flink.apache.org/2018/02/28/an-overview-of-end-to-end-exactly-once-processing-in-apache-flink-with-apache-kafka.html (consultato 2026-05-01).
Cerca