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

Modelli di consistency: strong, eventual, causal, monotonic

Lo spettro delle garanzie che un sistema può offrire, con un esempio pratico che mostra cosa promette e cosa rompe ogni modello.

La prima reazione che la maggior parte degli ingegneri ha verso la consistency, dopo una lezione o due sul teorema CAP, è quella di archiviarla come un binario. C’è la consistency “strong”, in cui il sistema si comporta come una singola macchina, e c’è la consistency “eventual”, in cui il sistema può mentire per un po’ e poi mettersi in pari. Strong è corretta e lenta. Eventual è veloce e strana. Scegline una.

Questo binario è sbagliato, e lo è in modo che conta. I sistemi reali offrono uno spettro di garanzie di consistency, e la maggior parte dei database interessanti ti permette di mescolarle: strong per alcune operazioni, più deboli per altre, con manopole esplicite. Se conosci solo i due estremi, stai scegliendo tra “troppo costoso” e “troppo confusionario” senza renderti conto che ci sono cinque o sei stazioni utili nel mezzo.

Questa lezione è la mappa. Cammineremo lungo lo spettro dal più forte al più debole, definiremo ogni modello in modo abbastanza preciso da farti capire quando viene violato, poi faremo girare un singolo esempio pratico (un carrello della spesa) sotto ogni modello e guarderemo cosa cambia.

Lo spettro

Ci sono sei modelli di consistency che vale la pena conoscere per nome. Formano un ordine parziale: i modelli più forti implicano tutte le garanzie di quelli più deboli, motivo per cui questa è una gerarchia e non una lista di alternative.

Linearizable (strong consistency)

Il sistema si comporta come se ci fosse una singola copia dei dati e ogni operazione avesse effetto in un singolo punto nel tempo, tra il momento in cui viene emessa e il momento in cui ritorna. Una volta che una scrittura ha successo, ogni lettura successiva, su qualunque replica, vede quella scrittura o una più recente. Letture e scritture appaiono nell’ordine real-time.

Questo è il modello di consistency più forte tra quelli pratici, ed è il più costoso. Per fornire la linearizzabilità tra le repliche, il sistema deve coordinare ogni scrittura attraverso un quorum, il che costa almeno un round-trip verso una maggioranza dei nodi. In un database che si estende su più region, quel round-trip può essere di 50-200 millisecondi. La linearizzabilità vieta anche trucchi come servire le letture da una replica locale senza controllare, perché la replica locale potrebbe essere stale.

Sistemi reali che offrono linearizzabilità: Google Spanner (con TrueTime, che è la prossima lezione), etcd, Zookeeper, un Postgres su singolo nodo banalmente, DynamoDB quando chiedi ConsistentRead=true.

Sequential consistency

Tutte le operazioni appaiono in qualche ordine totale, lo stesso su ogni osservatore, ma l’ordine non deve corrispondere al tempo reale. Se il processo A scrive X alle 10:00:01 e il processo B scrive Y alle 10:00:02, la sequential consistency permette a ogni osservatore di vedere prima Y, purché tutti vedano prima Y.

Questo modello è raro come obiettivo di design nei database moderni. Compare nei testi più vecchi, nell’hardware shared-memory (i memory model di x86 e ARM sono grosso modo sequential consistency per alcune operazioni, più deboli per altre), e come tappa intermedia nelle dimostrazioni. La maggior parte delle richieste “voglio un sistema strong” significa in realtà linearizable.

Causal consistency

Se l’operazione A ha influenzato causalmente l’operazione B, ogni osservatore vede A prima di B. Le operazioni causalmente non correlate possono essere osservate in qualunque ordine. “Influenzato causalmente” è definito dal message passing: se A è avvenuta su un nodo, poi B è avvenuta su un nodo che aveva osservato A, allora A è una causa di B.

La causal consistency è il modello più forte che non richiede coordinamento su ogni scrittura. Le repliche possono continuare ad accettare scritture locali; devono solo tracciare quali scritture dipendono da quali altre. Questo la rende un obiettivo popolare per i sistemi che vogliono sembrare giusti agli umani senza pagare la tassa di coordinamento della linearizzabilità.

L’esempio classico: un utente pubblica un aggiornamento di stato, poi un commento sul proprio aggiornamento. La causal consistency garantisce che chiunque veda il commento veda anche il post originale. Non garantisce che due utenti non correlati che pubblicano nello stesso momento appaiano nello stesso ordine a tutti.

Read-your-writes

