Arhitectura datelor și a sistemelor, de la zero Lecția 45 / 80

Semantici exactly-once în stream-uri

Ce oferă de fapt tranzacțiile Kafka, problema coordonării sursă-sink, limitele și de ce exactly-once peste servicii este greu.

Lecția 16, în Modulul 2, a făcut argumentul că livrarea exactly-once este imposibilă peste o rețea nesigură și că răspunsul arhitectural este livrarea at-least-once plus procesare idempotentă. Lecția aceea era despre messaging în general. Asta este despre o formă specifică a problemei în interiorul pipeline-urilor de streaming, unde furnizorii livrează ceva pe care îl numesc „exactly-once” și unde acea etichetă înseamnă ceva mai îngust și mai util decât sugerează cuvintele.

Promisiunea exactly-once este atrăgătoare: fiecare eveniment de intrare afectează fiecare ieșire exact o singură dată. Nu zero, pentru că sistemul este de încredere. Nu două, pentru că sistemul deduplică. Motivul pentru care e greu este același motiv ca înainte, iar motivul pentru care este uneori livrabil în streaming este că motoarele de streaming controlează mai mult din traseu decât un client generic de messaging.

De ce exactly-once este greu, în imagini

Un job de stream processing are o formă. Există o sursă de intrare, un procesor și un sink de ieșire. Fiecare eveniment trece prin toate trei. Fiecare poate eșua. Când o etapă eșuează, orchestrarea face retry.

Fără coordonare, retry-ul este ce cauzează duplicatele. Procesorul citește un eveniment din Kafka, calculează o transformare, scrie rezultatul în Kafka sau într-o bază de date și acknowledge-uiește sursa avansând offset-ul de consumer. Fiecare dintre acești pași este o operațiune separată. Dacă procesorul se prăbușește între scrierea ieșirii și avansarea offset-ului, încercarea următoare recitește același eveniment și scrie ieșirea a doua oară. At-least-once. Duplicatele sunt reale și nu ai nicio modalitate să le observi după faptă, dacă sink-ul tău nu poate deduplica.

Eșecul opus (avansează offset-ul, apoi se prăbușește înainte să scrie ieșirea) îți dă at-most-once, de asemenea rău. Comportamentul corect este ca avansarea offset-ului și scrierea ieșirii să se întâmple ambele sau niciuna. Asta este o tranzacție, distribuită peste sursa de intrare, procesor și sink-ul de ieșire.

Pentru majoritatea combinațiilor de sursă și sink, acea tranzacție nu există. Nu există un protocol gata făcut care să facă „avansează offset-ul Kafka și inserează un rând în Postgres” atomic. Cele două sisteme au noțiuni separate de commit, semantici de eșec separate, niciun coordonator partajat. Aceasta este inima problemei.

Semanticile exactly-once Kafka, ce acoperă de fapt

Kafka livrează o funcționalitate numită exactly-once semantics, abreviat EOS. Nu este un singur switch. Sunt trei mecanisme cooperante care împreună îți dau o tranzacție peste topic-uri Kafka.

Producer idempotent. Fiecare instanță de producer are un ID unic de producer și fiecare batch de înregistrări pe care îl trimite poartă un număr de secvență. Broker-ul ține minte, per partiție, ultima secvență acceptată de la fiecare producer. Dacă un blip de rețea cauzează producer-ul să facă retry la un batch pe care broker-ul l-a scris deja, broker-ul recunoaște numărul de secvență duplicat și returnează ack-ul original în loc să scrie de două ori. Retry-ul de pe partea producer-ului, care în mod normal ar fi o sursă de duplicate, devine un no-op la broker. Asta se activează prin setarea enable.idempotence=true și este podeaua pe care se construiește tot restul.

Tranzacții. Un producer poate deschide o tranzacție, scrie în mai multe partiții peste mai multe topic-uri și commit-uiește. Din perspectiva consumer-ului, scrierile fie devin toate vizibile împreună, fie niciuna. Implementarea folosește un coordonator de tranzacții (unul dintre brokeri) și marker-e în stilul two-phase-commit în log-urile de partiție. Producer-ul are și un ID tranzacțional care supraviețuiește restart-urilor, ceea ce previne două instanțe zombie ale aceluiași producer să scrie ambele în interiorul a ceea ce fiecare crede că este aceeași tranzacție.

