Aceasta este ultima lecție din Modulul 2. Este, după părerea mea, și cea mai utilă de internalizat, pentru că ideea ei centrală este unealta arhitecturală care, mai mult decât oricare alta, face ca sistemele distribuite să se comporte cum trebuie. Ideea este idempotency. Ocolul pe care trebuie să-l facem ca să ajungem acolo începe cu o problemă de vocabular: cuvintele „at-least-once”, „at-most-once” și „exactly-once” sunt folosite cu lejeritate în industrie, adesea interschimbabil, aproape întotdeauna greșit.
Când un slide deck al unui vendor spune „exactly-once delivery”, de obicei înseamnă ceva mai îngust decât sugerează cuvintele. Când un inginer zice „folosim at-least-once”, s-ar putea sau nu să fi gândit ce fac duplicatele consumatorului său. Când un manual spune „exactly-once este imposibil”, e corect într-un sens specific și înșelător în alt sens. Lecția asta e citirea atentă a acestor cuvinte.
Cele patru variante de livrare
Trei dintre ele sunt despre ce promite stratul de messaging. A patra e despre ce face stratul aplicației cu ce-i livrează stratul de messaging.
At-most-once. Expeditorul trimite mesajul și uită de el. Dacă se pierde în tranzit, e abandonat la broker, sau receptorul era offline, mesajul e pierdut definitiv. Nu există retry-uri. Implementarea e trivială: e doar trimitere. Aceasta e cea mai ieftină semantică. E potrivită pentru telemetrie căreia i se permit lacune (metrici în stil UDP, linii de log, trace-uri eșantionate), pentru fan-out broadcast-uri unde pierderea unei actualizări către un consumator nu e catastrofică, pentru orice unde mesajul următor oricum îl va suprascrie pe acesta. E greșită pentru orice care implică bani, schimbări de stare pe care utilizatorul le poate vedea, sau audit trails.
At-least-once. Expeditorul trimite mesajul, așteaptă un acknowledgement de la broker, și face retry până primește unul. Brokerul, similar, păstrează mesajul până a fost confirmat de consumator. Dacă un sughiț de rețea face ca un ack să se piardă în tranzit deși mesajul a fost procesat, expeditorul (sau brokerul) nu știe asta, face retry, și consumatorul vede mesajul a doua oară. Duplicatele nu sunt excepția, ele sunt starea normală. Orice sistem de messaging fiabil care nu este proiectat specific pentru exactly-once este at-least-once. Kafka, RabbitMQ, SQS, NATS JetStream, fiecare cloud queue pe care probabil l-ai folosit: at-least-once by default.
Exactly-once. Visul. Fiecare mesaj este livrat consumatorului exact o dată. Fără pierderi. Fără duplicate. Ca garanție end-to-end peste o rețea arbitrară, cu eșecuri arbitrare, asta e imposibil. Vom vedea de ce într-un moment. Ca proprietate într-un sistem închis cu componente cooperative, este realizabilă, și mai multe produse o promovează.
Procesare idempotentă. A patra variantă este răspunsul arhitectural la trilema stratului de messaging. În loc să ceri stratului de messaging să livreze fiecare mesaj exact o dată, accepți livrarea at-least-once și proiectezi consumatorul astfel încât procesarea aceluiași mesaj a doua oară să nu aibă niciun efect suplimentar. Stratul aplicației absoarbe duplicatele. Sistemul, end to end, se comportă ca exactly-once chiar dacă niciun strat individual nu garantează asta.
De ce exactly-once la stratul de messaging este greu
Cel mai scurt argument corect pentru care exactly-once este imposibil peste o rețea nesigură este Two Generals Problem. Doi generali pe dealuri opuse vor să coordoneze un atac la zori. Singurul mod de a comunica e prin mesageri care trebuie să traverseze valea dintre ei, iar valea e disputată. Orice mesager poate fi capturat. Primul general trimite un mesaj: „atacăm la zori.” Ea nu știe dacă a ajuns. Al doilea general, dacă l-a primit, trimite înapoi un ack. El nu știe dacă ack-ul a ajuns. Primul general, dacă a primit ack-ul, poate trimite un ack al ack-ului. Și așa mai departe. Nu există un număr finit de ack-uri la care ambii generali să fie siguri că celălalt cunoaște planul.
Demonstrația e o inducție de un rând: orice protocol finit poate fi tăiat la ultimul mesaj, iar expeditorul acelui ultim mesaj nu poate ști dacă a ajuns. Tradus în termeni de messaging: un expeditor nu poate ști, cu certitudine, dacă mesajul său a ajuns la receptor, dacă rețeaua nu este fiabilă. Rețeaua nu este fiabilă. Prin urmare, expeditorul fie face retry (riscând duplicate) fie nu (riscând pierdere). Alege una.
Exactly-once delivery ar necesita ca expeditorul, brokerul și receptorul să fie de acord, cu certitudine, dacă fiecare mesaj a fost procesat. Asta cere consens între cei trei pe fiecare mesaj. Consensul peste o rețea nesigură este posibil (Paxos, Raft) dar scump, și chiar și atunci, doar în cadrul cluster-ului care cooperează. Din momentul în care „consumatorul” tău e ceva ce sistemul de messaging nu controlează, cum ar fi un payment gateway terț, exactly-once peste graniță redevine imposibil.
Ce înseamnă de obicei „exactly-once” în textele de marketing
Vendorii chiar livrează feature-uri exactly-once, și nu mint, dar scope-ul este mai îngust decât sugerează expresia. Cel mai citat exemplu este Kafka exactly-once semantics, despre care Confluent a scris pe larg.
Ce livrează Kafka de fapt este exactly-once în interiorul ecosistemului Kafka. Specific, un producător poate scrie un batch de mesaje, un consumator își poate avansa offset-ul, și aceste două operații pot fi efectuate atomic în interiorul unei tranzacții Kafka. Rezultatul e că, atâta timp cât singurul output al consumatorului tău este să scrie mai multe mesaje înapoi în topicuri Kafka, și atâta timp cât consumatorul tău își face commit la offsets prin același API tranzacțional, niciun mesaj nu este procesat de două ori și niciun mesaj nu se pierde.
Cele două caveat-uri importante sunt în propoziția anterioară. Primul, „în interiorul Kafka.” Dacă output-ul consumatorului tău e o scriere în Postgres, sau un apel către Stripe, sau un rând în S3, tranzacția Kafka nu se extinde la acele sinks. Consumatorul ar putea procesa un mesaj, scrie în Postgres, eșua înainte de a face commit la offset, și la restart să reproceseze mesajul și să rescrie în Postgres. Scrierea în Postgres se întâmplă de două ori. Exactly-once-ul Kafka nu te salvează. Al doilea, „consumatorul își face commit la offsets prin același API tranzacțional.” Commit-ul implicit al consumatorului nu este tranzacțional, și majoritatea codului de consumator din lumea reală nu folosește versiunea tranzacțională. Garanția exactly-once cere un opt-in deliberat, la nivel de cod.
Kafka Streams extinde asta mai departe, pentru că Kafka Streams este în sine un pipeline consumer-producer doar pentru Kafka, iar commit-ul tranzacțional acoperă întreaga topologie. În interiorul unei aplicații Kafka Streams, exactly-once este real și util. Din momentul în care topologia ta are un sink care nu este Kafka, te întorci la a avea nevoie de idempotency la sink.
Aceeași formă se aplică și altor afirmații „exactly-once”. Exactly-once-ul Apache Flink este exactly-once peste starea checkpointed a Flink. Exactly-once-ul Google Pub/Sub este exactly-once într-o singură sesiune de subscriber. Fiecare dintre acestea e semnificativ în interiorul unei granițe. Niciuna dintre ele nu elimină nevoia de procesare idempotentă la sink-urile externe ale aplicației.
Idempotency: răspunsul arhitectural
Idempotency înseamnă că o operație poate fi aplicată de mai multe ori cu același efect ca aplicarea ei o singură dată. Setarea unei variabile la 5 este idempotentă. Incrementarea unei variabile nu este. Trimiterea unui email care spune „parola ta a fost resetată” nu este, deși daunele vizibile pentru utilizator ale duplicatelor sunt mici. Taxarea unui card de credit cu 100 de euro categoric nu este, iar daunele vizibile pentru utilizator ale unui duplicat sunt o rambursare, un email furios, și posibil un chargeback.
Treaba consumatorului idempotent este să ia un stream at-least-once și să proceseze fiecare mesaj, deduplicând după o identitate, astfel încât efectul observabil din exterior să fie exactly-once. Există patru pattern-uri comune de a face asta, și deseori se combină.
Idempotency keys sunt pattern-ul canonic pentru API-uri HTTP. Clientul generează un identificator unic per cerere logică și-l trimite într-un header (Stripe folosește Idempotency-Key, convenția e copiată pe scară largă). Serverul, înainte de procesare, verifică dacă a mai văzut această cheie. Dacă da, returnează același răspuns pe care l-a returnat prima dată, fără reprocesare. Dacă nu, procesează cererea, stochează cheia împreună cu răspunsul, și returnează. Semantica este: aceeași cheie produce același efect și același răspuns, indiferent de câte ori e trimisă.
Clientul este responsabil să aleagă chei care sunt unice per cerere logică. O formă comună este să generezi un UUID v4 în momentul în care utilizatorul apasă „pay” și să-l reutilizezi peste toate retry-urile acelei plăți până când fie cererea reușește în final, fie utilizatorul începe explicit o nouă încercare. Un bug comun este să generezi o cheie nouă la fiecare retry, ceea ce zădărnicește scopul.
Upsert-uri cu chei naturale sunt echivalentul de partea bazei de date. În loc de „inserează acest rând” consumatorul emite „inserează acest rând, dar dacă un rând cu același (order_id, line_id) există deja, nu fă nimic.” Postgres îi spune INSERT ... ON CONFLICT DO NOTHING. SQL Server îi spune MERGE. MySQL îi spune INSERT ... ON DUPLICATE KEY UPDATE. Insight-ul cheie este că tabelul are o constrângere unique care captează identitatea logică a operației, iar baza de date îți forțează idempotency.
Operații care sunt inerent idempotente sunt cazul cel mai ușor. Setarea unei stări la o valoare specifică (X = 5) este idempotentă indiferent de câte ori o faci. Setarea „această rezervare e acum în starea ‘paid’” este idempotentă. Incrementarea unui counter nu este, dar în multe cazuri poți reformula incrementul ca un set („această rezervare arată acum suma cumulativă plătită de 100 de euro”) și recâștigi idempotency. Când ai de ales cum să modelezi operația, preferă forma idempotentă.
Transformări idempotente în stream processing sunt versiunea asta pentru pipeline-uri analitice. Un agregat windowed (count de evenimente per minut, sum de sume per oră) re-rulat pe același input produce același output. Atâta timp cât pipeline-ul este determinist, reprocesarea aceluiași input dă același rezultat, iar suprascrierea totalurilor orare de ieri cu recalcularea de astăzi e inofensivă. Motoarele de stream processing se sprijină mult pe asta. Combinația „transformare deterministă” plus „sink care suprascrie” este funcțional idempotentă end to end.
Un exemplu lucrat: un endpoint de plată idempotent
Designul naiv taxează dublu la retry. Imaginează-ți un endpoint HTTP:
POST /payments
Body: { amount: 100, card: "..." }
Clientul face POST. Serverul taxează cardul prin gateway, returnează 200 OK cu noul payment record. Răspunsul clientului se pierde în tranzit din cauza unei conexiuni mobile fluctuante. Clientul face retry. Serverul taxează cardul din nou. Două taxări. Un utilizator furios.
Designul idempotent adaugă un header Idempotency-Key. Clientul generează cheia în momentul în care utilizatorul apasă „pay” și o reutilizează pentru retry-uri. Serverul, la primirea cererii, caută cheia într-un mic store (Redis, sau un tabel dedicat). Dacă cheia e nouă, încuie cheia, procesează plata, stochează (key, response), și returnează. Dacă cheia are deja un răspuns stocat, serverul returnează răspunsul stocat fără să retaxeze. Dacă cheia e încuiată dar n-are încă un răspuns (o cerere anterioară e încă în zbor), serverul fie așteaptă, fie returnează un răspuns „still processing” pe care clientul îl poate poll-ui.
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 }
Observă trei lucruri despre acest design.
Primul, store-ul de deduplicare trebuie să fie durabil. Dacă e în memorie și serverul restart-ează, următorul retry va arăta ca o cerere nouă și ai pierdut proprietatea pentru care plăteai. Redis cu persistență e acceptabil. Un tabel Postgres e mai bun. Un cache in-memory e greșit.
Al doilea, cheia trebuie stocată înainte de side effect, nu după. Dacă taxezi cardul și apoi scrii cheia, un crash între cele două te lasă cu o taxare care n-are niciun record de idempotency, și următorul retry va taxa din nou. Pattern-ul corect este: încuie cheia, efectuează side effect-ul, scrie răspunsul, eliberează lock-ul. Locking-ul poate fi un row-level pessimistic lock sau un INSERT cu o constrângere unique care eșuează la duplicate.
Al treilea, key store-ul are o politică de retenție. Nu poți păstra idempotency keys pe vecie. O alegere comună este de douăzeci și patru de ore, care acoperă orice fereastră rezonabilă de retry de pe partea clientului. Store-ul crește cu rata de cereri unice pe zi și rămâne mărginit.
Punând totul cap la cap
Prescripția arhitecturală care decurge din această lecție e scurtă. Folosește stratul de messaging pentru livrare at-least-once, pentru că at-least-once e singurul lucru pe care stratul îl poate promite onest peste o rețea nesigură. Fă fiecare consumator idempotent, prin unul dintre pattern-urile de mai sus. Tratează afirmațiile exactly-once cu scepticism: identifică granița în interiorul căreia afirmația e valabilă, identifică sink-urile din afara acelei granițe, și fă acele sink-uri idempotente pe cont propriu.
Costul acestui design e un mic overhead per mesaj: un lookup în store-ul de deduplicare, costul de stocare al cheilor, și disciplina de a genera chei unice la stratul potrivit. Beneficiul este că sistemul se comportă corect sub retry-uri, eșecuri de broker, partiții de rețea, și genul de eșecuri pe care nu le poți anticipa, pentru că stratul aplicației nu mai contează dacă un mesaj dat a sosit o dată sau de cinci ori.
Despre ce a fost Modulul 2
Acum șase lecții am început Modulul 2 cu cele opt fallacies of distributed computing. Arcul modulului a fost: orice proprietate pe care o luai de bună într-o lume cu un singur proces, o singură mașină, devine o întrebare din momentul în care încetezi să fii pe o singură mașină. Rețeaua nu este fiabilă. Latency nu este zero. Ceasurile nu sunt de acord. CAP te forțează să alegi între consistență și disponibilitate când vine partiția, și partiția va veni. Strong consistency e scump când o poți avea, și deseori nu o poți avea. Tranzacțiile distribuite costă mai mult decât te aștepți, iar coordinator-failure mode-ul de la 2PC înseamnă că ar trebui să apelezi mai degrabă la Sagas. Și, astăzi, exactly-once delivery e o ficțiune la stratul de messaging care devine fapt la stratul aplicației prin idempotency.
Dacă ai venit în Modulul 2 sperând la tehnicile care fac sistemele distribuite mai ușoare, veștile sunt mixte. Tehnicile există, și le-am acoperit, dar titlul e că sistemele distribuite nu devin mai ușoare; devin tractabile. Modul în care devin tractabile e că internalizezi un număr mic de pattern-uri (operații idempotente, sagas, eventual consistency, granițe atente între starea locală și cea globală) și le aplici peste tot, by default, până când nu mai sunt tehnici și sunt doar modul în care scrii cod care vorbește cu alte mașini.
Restul cursului construiește pe această fundație. Modulul 3 devine concret pe stratul de date: replicas, pooling, sharding, partitioning. Modulul 4 devine concret pe queue-uri și workers. Modulul 5 se întoarce la întrebarea descompunerii pe servicii cu vocabularul sistemelor distribuite acum în mână. Niciunul dintre acele module nu are sens fără fundațiile Modulului 2. Cu ele, restul cursului e o secvență lungă de „date aceste constrângeri, iată pattern-ul care le rezolvă.” Asta e ce înseamnă arhitectura. Bun venit la Modulul 3.
Citations and further reading
- Stripe API documentation, “Idempotent requests”,
https://stripe.com/docs/api/idempotent_requests(retrieved 2026-05-01). The canonical write-up of the idempotency-key pattern as a public API contract. - 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/(retrieved 2026-05-01). The original Kafka exactly-once announcement, with careful scoping of what the guarantee covers. - Jim Gray and Andreas Reuter, “Transaction Processing: Concepts and Techniques” (Morgan Kaufmann, 1992). The reference text on transactions, idempotency, and the design of reliable systems. Old but still the best treatment of the underlying ideas.
- Tyler Treat, “You Cannot Have Exactly-Once Delivery”,
https://bravenewgeek.com/you-cannot-have-exactly-once-delivery/(retrieved 2026-05-01). A short, sharp essay that walks through the Two Generals Problem in messaging terms.