Dopo che un client ha scritto X, le letture successive di quello stesso client vedono X (o un valore che soppianta X). Gli altri client non hanno alcuna garanzia simile.

Questo è un modello a livello di session, non globale. Di solito si implementa instradando le letture di un client verso la replica che ha gestito la sua ultima scrittura, oppure portando con sé un cookie “last write timestamp” che il read path usa per aspettare la replicazione. Quasi ogni app per consumatori si aspetta questo senza rendersene conto: quando invii un form, ti aspetti di vedere la tua submission, anche se non si è ancora propagata alle altre region.

Monotonic reads

Una volta che un client ha letto un valore, le letture successive restituiscono quel valore o uno più recente. Il client non vede mai il tempo andare indietro.

Anche questo è a livello di session. La modalità di fallimento che previene: un client legge dalla replica A e vede la versione 5, poi legge dalla replica B e vede la versione 3. Senza monotonic reads, il client appare viaggiare indietro nel tempo, il che rompe lo stato della UI, le animazioni, e la fiducia dell’utente. Le monotonic reads di solito si implementano con client affinity (rimani su una replica) o con controlli su version vector.

Eventually consistent

Se le scritture si fermano, tutte le repliche convergono prima o poi sullo stesso valore. Non c’è alcuna garanzia su quando, alcuna garanzia sull’ordine in cui appaiono gli stati intermedi, alcuna garanzia su cosa vedono le singole letture.

Questo è il più debole tra i modelli con un nome. È anche sufficiente per molti workload reali: il “viewed count” su un video, la dimensione approssimativa di una coda, l’ultima posizione conosciuta di un autista delle consegne. In ognuno di questi casi, “la risposta giusta più o meno qualche secondo” va bene.

Un esempio pratico: il carrello della spesa

Prendi una singola utente, Alice, con un carrello della spesa in un negozio replicato globalmente. Le repliche vivono in tre region. Alice sta navigando da un telefono a Milano. Gli app server del negozio instradano le sue richieste alla replica più vicina.

Alice fa tre cose in sequenza:

  1. Aggiunge l’item X al carrello.
  2. Aggiunge l’item Y al carrello.
  3. Rimuove l’item X dal carrello.

Ora considera cosa succede sotto ogni modello quando Alice (o il suo compagno Bob, su una replica diversa) legge il carrello.

Linearizable. Qualunque lettura, da chiunque, dopo lo step 3 restituisce “solo Y”. Le letture fatte tra gli step restituiscono stati del carrello che corrispondono al tempo reale: “X” dopo lo step 1, “X e Y” dopo lo step 2, “Y” dopo lo step 3. Il costo: ogni scrittura aspetta un quorum tra le tre region. Se le region sono separate da continenti, ogni add-to-cart richiede 100 millisecondi o più.

Causal. Le letture vedono le operazioni nell’ordine in cui Alice le ha fatte: nessuno vede il remove-X senza aver visto l’add-X. Ma due utenti concorrenti che aggiungono item a un carrello familiare condiviso potrebbero vedere le aggiunte reciproche in ordini diversi. Per un carrello di un singolo utente, causal sembra indistinguibile da linearizable. Per un carrello condiviso, causal è il modello giusto: preserva l’intento dell’utente senza pagare per l’ordinamento globale.

Read-your-writes. Il telefono di Alice vede subito i suoi cambiamenti: add-X, poi add-X-and-Y, poi Y. Se Alice apre il carrello sul laptop, che viene instradato a una replica diversa, potrebbe vedere brevemente il carrello nel suo stato precedente. Bob, che guarda lo stesso carrello da Roma, potrebbe vedere qualunque versione di esso. La latenza è molto più bassa di linearizable; il prezzo è che gli altri osservatori possono confondersi.

Monotonic reads. Alice non vede mai il carrello andare indietro. Se ha visto il carrello con gli item X e Y, non vedrà, al refresh successivo, solo X. Ma potrebbe non vedere subito la sua rimozione di X, se sta leggendo da una replica che non l’ha ancora ricevuta. Questo modello esclude il peggiore dei bug di UI (“il mio item è tornato”), senza risolvere il problema della freshness.

Eventually consistent. Il carrello raggiungerà, prima o poi, uno stato su cui tutti concordano. Nel frattempo, tutto è permesso: il telefono di Alice potrebbe mostrare X e Y, il suo laptop solo Y, e Bob potrebbe vedere un carrello vuoto. Quando la polvere si posa, tutte le repliche convergono. La latenza è la più bassa; l’esperienza utente è la peggiore.