Izolare read-committed. Consumatorii pot fi configurați cu isolation.level=read_committed. Vor sări orice înregistrări care fac parte dintr-o tranzacție deschisă sau abortată și vor vedea doar înregistrări care aparțin unei tranzacții commit-uite. Fără asta, consumatorii văd tot ce este scris, inclusiv abort-uri, iar atomicitatea tranzacției este invizibilă.

Commit-ul de offset în interiorul tranzacției. Asta este partea care închide bucla. Când un pipeline Kafka Streams sau un consumer-producer tranzacțional commit-uiește munca, avansarea offset-ului pentru topic-ul de intrare este inclusă ca o scriere în interiorul aceleiași tranzacții ca scrierile de ieșire. Fie offset-urile și ieșirile commit-uiesc ambele, fie ambele abort-ează.

Puse împreună, asta îți dă o garanție puternică, dar doar în interiorul unei limite specifice: intrare din Kafka, procesare, ieșire către Kafka, totul în cadrul aceluiași cluster Kafka (sau, cu funcționalități mai noi, clustere federate care partajează un coordonator de tranzacții). În interiorul acelei limite, nicio înregistrare de intrare nu produce o ieșire mai mult de o dată.

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 folosește această mașinărie automat când processing.guarantee=exactly_once_v2 este setat. Runtime-ul deschide o tranzacție per task per interval de commit, scrie ieșirile și offset-urile în interiorul ei și commit-uiește. Atâta timp cât topologia rămâne în interiorul Kafka, garanția se ține.

Unde se oprește

Limita, în termeni simpli, este „intrările și ieșirile pe care coordonatorul de tranzacții Kafka le poate include într-un commit.” Asta înseamnă topic-uri Kafka, plus topic-ul de offset al consumer-ului. Nu este Postgres. Nu este S3. Nu este un API HTTP. Nu este Elasticsearch. Nu este un sistem de metrici. Niciunul dintre acestea nu participă la tranzacție.

Un job Kafka Streams care citește dintr-un topic, face niște procesare și scrie într-un alt topic: exactly-once este real și poți să te bazezi pe el. Un job Kafka Streams care citește dintr-un topic și împinge rezultatele într-o bază de date Postgres printr-un sink connector: exactly-once este o ficțiune parțială. Tranzacția commit-uiește ieșirea către Kafka (connector-ul citește dintr-un topic Kafka de ieșire), dar scrierea connector-ului în Postgres se întâmplă în afara tranzacției Kafka. Dacă connector-ul se prăbușește între scrierea în Postgres și commit-uirea propriului offset, la restart rescrie. Duplicate în Postgres.

Același lucru se întâmplă pentru orice sink extern. Tranzacția Kafka se oprește la marginea Kafka. Ce se află dincolo de margine trebuie să gestioneze duplicatele pe cont propriu.

Soluțiile pentru sink-uri externe

Există trei opțiuni, în ordine descrescătoare a frecvenței cu care funcționează în practică.

Sink-uri idempotente. Acesta este răspunsul corect în aproape orice caz. Proiectează sink-ul astfel încât rescrierea aceleiași ieșiri să producă aceeași stare finală. Pattern-urile din lecția 16 se aplică direct. Upsert în Postgres pe o cheie unică. POST către un API HTTP cu un header Idempotency-Key pe care receptorul îl ține minte. Scrie în S3 cu o cheie de obiect deterministă derivată din intrare, astfel încât a doua scriere fie înlocuiește prima, fie este respinsă ca duplicat. Garanția exactly-once a motorului de streaming acoperă tot până la sink, iar idempotența sink-ului acoperă restul. Împreună îți dau corectitudine end-to-end.

Disciplina este că idempotența sink-ului trebuie proiectată în el, nu presupusă. Un INSERT Postgres naiv nu este idempotent. Un INSERT ... ON CONFLICT (key) DO UPDATE este. Un POST HTTP naiv nu este. Un POST cu un tabel de dedup pe partea de server pe Idempotency-Key este. Motorul de streaming nu poate face un sink neidempotent să se comporte; tu trebuie să repari sink-ul.

Coordonare în stil two-phase commit. Unele sink-uri suportă un protocol write-then-commit pe care motorul de streaming îl poate conduce. Flink le numește pe acestea „two-phase commit sinks.” Motorul scrie ieșirea în timpul tranzacției de streaming, ține datele într-o stare în așteptare și îi spune sink-ului să commit-uiască doar după ce checkpoint-ul motorului reușește. Dacă motorul eșuează, îi spune sink-ului să abort-eze. Sink-ul trebuie să suporte scrieri în așteptare și apeluri explicite de commit sau abort, ceea ce majoritatea nu fac. Sink-urile JDBC și Kafka au implementări. Majoritatea celorlalte sink-uri nu au. Când o ai, funcționează. Când nu o ai, opțiunea este închisă și cazi înapoi pe scrieri idempotente.

