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

Replication lag și consistency read-after-write

Bug-ul utilizator-a-văzut-date-stale. De ce se întâmplă cu replication async și pattern-urile care îl previn: read-your-writes, sticky sessions, monotonic reads.

Lecția anterioară a descris replication leader/follower ca default pentru majoritatea sistemelor de producție. A menționat și că replication asynchronous e default-ul în interiorul acelui default: leader-ul nu așteaptă follower-ii să confirme înainte să confirme o scriere. Costul de latency al așteptării e prea mare pentru majoritatea workload-urilor, iar câteva secunde de replication lag sunt de obicei invizibile.

De obicei. Lecția asta e despre momentele când nu sunt invizibile, despre bug-urile percepute de utilizator care rezultă și despre pattern-urile care le rezolvă. Fenomenul are un nume (replication lag), iar clasa de bug-uri are un nume (read-after-write inconsistency, sau mai general, anomaliile de consistency pe care utilizatorul le vede când citirile lui aterizează pe un follower care e în urmă față de leader). Ambele nume descriu același lucru din unghiuri diferite. Lecția e mică, bine cunoscută și e locul în care multe echipe descoperă că „eventual consistency” nu e un slogan, ci o preocupare operațională zilnică.

Bug-ul, povestit ca o poveste

Ai un setup Postgres leader/follower. Un leader, trei read replicas, toate asynchronous. Aplicația citește de la orice replica printr-un load balancer, iar scrierile merg direct la leader. Replication lag e în mod normal sub o sută de milisecunde.

Un utilizator postează un comentariu. Aplicația trimite INSERT la leader, care întoarce success în câteva milisecunde. Aplicația redirecționează utilizatorul la firul de comentarii. Page-load-ul se întâmplă să aterizeze pe un read replica care încă n-a primit schimbarea de la leader. Pagina se randează fără comentariul utilizatorului.

Utilizatorul e confuz. A trecut comentariul? Apasă din nou „post”. De data asta, page-load-ul se întâmplă să nimerească o replica care a ajuns din urmă, iar acum vede două copii ale aceluiași comentariu. Sau trei. Sau, al doilea INSERT aterizează pe leader înainte ca prima replica să fi ajuns din urmă, iar utilizatorul postează de două ori și vede o dată, iar asta e și mai confuz.

Aceasta e replication lag, făcută vizibilă. Sistemul se comportă exact cum a fost proiectat: scrierea e durabilă, read replicas funcționează, load balancer-ul își face treaba. Utilizatorul e cel care vede o inconsistență, iar utilizatorului nu-i pasă de CAP, PACELC (lecția 11) sau modelele de consistency din lecția 12. Lui îi pasă că nu poate vedea ce tocmai a scris.

Acesta e bug-ul. E una dintre patru anomalii vizibile pentru utilizator pe care replication asynchronous le poate produce.

Patru clase de anomalie de consistency

Violare read-your-writes. Bug-ul de mai sus. Utilizatorul face o scriere, apoi citește imediat și nu-și vede propria scriere. E modul de eșec cel mai direct perceput de utilizator. Oamenii observă când lucrul pe care tocmai l-au tastat a dispărut. Nu observă când un rând pe care nu l-au văzut niciodată e cu câteva sute de milisecunde în urmă.

Violare monotonic-reads. Utilizatorul citește, vede X. Face refresh. Vede o stare mai veche, X minus unu. Asta se întâmplă când două citiri consecutive aterizează pe replici diferite, iar a doua e mai în urmă decât prima. Din perspectiva utilizatorului, timpul pare să curgă înapoi. Nu la fel de imediat vizibilă ca prima anomalie, dar dezorientantă când se întâmplă, și în special urâtă în dashboard-uri sau feed-uri de activitate unde stale-după-fresh arată ca un glitch.

Violare cauzală. Un utilizator postează o întrebare, iar un prieten postează un răspuns. Un alt utilizator, care urmărește firul, vede răspunsul înainte să vadă întrebarea. Asta se întâmplă pentru că întrebarea și răspunsul au fost scrise la momente diferite, replicate independent și au ajuns la read replica observatorului în ordine inversă. Cauzalitatea e ruptă: B pare să preceadă A, deși A a cauzat B. Comun în messaging, fire de comentarii și documente colaborative.