Il senso dell’esempio è che il modello di consistency giusto non è “il più forte disponibile”. È il modello più debole che non produce bug visibili per il tuo workload. I carrelli per lo più vogliono causal più read-your-writes. I saldi bancari vogliono linearizable. I conteggi dei like vogliono eventually consistent.

La tabella costi-benefici

ModelloLatenza in letturaLatenza in scritturaCoordinamentoBug comuni prevenuti
LinearizableAltaAltaQuorum su ogni scrittura, letture frescheLetture stale, scritture perse, viaggi nel tempo
CausalBassaBassaTracciare le dipendenze, niente quorumEffetti causali fuori ordine
Read-your-writesBassaBassaRouting per session”Dov’è finita la mia submission?”
Monotonic readsBassaBassaAffinity per session”I miei dati sono andati indietro nel tempo”
EventualMinimaMinimaNessunoQuasi nessuno

La parte costosa dei modelli più forti non è l’algoritmo, è il coordinamento. Ogni garanzia su cosa vedono le letture si paga in messaggi tra repliche, e ogni messaggio ha un floor di latenza fissato dalla velocità della luce.

Sistemi reali e cosa offrono

Un breve giro tra i database e le manopole di consistency che espongono.

  • Google Spanner. Linearizable di default, globalmente, usando TrueTime per limitare l’incertezza dei clock. Il prezzo è che ogni transazione aspetta un “commit wait” di TrueTime di pochi millisecondi. Spanner è la prova di esistenza che linearizable può essere reso abbastanza veloce per la produzione su scala planetaria, dato un budget per gli orologi atomici.
  • DynamoDB. Letture eventually consistent di default, configurabili a letture “strongly consistent” al doppio del costo e con latenza più alta. Le letture strong sono linearizable su una singola partizione. Le transazioni cross-partition sono una primitiva separata e più costosa.
  • MongoDB. Regolabile per query tramite le impostazioni di read concern e write concern. “Read concern majority” più “write concern majority” ti dà letture linearizable sul leader. Impostazioni più morbide ti danno read-your-writes o eventual.
  • Cassandra. Regolabile per operazione tramite i consistency level (ONE, QUORUM, LOCAL_QUORUM, ALL). Letture in quorum più scritture in quorum ti danno qualcosa di vicino a linearizable su una singola chiave. Livelli più bassi ti danno eventual.
  • Replicazione Postgres. Il primary è banalmente linearizable. Le repliche sincrone sono linearizable. Le repliche async sono eventually consistent, con l’opzione di read-your-writes se la tua applicazione è attenta nel routing.

Nota il pattern: nessun grande database distribuito sceglie un modello e ci si attiene. Tutti espongono manopole, perché operazioni diverse all’interno della stessa applicazione hanno esigenze di consistency diverse. La skill architetturale è sapere quale manopola girare per quale operazione.

La gerarchia

flowchart TD
    L[Linearizable] --> S[Sequential]
    S --> C[Causal]
    C --> RYW[Read-your-writes]
    C --> MR[Monotonic reads]
    RYW --> E[Eventually consistent]
    MR --> E

I modelli più forti sono in alto. Una freccia da A a B significa “A implica B”: se il tuo sistema è linearizable, è anche causal, monotonic, ed eventually consistent. Scegliere un modello significa scegliere fin dove sei disposto a scendere nel diagramma.

Cosa portarsi via

Tre cose da portare nella prossima lezione.

Primo, “strong” ed “eventual” sono gli estremi di uno spettro reale, non le uniche opzioni. La causal consistency in particolare è sottoutilizzata; cattura la maggior parte di ciò che gli utenti si aspettano, a una frazione del costo di coordinamento.

Secondo, la consistency è per-operazione, non per-sistema. Lo stesso database può essere linearizable per un aggiornamento di saldo ed eventually consistent per un view counter. Trattare la consistency come una singola impostazione globale butta via la maggior parte dello spazio di design.

Terzo, il costo dei modelli più forti si paga in latenza, e la latenza è fissata dalla fisica. Coordinarsi tra continenti richiede tempo. Se il tuo sistema ha bisogno sia di scala globale sia di consistency linearizable, la pagherai in ritardi visibili all’utente, oppure in orologi atomici, oppure in entrambi.

La prossima lezione va sotto la consistency, dentro il tempo stesso: perché gli orologi fisici sono inaffidabili, cosa ci danno invece i Lamport timestamps e i vector clocks, e come Spanner compra consistency linearizable su scala planetaria comprando orologi migliori.

Cerca