Questa è l’ultima lezione del Modulo 2. È anche, secondo me, la più utile da interiorizzare, perché l’idea al suo centro è lo strumento architetturale che, più di ogni altro, fa funzionare i sistemi distribuiti come si deve. L’idea è l’idempotency. La deviazione che dobbiamo prendere per arrivarci parte da un problema di vocabolario: le parole “at-least-once”, “at-most-once” ed “exactly-once” sono usate alla leggera nel settore, spesso come sinonimi, quasi sempre in modo sbagliato.
Quando le slide di un vendor dicono “exactly-once delivery”, quello che di solito intendono è qualcosa di più ristretto di quanto le parole suggeriscano. Quando un ingegnere dice “stiamo usando at-least-once”, può aver ragionato o no su cosa fanno i duplicati al suo consumer. Quando un libro di testo dice “exactly-once è impossibile”, è corretto in un senso specifico e fuorviante in un altro. Questa lezione è la lettura attenta di quelle parole.
I quattro sapori del delivery
Tre di questi riguardano cosa promette il messaging layer. Il quarto riguarda cosa fa l’application layer con quello che il messaging layer gli passa.
At-most-once. Il sender spedisce il messaggio e se ne dimentica. Se va perso in transito, viene scartato dal broker, o il receiver era offline, il messaggio è perso per sempre. Non ci sono retry. L’implementazione è banale: si spedisce e basta. È la semantica più economica. È appropriata per la telemetria a cui sono concessi dei buchi (metriche stile UDP, righe di log, trace campionate), per i broadcast fan-out dove la perdita dell’update di un singolo consumer non è catastrofica, per qualsiasi cosa in cui il prossimo messaggio sovrascriverà comunque questo. È sbagliata per qualsiasi cosa che coinvolga denaro, cambi di stato che l’utente può vedere, o audit trail.
At-least-once. Il sender spedisce il messaggio, aspetta un acknowledgement dal broker, e fa retry finché non lo riceve. Il broker, in modo simile, trattiene il messaggio finché non viene acknowledgato dal consumer. Se un singhiozzo di rete fa perdere un ack in transito anche se il messaggio era stato processato, il sender (o il broker) non lo sa, fa retry, e il consumer vede il messaggio una seconda volta. I duplicati non sono l’eccezione, sono lo stato stazionario. Ogni sistema di messaging affidabile che non sia specificamente progettato per exactly-once è at-least-once. Kafka, RabbitMQ, SQS, NATS JetStream, ogni cloud queue che probabilmente hai usato: at-least-once di default.
Exactly-once. Il sogno. Ogni messaggio viene consegnato al consumer esattamente una volta. Nessun drop. Nessun duplicato. Come garanzia end-to-end attraverso una rete arbitraria, con failure arbitrari, è impossibile. Vedremo perché tra un attimo. Come proprietà dentro un sistema chiuso con componenti cooperativi, è raggiungibile, e diversi prodotti la pubblicizzano.
Processing idempotente. Il quarto sapore è la risposta architetturale al trilemma del messaging layer. Invece di chiedere al messaging layer di consegnare ogni messaggio esattamente una volta, accetti il delivery at-least-once e progetti il consumer in modo che processare lo stesso messaggio una seconda volta non abbia alcun effetto aggiuntivo. L’application layer assorbe i duplicati. Il sistema, end to end, si comporta come exactly-once anche se nessun singolo layer lo garantisce.
Perché exactly-once al messaging layer è difficile
L’argomento corretto più breve sul perché exactly-once sia impossibile attraverso una rete inaffidabile è il Two Generals Problem. Due generali su colline opposte vogliono coordinare un attacco all’alba. L’unico modo per comunicare è tramite messaggeri che devono attraversare la valle tra loro, e la valle è contesa. Qualsiasi messaggero può essere catturato. La prima generale manda un messaggio: “attacco all’alba”. Lei non sa se è arrivato. Il secondo generale, se l’ha ricevuto, manda indietro un ack. Lui non sa se l’ack è arrivato. La prima generale, se ha ricevuto l’ack, può mandare un ack dell’ack. E così via. Non c’è un numero finito di ack al quale entrambi i generali possano essere certi che l’altro conosca il piano.
La dimostrazione è un’induzione di una riga: qualsiasi protocollo finito può essere tagliato all’ultimo messaggio, e il sender di quell’ultimo messaggio non può sapere se è arrivato. Tradotto in termini di messaging: un sender non può sapere, con certezza, se il suo messaggio ha raggiunto il receiver, a meno che la rete non sia affidabile. La rete non è affidabile. Quindi, il sender o fa retry (rischiando duplicati) o non lo fa (rischiando perdita). Scegli uno.
Il delivery exactly-once richiederebbe che sender, broker e receiver concordino, con certezza, sul fatto che ogni messaggio sia stato processato. Questo richiede consenso tra i tre su ogni messaggio. Il consenso attraverso una rete inaffidabile è possibile (Paxos, Raft) ma costoso, e anche allora, solo dentro il cluster cooperante. Nel momento in cui il tuo “consumer” è qualcosa che il sistema di messaging non controlla, come un payment gateway di terze parti, exactly-once attraverso il confine torna a essere impossibile.
Cosa significa di solito “exactly-once” nei testi di marketing
I vendor le spediscono davvero le feature exactly-once, e non stanno mentendo, ma lo scope è più ristretto di quanto la frase suggerisca. L’esempio più citato è la Kafka exactly-once semantics, di cui Confluent ha scritto a lungo.
Quello che Kafka effettivamente offre è exactly-once dentro l’ecosistema Kafka. Nello specifico, un producer può scrivere un batch di messaggi, un consumer può avanzare il suo offset, e queste due operazioni possono essere eseguite atomicamente dentro una Kafka transaction. Il risultato è che, finché l’unico output del tuo consumer è scrivere altri messaggi indietro su topic Kafka, e finché il tuo consumer commit gli offset attraverso la stessa API transazionale, nessun messaggio è processato due volte e nessun messaggio è perso.
I due caveat importanti sono nella frase precedente. Primo, “dentro Kafka”. Se l’output del tuo consumer è una write su Postgres, o una chiamata a Stripe, o una row su S3, la Kafka transaction non si estende a quei sink. Il consumer potrebbe processare un messaggio, scrivere su Postgres, fallire prima di committare l’offset, e al restart riprocessare il messaggio e riscrivere su Postgres. La write su Postgres avviene due volte. L’exactly-once di Kafka non ti salva. Secondo, “il consumer commit i suoi offset attraverso la stessa API transazionale”. Il commit di consumer di default non è transazionale, e la maggior parte del codice consumer in giro non usa la versione transazionale. La garanzia exactly-once richiede un opt-in deliberato a livello di codice.
Kafka Streams estende ulteriormente questo, perché Kafka Streams è esso stesso una pipeline consumer-producer Kafka-only, e il commit transazionale copre tutta la topology. Dentro un’applicazione Kafka Streams, exactly-once è reale e utile. Nel momento in cui la tua topology ha un sink che non è Kafka, sei tornato ad avere bisogno di idempotency al sink.
La stessa forma si applica alle altre claim di “exactly-once”. L’exactly-once di Apache Flink è exactly-once attraverso lo state checkpointed di Flink. L’exactly-once di Google Pub/Sub è exactly-once dentro una singola sessione di subscriber. Ognuna di queste è significativa dentro un confine. Nessuna di esse elimina la necessità di processing idempotente nei sink esterni dell’applicazione.
Idempotency: la risposta architetturale
Idempotency significa che un’operazione può essere applicata molte volte con lo stesso effetto di applicarla una sola volta. Settare una variabile a 5 è idempotente. Incrementare una variabile non lo è. Mandare un’email che dice “la tua password è stata resettata” non lo è, anche se il danno visibile all’utente dei duplicati è piccolo. Addebitare una carta di credito di 100 euro enfaticamente non lo è, e il danno visibile all’utente di un duplicato è un rimborso, un’email arrabbiata, e possibilmente un chargeback.
Il lavoro del consumer idempotente è prendere uno stream at-least-once e processare ogni messaggio, deduplicando per qualche identità, in modo che l’effetto osservabile esternamente sia exactly-once. Ci sono quattro pattern comuni per farlo, e spesso si combinano.
Idempotency key sono il pattern canonico per le HTTP API. Il client genera un identificatore unico per request logica e lo manda in un header (Stripe usa Idempotency-Key, la convenzione è ampiamente copiata). Il server, prima di processare, controlla se ha già visto questa key. Se sì, restituisce la stessa response che aveva restituito la prima volta, senza riprocessare. Se no, processa la request, memorizza la key insieme alla response, e ritorna. La semantica è: la stessa key produce lo stesso effetto e la stessa response, non importa quante volte venga inviata.
Il client è responsabile di scegliere key che siano uniche per request logica. Una forma comune è generare un UUID v4 nel momento in cui l’utente clicca “paga” e riusarlo in tutti i retry di quel pagamento finché o la request alla fine va a buon fine o l’utente esplicitamente inizia un nuovo tentativo. Un bug comune è generare una nuova key ad ogni retry, il che vanifica il senso.
Upsert con natural key sono l’equivalente lato database. Invece di “inserisci questa row” il consumer emette “inserisci questa row, ma se esiste già una row con la stessa (order_id, line_id), non fare nulla”. Postgres lo chiama INSERT ... ON CONFLICT DO NOTHING. SQL Server lo chiama MERGE. MySQL lo chiama INSERT ... ON DUPLICATE KEY UPDATE. L’intuizione chiave è che la tabella ha un unique constraint che cattura l’identità logica dell’operazione, e il database ti impone l’idempotency.
Operazioni che sono intrinsecamente idempotenti sono il caso più facile. Settare uno stato a un valore specifico (X = 5) è idempotente indipendentemente da quante volte lo fai. Settare “questa prenotazione è ora nello stato ‘paid’” è idempotente. Incrementare un counter non lo è, ma in molti casi puoi riformulare l’incremento come un set (“questa prenotazione ora mostra l’importo cumulativo pagato di 100 euro”) e recuperare l’idempotency. Quando hai una scelta su come modellare l’operazione, preferisci la forma idempotente.
Trasformazioni idempotenti nello stream processing sono la versione di questo per le pipeline analitiche. Un aggregato windowed (count di eventi al minuto, somma di importi all’ora) rieseguito sullo stesso input produce lo stesso output. Finché la pipeline è deterministica, riprocessare lo stesso input produce lo stesso risultato, e sovrascrivere i totali orari di ieri con la ricomputazione di oggi è innocuo. I motori di stream processing si appoggiano molto a questo. La combinazione di “trasformazione deterministica” più “sink che sovrascrive” è funzionalmente idempotente end to end.
Un esempio lavorato: un endpoint di pagamento idempotente
Il design naive fa un doppio addebito al retry. Immagina un endpoint HTTP:
POST /payments
Body: { amount: 100, card: "..." }
Il client posta. Il server addebita la carta tramite il gateway, restituisce 200 OK con il nuovo record di pagamento. La response del client va persa in transito a causa di una connessione mobile traballante. Il client fa retry. Il server addebita di nuovo la carta. Due addebiti. Un utente arrabbiato.
Il design idempotente aggiunge un header Idempotency-Key. Il client genera la key nel momento in cui l’utente clicca “paga” e la riusa per i retry. Il server, ricevendo la request, cerca la key in un piccolo store (Redis, o una tabella dedicata). Se la key è nuova, locka la key, processa il pagamento, memorizza (key, response), e ritorna. Se la key ha già una response memorizzata, il server restituisce la response memorizzata senza riaddebitare. Se la key è lockata ma non ha ancora una response (una request precedente è ancora in volo), il server o aspetta o restituisce una response “still processing” che il client può fare poll.
sequenceDiagram
participant Client
participant Payment as Payment Service
participant Store as (key, response) store
participant Gateway as Card Gateway
Client->>Payment: POST /payments [Key: K]
Payment->>Store: get K
Store-->>Payment: not found
Payment->>Store: lock K
Payment->>Gateway: charge card 100
Gateway-->>Payment: txn 8821, success
Payment->>Store: write (K, response)
Payment-->>Client: 200 OK { txn: 8821 }
Note over Client,Payment: response lost in transit
Client->>Payment: POST /payments [Key: K] (retry)
Payment->>Store: get K
Store-->>Payment: response { txn: 8821 }
Payment-->>Client: 200 OK { txn: 8821 }
Nota tre cose su questo design.
Primo, lo store di deduplicazione deve essere durevole. Se è in memoria e il server fa restart, il prossimo retry sembrerà una nuova request e hai perso la proprietà per cui stavi pagando. Redis con persistenza è accettabile. Una tabella Postgres è meglio. Una cache in-memory è sbagliata.
Secondo, la key deve essere memorizzata prima del side effect, non dopo. Se addebiti la carta e poi scrivi la key, un crash tra le due ti lascia con un addebito che non ha record di idempotency, e il prossimo retry addebiterà di nuovo. Il pattern giusto è: locka la key, esegui il side effect, scrivi la response, rilascia il lock. Il locking può essere un pessimistic lock a livello di row o una INSERT con un unique constraint che fallisce sui duplicati.
Terzo, il key store ha una retention policy. Non puoi tenere le idempotency key per sempre. Una scelta comune è ventiquattro ore, che copre qualsiasi finestra ragionevole di retry del client. Lo store cresce al ritmo delle request uniche al giorno e resta limitato.
Mettendo tutto insieme
La prescrizione architetturale che cade fuori da questa lezione è breve. Usa il messaging layer per il delivery at-least-once, perché at-least-once è l’unica cosa che il layer può onestamente promettere attraverso una rete inaffidabile. Rendi idempotente ogni consumer, con uno dei pattern visti sopra. Tratta le claim di exactly-once con scetticismo: identifica il confine dentro cui la claim regge, identifica i sink fuori da quel confine, e rendi quei sink idempotenti per conto loro.
Il costo di questo design è un piccolo overhead per messaggio: una lookup nello store di deduplicazione, il costo di storage delle key, e la disciplina di generare key uniche al layer giusto. Il beneficio è che il sistema si comporta correttamente sotto retry, fallimenti del broker, partition di rete, e il tipo di failure che non puoi anticipare, perché l’application layer non si interessa più se un dato messaggio sia arrivato una volta o cinque.
Di cosa è stato il Modulo 2
Sei lezioni fa abbiamo iniziato il Modulo 2 con le otto fallacies del computing distribuito. L’arco del modulo è stato: ogni proprietà che davi per scontata in un mondo a singolo processo e singola macchina diventa una domanda nel momento in cui smetti di stare su una sola macchina. La rete non è affidabile. La latenza non è zero. I clock non sono d’accordo. CAP ti costringe a scegliere tra consistency e availability quando arriva la partition, e la partition arriverà. La strong consistency è costosa quando puoi averla, e spesso non puoi averla. Le transazioni distribuite costano più di quanto ti aspetti, e la modalità di failure del coordinator del 2PC significa che dovresti ripiegare sulle Saga. E, oggi, il delivery exactly-once è una finzione al messaging layer che diventa un fatto all’application layer attraverso l’idempotency.
Se sei entrato nel Modulo 2 sperando nelle tecniche che rendono i sistemi distribuiti più facili, le notizie sono miste. Le tecniche esistono, e le abbiamo coperte, ma il titolo è che i sistemi distribuiti non diventano più facili; diventano trattabili. Il modo in cui diventano trattabili è che interiorizzi un piccolo numero di pattern (operazioni idempotenti, saga, eventual consistency, confini attenti tra stato locale e globale) e li applichi ovunque, di default, finché non sono più tecniche e diventano semplicemente il modo in cui scrivi codice che parla con altre macchine.
Il resto del corso si costruisce su queste fondamenta. Il Modulo 3 diventa concreto sul data layer: replica, pooling, sharding, partitioning. Il Modulo 4 diventa concreto su queue e worker. Il Modulo 5 torna alla domanda di service-decomposition con il vocabolario dei sistemi distribuiti ora in mano. Nessuno di quei moduli ha senso senza le fondamenta del Modulo 2. Con esse, il resto del corso è una lunga sequenza di “dati questi vincoli, ecco il pattern che li risolve”. Quella è l’architettura. Benvenuto al Modulo 3.
Riferimenti e letture di approfondimento
- Stripe API documentation, “Idempotent requests”,
https://stripe.com/docs/api/idempotent_requests(consultato 2026-05-01). La trattazione canonica del pattern idempotency-key come contratto di API pubblica. - 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 di Kafka exactly-once, con uno scoping attento di cosa copre la garanzia. - Jim Gray e Andreas Reuter, “Transaction Processing: Concepts and Techniques” (Morgan Kaufmann, 1992). Il testo di riferimento sulle transazioni, l’idempotency, e il design dei sistemi affidabili. Vecchio ma ancora il miglior trattamento delle idee sottostanti.
- Tyler Treat, “You Cannot Have Exactly-Once Delivery”,
https://bravenewgeek.com/you-cannot-have-exactly-once-delivery/(consultato 2026-05-01). Un saggio breve e tagliente che attraversa il Two Generals Problem in termini di messaging.