Date stale persistente la scară. O read replica rămâne minute în urmă din cauza unui query lung, a unui blip de rețea sau a unui spike de load pe leader. Fiecare utilizator rutat la acea replica primește date stale pe durata respectivă. Aplicația nu e vizibil stricată (nicio eroare), dar fiecare citire e greșită. Echipa află dintr-un raport de bug sau, mai rău, de la un dashboard de metrici care a început să-i mintă.

Anomaliile sunt listate aproximativ în ordinea crescătoare a subtilității și descrescătoare a frecvenței cu care aplicația e blamată pentru ele. Read-your-writes e cea de care utilizatorii se plâng. Celelalte trei sunt cele care erodează tăcut încrederea în sistem.

Garanția read-your-writes

Cea mai simplă rezolvare pentru prima anomalie e și cea mai brută. După ce un utilizator scrie, rutează-i citirile la leader pentru o anumită fereastră de timp. Leader-ul are scrierea prin definiție; citirile de la el o văd întotdeauna.

sequenceDiagram
    participant U as User
    participant LB as Load balancer
    participant L as Leader
    participant F as Follower

    Note over U,F: Without read-your-writes (the bug)
    U->>LB: POST /comment
    LB->>L: INSERT comment
    L-->>LB: success
    LB-->>U: 200 OK
    U->>LB: GET /thread
    LB->>F: SELECT comments
    F-->>LB: stale list (no new comment)
    LB-->>U: missing comment

    Note over U,F: With read-your-writes (the fix)
    U->>LB: POST /comment
    LB->>L: INSERT comment
    L-->>LB: success
    LB-->>U: 200 OK (set sticky flag)
    U->>LB: GET /thread (sticky flag)
    LB->>L: SELECT comments
    L-->>LB: fresh list (with new comment)
    LB-->>U: comment present

Întrebarea „fereastra de timp” e cea de care depinde implementarea. Trei variante.

Fereastră bazată pe timp. După o scriere, rutează citirile la leader pentru următoarele treizeci de secunde (sau orice valoare care depășește confortabil worst-case-ul de replication lag). După fereastră, cazi înapoi pe followers. Ușor de implementat, dar fiecare cădere de replica sau link lent îți extinde worst-case-ul de lag, iar fereastra trebuie setată conservator.

Fereastră tracked-replica. Sesiunea fiecărui utilizator stochează un token: log sequence number-ul, GTID-ul sau timestamp-ul ultimei lui scrieri. La fiecare citire, aplicația alege o replica care a ajuns cel puțin până la acel token, căzând înapoi pe leader dacă niciun follower n-a ajuns. Postgres expune asta prin pg_last_wal_replay_lsn(), iar clienții pot trimite request-uri Sync; MySQL are WAIT_FOR_EXECUTED_GTID_SET. Mai precisă decât bazată pe timp, mai complexă de implementat și merită pentru sistemele unde load-ul de citire pe leader e un cost real.

Fereastră per utilizator-și-resursă. Urmărește pe bază per resursă: utilizatorul are nevoie de consistency read-your-writes doar pe lucrurile pe care le-a scris. Citirea datelor altora tolerează replication lag-ul normal. Asta cere ca aplicația să știe care citiri sunt post-scriere și care nu, ceea ce de obicei se traduce prin „citirile din aceeași acțiune de controller care tocmai a făcut o scriere sunt rutate la leader, alte citiri nu”. Pragmatic și eficient.

Sticky sessions

Dacă un utilizator e rutat consistent către aceeași replica pe parcursul sesiunii sale, primește monotonic reads gratis: replica se mișcă doar înainte în timp, deci citirile consecutive de la ea nu pot merge înapoi. Acestea sunt „sticky sessions”: load balancer-ul sau aplicația lipește un utilizator de o singură replica.