At-least-once plus dedup downstream. Opțiunea pragmatică atunci când niciuna dintre cele de mai sus nu este fezabilă. Motorul de streaming este at-least-once. Sink-ul acceptă duplicate. Un pas batch downstream sau o agregare la momentul query-ului deduplică pe un ID de eveniment. Asta este aceeași formă ca pattern-ul append-with-dedup din lecția 38. Funcționează și este operațional mai greu decât opțiunea sink-ului idempotent, pentru că fiecare citire sau fiecare pas batch trebuie să plătească pentru dedup. Recurge la asta când sink-ul chiar nu poate fi făcut idempotent.

Ce înseamnă asta în practică

Sumarul onest este scurt. Exactly-once este o proprietate la nivel de sistem, nu un checkbox într-un fișier de configurare. Kafka EOS este excelent pentru partea de sistem pe care o acoperă, care este Kafka-la-Kafka. Pentru orice altceva, motorul de streaming îți dă o garanție puternică până la sink, iar tu, inginerul, ești responsabil pentru comportamentul sink-ului.

Regula arhitecturală care rezultă din asta este aceeași ca regula Modulului 2, reformulată pentru streaming: proiectează fiecare sink să fie idempotent și tratează etichetele exactly-once cu scepticism. Identifică limita la care se aplică eticheta. Identifică sink-urile din afara acelei limite. Fă acele sink-uri idempotente pe cont propriu.

Există două capcane specifice care merită semnalate.

Prima capcană este activarea EOS fără consumatori read-committed. Partea de producer este tranzacțională, partea de consumer nu este, iar consumer-ul citește scrieri abortate alături de cele commit-uite. Ieșirea arată bine în topic, dar este greșită când este consumată. EOS este producer-ul plus nivelul de izolare al consumer-ului plus commit-ul de offset, toate împreună. O jumătate de configurație nu este o configurație.

A doua capcană este să presupui că sink-urile Kafka Connect moștenesc EOS. Nu o fac, în mod implicit. Un connector care scrie într-un sistem non-Kafka este at-least-once la marginea sink-ului, dacă nu cumva connector-ul specific sink-ului implementează two-phase commit, iar destinația suportă asta. Documentația Confluent este explicită despre care connector-e sunt capabile de exactly-once și sub ce configurație. Citește pagina aceea înainte să promiți proprietatea unui stakeholder.

Unde ne lasă asta

Modulul 6 a acoperit acum, pe rând, motoarele, topologiile, starea, timpul și semanticile de livrare ale streaming-ului. Lecția următoare se mută la o problemă diferită, dar adiacentă: cum ajung schimbările de stare din bazele tale de date în layer-ul de streaming în primul rând, fără să violeze garanțiile de consistență ale bazei de date sau ale message bus-ului. Asta este change data capture și pattern-ul outbox, iar ele sunt podul între lumea OLTP în care restul companiei scrie cod și lumea de streaming pe care acest modul a descris-o.

Firul care trece prin toate aceste lecții este același care a trecut prin Modulul 2: corectitudinea distribuită nu este gratuită, iar pattern-urile care o fac tractabilă sunt un set mic, aplicat consecvent, până când încetează să fie tehnici și devin modul în care construiești implicit. Exactly-once este unul dintre acele pattern-uri. Disciplina este să știi exact ce garantează motorul tău, exact unde se oprește garanția și exact care linii de cod la sink fac diferența.

Citări și lectură suplimentară

  • 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/ (consultat 2026-05-01). Anunțul original, cu scoping atent.
  • Apache Kafka documentation, “Transactions”, https://kafka.apache.org/documentation/#transactions (consultat 2026-05-01).
  • Confluent, “Patterns for streaming microservices” (consultat 2026-05-01). Lucrarea despre streaming-microservices care acoperă scopul EOS, idempotența sink-urilor și pattern-ul de sink two-phase-commit.
  • Tyler Akidau, Slava Chernyak, Reuven Lax, “Streaming Systems” (O’Reilly, 2018). Capitole despre modele de consistență în streaming și rolul efectelor secundare. Consultat 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 (consultat 2026-05-01).
Caută