Trade-off-ul e clar. Lipirea de o singură replica strică load balancing-ul: dacă un utilizator popular e lipit de o replica mică, acea replica e suprasolicitată. Dacă o replica cade, fiecare utilizator lipit de ea trebuie re-rutat, posibil către o replica mai în urmă, posibil producând violarea de monotonic-read pe care încercai s-o eviți. Sticky sessions nici nu ajută peste logout/login, peste device-uri sau peste sesiuni suficient de lungi încât lag-ul unei singure replici să se schimbe.

În practică, sticky sessions sunt deseori combinate cu un token de tracked-replica: lipești unde se poate, iar când re-rutarea e necesară, re-rutezi doar la o replica care a ajuns la ultima poziție de log văzută de utilizator.

Garanția monotonic-read

Dacă nu poți sau nu vrei să lipești de o singură replica, încă poți obține monotonic reads asigurând că fiecare citire e servită de la o replica cel puțin la fel de actualizată ca cea anterioară. Sesiunea urmărește cea mai recentă poziție de log pe care utilizatorul a văzut-o pe orice citire; citirile ulterioare merg doar la replicile care au ajuns la acea poziție. Dacă nicio replica n-a ajuns, citirea merge la leader.

Aceasta e o generalizare a pattern-ului tracked-replica de read-your-writes: în loc să urmărească doar „ce a scris utilizatorul”, urmărește „ce a văzut utilizatorul”. Costul de implementare e similar; proprietatea de consistency e mai puternică.

Causal consistency

Read-your-writes și monotonic reads adresează fiecare câte o anomalie. Causal consistency adresează a treia: asigurarea că, dacă scrierea A precede cauzal scrierea B, fiecare cititor vede A înainte de B. Mecanismul e mai complex.

Implementarea clasică e vector clocks: fiecare replica urmărește un vector de counters, unul per fiecare altă replica, care înregistrează câte scrieri de la fiecare replica a aplicat. O scriere de la o replica poartă un vector clock; o altă replica aplică scrierea doar când propriul vector domină dependențele. Asta garantează ordinea cauzală în întregul sistem, dar e scumpă în metadata: fiecare record poartă un vector care crește cu numărul de replici.

Majoritatea sistemelor de producție fac ceva mai ieftin. Urmăresc dependențe cauzale doar într-o sesiune (citirile și scrierile pe care un utilizator le-a văzut), le pasează ca un token și asigură că fiecare citire vede toate scrierile cauzal-anterioare din acea sesiune. Acesta e pattern-ul în citirile causal-consistent ale MongoDB (unde clientul pasează un token clusterTime între operații), în CockroachDB și în sisteme similare. E mai slabă decât causal consistency complet (legăturile cauzale cross-session pot fi încă violate), dar acoperă majoritatea cazurilor practice pentru o fracțiune din cost.

Costul fiecărei garanții

Fiecare proprietate de consistency pe care o adaugi îți cumpără înapoi o anumită corectitudine vizibilă pentru utilizator la un cost măsurabil.

Read-your-writes trimite unele citiri la leader, reducând beneficiul de scalare a citirilor al replicilor. Dacă o pagină populară face un pattern de scriere-apoi-citire, citirile acelei pagini lovesc toate leader-ul și efectiv le-ai descalat.

Sticky sessions strică abilitatea load balancer-ului de a egaliza load-ul și complică gestionarea căderii de replica. Nu supraviețuiesc curat sesiunilor cross-device.

Monotonic-read tokens adaugă complexitate la fiecare path de citire: aplicația trebuie să cunoască token-ul, load balancer-ul trebuie să filtreze replicile după el, iar data store-ul trebuie să expună poziții de log. Funcționabil, dar sunt mai multe piese în mișcare.

Causal-consistency tokens adaugă metadata la request-uri și răspunsuri, ceea ce costă bandwidth. Cer și ca aplicația să treacă token-ul prin fiecare apel, inclusiv peste servicii dacă arhitectura e mai mult de un serviciu adâncă.

Nu plătești toate aceste costuri peste tot. Practica de inginerie rezonabilă e să le plătești pe path-urile unde bug-ul ar fi cel mai vizibil și să accepți lag-ul implicit pe path-urile unde n-ar fi.

Cum arată „suficient de bine” în 2026

Majoritatea aplicațiilor pot tolera câteva secunde de replication lag pentru majoritatea citirilor. Utilizatorul nu face refresh suficient de rapid ca să observe. Datele nu se schimbă suficient de rapid ca să fie ambigue. Aplicația se bazează implicit pe asta și e în regulă.

Locurile unde n-ar trebui să te bazezi pe asta sunt cele unde utilizatorul are un model mental pe care sistemul ar trebui să-l satisfacă. Trei euristici utile.

Utilizatorul tocmai și-a salvat profilul, iar pagina următoare îi încarcă profilul? Citește de la leader. Utilizatorul tocmai a postat un comentariu, iar firul de comentarii se încarcă? Citește de la leader. Utilizatorul tocmai a plasat o comandă și i se arată sumarul comenzii? Citește de la leader. Toate astea sunt pattern-ul read-your-writes, aplicat selectiv pe path-urile cu cea mai mare vizibilitate.

Utilizatorul răsfoiește un feed cu conținutul altor oameni? Citește de la un follower. Câteva secunde de staleness sunt invizibile. Utilizatorul e pe un dashboard care se actualizează la fiecare minut? Citește de la un follower. Staleness-ul e parte din contract. Citirile interne, cum ar fi un admin tool care rulează rapoarte, pot tolera secunde sau minute de lag fără ca cineva să observe.

Lucrul care doare e staleness-ul perceput de utilizator: când utilizatorul are un model despre cum ar trebui să fie datele, iar sistemul nu se potrivește. Lucrul care nu doare e staleness-ul invizibil: când nimeni nu se uită suficient de atent la o anumită valoare ca să observe că e cu o secundă-două în urmă.

Ingineria mitigărilor de replication lag e în mare parte despre a-ți da seama care citiri sunt percepute de utilizator și care nu, și aplicarea pattern-urilor mai costisitoare doar pe primele. Înțelege bine această distincție, iar sistemul se va simți strongly consistent gratuit, pe path-urile la care utilizatorului îi pasă, în timp ce păstrezi beneficiile de scalare a citirilor ale replicilor peste tot altundeva.

Ce acoperă lecția următoare

Replication păstrează mai multe copii ale acelorași date pe mașini diferite. Partitioning, subiectul lecției 27, împarte datele astfel încât fiecare mașină să țină un subset diferit. Majoritatea sistemelor reale fac ambele: fiecare partition e replicat, fiecare replica e leader sau follower pentru datele partition-ului ei. Pattern-urile din această lecție și din cea următoare se compun, iar designul cu patru cadrane rezultat (replicat și partitionat) e default-ul pentru orice bază de date care operează la scară semnificativă.

Citări și lecturi suplimentare

  • Martin Kleppmann, Designing Data-Intensive Applications (O’Reilly, 2017), Capitolul 5, “Problems with Replication Lag”. Tratamentul de referință, cu aceeași taxonomie de anomalii folosită aici.
  • Documentația Postgres, “Hot Standby” și pg_last_wal_replay_lsn(), https://www.postgresql.org/docs/current/hot-standby.html (consultat 2026-05-01). Mecanica streaming replication-ului și cum să urmărești catch-up-ul replicilor.
  • Documentația MongoDB, “Causal Consistency and Read and Write Concerns”, https://www.mongodb.com/docs/manual/core/causal-consistency-read-write-concerns/ (consultat 2026-05-01). Un exemplu concret de tokens de causal-consistency la nivelul client/sesiune.
  • Documentația AWS RDS, “Working with Read Replicas”, https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/USER_ReadRepl.html (consultat 2026-05-01). Vederea operațională a read replicas, metricilor de replication lag și failover.
  • Doug Terry et al., “Session Guarantees for Weakly Consistent Replicated Data”, IEEE PDIS 1994. Lucrarea originală care a numit read-your-writes, monotonic reads, monotonic writes și writes-follow-reads.
Caută