Salta al contenuto principale

11 - Eventual Consistency: Strategie, Trade-Off e UX Patterns

Dopo il salvataggio l'utente può vedere il vecchio nome: read your writes con un token di versione, interfaccia ottimistica e conflitti con orologi vettoriali.

Cosa ne pensi di questo articolo?

11 - Eventual Consistency: Strategie, Trade-Off e UX Patterns
Event-Driven ArchitectureFoto:RDNE Stock projectPexels

In Breve

Dopo il salvataggio l'utente può vedere il vecchio nome: read your writes con un token di versione, interfaccia ottimistica e conflitti con orologi vettoriali.

«Ho salvato e vedo ancora il vecchio nome»

L'utente cambia il nome del profilo, preme «Salva» e la pagina gli mostra quello vecchio; ricarica e compare il nuovo. Per l'utente è un bug. Per il sistema è il comportamento previsto: la scrittura è andata al primario, la lettura a una replica che non ha ancora ricevuto la modifica. La consistenza eventuale è una proprietà con cui progetti, non un difetto da nascondere: promette che le repliche, smettendo le scritture, convergono, e non dice niente su quando. Il tuo lavoro è decidere, dato per dato, che cosa l'utente deve vedere nell'intervallo, e scegliere il meccanismo meno costoso che glielo garantisce.

Questo articolo chiude la serie e usa tutto ciò che hai visto. Simulo un primario con due repliche in ritardo per mostrare tre anomalie (lettura vecchia, lettura che torna indietro, perdita di una modifica), poi i rimedi: un token di versione per leggere le proprie scritture, l'interfaccia ottimistica con rollback, e il rilevamento dei conflitti con gli orologi vettoriali. Le definizioni dei modelli vengono da Jepsen, i fatti sui servizi dalle guide ufficiali consultate il 2026-11-12. Gli stessi temi, dal lato del servizio che risponde, sono nell'articolo su CQRS, dove la vista in ritardo nasce dalla proiezione.

I modelli, senza il folklore

Tra «strong» ed «eventual» c'è uno spettro, e le garanzie utili stanno nel mezzo. Jepsen le definisce con precisione, e tre bastano qui. Read your writes: se un processo scrive e poi legge, vede la propria scrittura; Jepsen precisa che si ottiene restando sempre sullo stesso server (è «sticky available»), non cambiando server.

Monotonic reads: una lettura successiva non può osservare uno stato precedente a quello di una lettura già fatta, ma solo per lo stesso processo. Causal consistency: le operazioni legate da causa ed effetto appaiono nello stesso ordine a tutti, mentre quelle indipendenti possono apparire in ordine diverso. Sono garanzie per sessione, e per questo si possono dare senza pagare la consistenza forte su tutto il sistema.

Due luoghi comuni da evitare, verificati sulle guide. S3 non è più «eventual»: la guida dice che fornisce una consistenza forte read-after-write per le richieste PUT e DELETE sugli oggetti, in tutte le regioni. DynamoDB invece offre due opzioni di lettura per tabelle e indici locali, eventualmente consistente (il default) e fortemente consistente; le letture dagli indici globali e dagli stream sono sempre eventualmente consistenti. Chi elenca i servizi per «forte/eventuale» a memoria sbaglia in uno dei due sensi, quindi la scelta si fa sulla pagina della versione che usi.

Tre anomalie in un programma

Il primario assegna un numero di versione a ogni scrittura e lo propaga alle repliche, che lo ricevono con ritardi diversi: R1 dopo 2 secondi, R2 dopo 5. Il client che scrive riceve la versione e può portarla nelle letture successive come token. Se la replica interpellata è indietro rispetto al token, la lettura va al primario. È il meccanismo più semplice che dà read-your-writes senza forzare tutto il traffico sul primario.

// Un primario e due repliche con ritardo di propagazione. Tempo virtuale in secondi.
const clock = { t: 0 };
type Voce = { versione: number; valore: string };

class Replica {
  nome: string;
  ritardo: number;
  private dati: Voce = { versione: 0, valore: 'Mario Rossi' };
  private inArrivo: { quando: number; voce: Voce }[] = [];
  constructor(nome: string, ritardo: number) { this.nome = nome; this.ritardo = ritardo; }
  ricevi(voce: Voce): void { this.inArrivo.push({ quando: clock.t + this.ritardo, voce }); }
  leggi(): Voce {
    for (const m of this.inArrivo) if (m.quando <= clock.t && m.voce.versione > this.dati.versione) this.dati = m.voce;
    return this.dati;
  }
}

const r1 = new Replica('R1', 2), r2 = new Replica('R2', 5);
let primario: Voce = { versione: 0, valore: 'Mario Rossi' };
let letture_primario = 0;
function scrivi(valore: string): number {
  primario = { versione: primario.versione + 1, valore };
  [r1, r2].forEach((r) => r.ricevi(primario));
  return primario.versione;               // il client riceve la versione scritta e la conserva
}
const mostra = (v: Voce) => `v${v.versione} "${v.valore}"`;

function leggiConToken(r: Replica, minVersione: number): Voce {
  const v = r.leggi();
  if (v.versione >= minVersione) return v;
  letture_primario++;                      // la replica e' indietro rispetto a cio' che il client ha scritto
  return primario;
}

// Scrittura a t=0: R1 la ricevera' a t=2, R2 a t=5.
const mia = scrivi('Mario Bianchi');

clock.t = 1;
console.log('t=1 A) da R1, senza token       :', mostra(r1.leggi()), '<- il vecchio nome, subito dopo "Salva"');
console.log('t=1 C) da R1, con token v' + mia + '       :', mostra(leggiConToken(r1, mia)), '| letture al primario:', letture_primario);

clock.t = 3;
const prima = r1.leggi(), dopo = r2.leggi();
console.log('t=3 B) prima R1 poi R2          :', mostra(prima), '|', mostra(dopo), '<- la seconda lettura torna indietro');
console.log('t=3 C) da R2, con token v' + mia + '       :', mostra(leggiConToken(r2, mia)), '| letture al primario:', letture_primario);

clock.t = 6;
console.log('t=6 C) da R2, con token v' + mia + '       :', mostra(leggiConToken(r2, mia)), '| letture al primario:', letture_primario, '(la replica ha raggiunto il token)');

// Output:
// t=1 A) da R1, senza token       : v0 "Mario Rossi" <- il vecchio nome, subito dopo "Salva"
// t=1 C) da R1, con token v1       : v1 "Mario Bianchi" | letture al primario: 1
// t=3 B) prima R1 poi R2          : v1 "Mario Bianchi" | v0 "Mario Rossi" <- la seconda lettura torna indietro
// t=3 C) da R2, con token v1       : v1 "Mario Bianchi" | letture al primario: 2
// t=6 C) da R2, con token v1       : v1 "Mario Bianchi" | letture al primario: 2 (la replica ha raggiunto il token)

A t=1 la lettura da R1 senza token restituisce v0: il vecchio nome subito dopo il salvataggio. Con il token v1, la funzione leggiConToken vede che R1 è a v0 e ricade sul primario (prima lettura al primario). A t=3 la riga B mostra l'anomalia peggiore: R1 ha già v1, R2 ancora v0, e un utente che ricarica e finisce su un'altra replica vede il nome tornare indietro.

È una violazione di monotonic reads, e il rimedio è restare sulla stessa replica per la sessione oppure usare il token. A t=6 R2 ha raggiunto v1 e il token non costa più nulla: il carico sul primario si ha solo nell'intervallo di ritardo. Il costo del token è proprio quello: un campo in più da portare in giro (cookie, header, stato del client) e un fallback che, se tutti i token sono recenti, concentra le letture sul primario. Le durate sono secondi virtuali, non misure di un database.

Interfaccia ottimistica: due strati e un rollback che è una sottrazione

L'interfaccia ottimistica mostra subito l'effetto dell'azione e riconcilia dopo con la risposta del server. Si sbaglia quando si modifica direttamente lo stato mostrato: non si sa più cosa annullare se la risposta è un rifiuto o se arriva fuori ordine. Il modello robusto ha due strati, ciò che il server ha confermato e l'elenco delle operazioni in volo, e la vista è la somma.

Un rollback allora non è un'operazione: si toglie l'operazione dallo strato in volo. Il programma lo mostra senza alcun framework; in React 19 l'equivalente è useOptimistic, la cui funzione di aggiornamento, dice la documentazione di React, va chiamata dentro una Action.

type Task = { id: string; fatto: boolean };
type Op = { opId: number; taskId: string; fatto: boolean };

// Due strati: cio' che il server ha confermato e le operazioni ancora in volo. L'interfaccia mostra la loro somma.
let confermato: Task[] = [{ id: 'a', fatto: false }, { id: 'b', fatto: false }];
let inVolo: Op[] = [];
const avvisi: string[] = [];

const vista = (): string =>
  confermato.map((t) => {
    const op = [...inVolo].reverse().find((o) => o.taskId === t.id);   // l'ultima operazione in volo vince sulla vista
    return `${t.id}=${(op ? op.fatto : t.fatto) ? 'x' : '_'}${op ? '*' : ''}`;
  }).join(' ');

function clicca(opId: number, taskId: string): void {
  const attuale = confermato.find((t) => t.id === taskId)!;
  const ultimo = [...inVolo].reverse().find((o) => o.taskId === taskId);
  inVolo = [...inVolo, { opId, taskId, fatto: !(ultimo ? ultimo.fatto : attuale.fatto) }];
  console.log(`clic #${opId} su ${taskId}        -> ${vista()}`);
}
function risposta(opId: number, ok: boolean): void {
  const op = inVolo.find((o) => o.opId === opId)!;
  inVolo = inVolo.filter((o) => o.opId !== opId);                       // l'operazione esce dallo strato "in volo"
  if (ok) confermato = confermato.map((t) => (t.id === op.taskId ? { ...t, fatto: op.fatto } : t));
  else avvisi.push(`#${opId} rifiutata: ${op.taskId} torna com'era`);   // rollback = semplicemente non applicarla
  console.log(`risposta #${opId} ${ok ? 'ok     ' : 'rifiuto'} -> ${vista()}`);
}

clicca(1, 'a');
clicca(2, 'b');
clicca(3, 'a');                // l'utente cambia idea su "a" prima che il server risponda
risposta(2, false);            // il server rifiuta la #2: "b" torna come prima
risposta(1, true);             // la #1 conferma "a=x", ma la #3 e' ancora in volo: l'interfaccia mostra la #3
risposta(3, true);
console.log('avvisi:', avvisi);

// Output:
// clic #1 su a        -> a=x* b=_
// clic #2 su b        -> a=x* b=x*
// clic #3 su a        -> a=_* b=x*
// risposta #2 rifiuto -> a=_* b=_
// risposta #1 ok      -> a=_* b=_
// risposta #3 ok      -> a=_ b=_
// avvisi: [ "#2 rifiutata: b torna com'era" ]

Il segno * indica una modifica non ancora confermata. L'utente clicca su a (spunta), su b (spunta) e di nuovo su a (toglie la spunta) prima di qualunque risposta. Il rifiuto della #2 fa tornare b a _ e scatta un avviso. Poi la #1 viene confermata, ma la vista non cambia: la #3 è ancora in volo e vince, quindi a non si riaccende per un attimo.

Questo è il difetto tipico dell'interfaccia ottimistica scritta ingenuamente, il lampeggio quando le risposte arrivano in un ordine diverso da quello dei clic, ed è la ragione del secondo strato. Per le operazioni critiche (un pagamento) non si usa: l'utente aspetta la conferma.

Quando due persone scrivono insieme

Con più repliche scrivibili, o con più utenti sullo stesso documento, due modifiche possono essere concorrenti: nessuna delle due ha visto l'altra. Un orologio vettoriale tiene un contatore per nodo e permette di dirlo: se tutti i contatori di A sono maggiori o uguali a quelli di B, A segue B; se alcuni sono maggiori e altri minori, sono concorrenti. Il timestamp, invece, ordina sempre, anche quando l'ordine non esiste, ed è la ragione per cui last-write-wins perde dati senza avvisare. Il programma prova entrambe le vie, un merge a tre vie per campo e un contatore che converge.

type Orologio = Record<string, number>;
type Relazione = 'uguale' | 'precede' | 'segue' | 'concorrente';

const incrementa = (o: Orologio, nodo: string): Orologio => ({ ...o, [nodo]: (o[nodo] ?? 0) + 1 });
function confronta(a: Orologio, b: Orologio): Relazione {
  let aMaggiore = false, bMaggiore = false;
  for (const n of new Set([...Object.keys(a), ...Object.keys(b)])) {
    if ((a[n] ?? 0) > (b[n] ?? 0)) aMaggiore = true;
    if ((b[n] ?? 0) > (a[n] ?? 0)) bMaggiore = true;
  }
  return aMaggiore && bMaggiore ? 'concorrente' : aMaggiore ? 'segue' : bMaggiore ? 'precede' : 'uguale';
}

type Doc = { titolo: string; descrizione: string; orologio: Orologio; quando: number };

// Stato iniziale comune, poi due modifiche indipendenti su due nodi.
const base: Doc = { titolo: 'Bozza', descrizione: 'Testo', orologio: { A: 1 }, quando: 10 };
const suA: Doc = { ...base, titolo: 'Piano 2027', orologio: incrementa(base.orologio, 'A'), quando: 20 };
const suB: Doc = { ...base, descrizione: 'Testo rivisto', orologio: incrementa(base.orologio, 'B'), quando: 30 };

console.log('A rispetto a base :', confronta(suA.orologio, base.orologio));
console.log('A rispetto a B    :', confronta(suA.orologio, suB.orologio));

// Last-write-wins: vince il timestamp piu' alto, l'altra modifica sparisce senza avviso.
const lww = suA.quando > suB.quando ? suA : suB;
console.log('LWW               :', lww.titolo, '|', lww.descrizione, '<- il titolo nuovo e\' perso');

// Merge a tre vie per campo: se solo un lato ha cambiato un campo rispetto all'antenato, si accetta; se entrambi, conflitto.
function merge3(antenato: Doc, x: Doc, y: Doc): { doc: Doc; conflitti: string[] } {
  const conflitti: string[] = [];
  const scegli = (campo: 'titolo' | 'descrizione'): string => {
    const cx = x[campo] !== antenato[campo], cy = y[campo] !== antenato[campo];
    if (cx && cy && x[campo] !== y[campo]) { conflitti.push(campo); return x[campo]; }
    return cx ? x[campo] : y[campo];
  };
  const doc: Doc = { titolo: scegli('titolo'), descrizione: scegli('descrizione'), orologio: {}, quando: Math.max(x.quando, y.quando) };
  for (const n of new Set([...Object.keys(x.orologio), ...Object.keys(y.orologio)])) doc.orologio[n] = Math.max(x.orologio[n] ?? 0, y.orologio[n] ?? 0);
  return { doc: { ...doc, orologio: incrementa(doc.orologio, 'merge') }, conflitti };
}
const m = merge3(base, suA, suB);
console.log('merge a tre vie   :', m.doc.titolo, '|', m.doc.descrizione, '| conflitti:', m.conflitti);

const suB2: Doc = { ...base, titolo: 'Programma', orologio: incrementa(base.orologio, 'B'), quando: 30 };
console.log('stesso campo      :', JSON.stringify(merge3(base, suA, suB2).conflitti), '<- va chiesto all\'utente');

// Contatore che converge: ogni nodo conta i propri incrementi, il merge prende il massimo per nodo.
type GCounter = Record<string, number>;
const mergeG = (a: GCounter, b: GCounter): GCounter => {
  const r = { ...a };
  for (const [n, v] of Object.entries(b)) r[n] = Math.max(r[n] ?? 0, v);
  return r;
};
const valore = (c: GCounter) => Object.values(c).reduce((s, v) => s + v, 0);
const nodoA: GCounter = { A: 3 }, nodoB: GCounter = { B: 2 };           // 3 like su A, 2 su B, indipendenti
console.log('somma dei merge   :', valore(mergeG(nodoA, nodoB)), '| in ordine inverso:', valore(mergeG(nodoB, nodoA)), '| ripetuto:', valore(mergeG(mergeG(nodoA, nodoB), nodoB)));
console.log('"ultimo valore"   :', Math.max(valore(nodoA), valore(nodoB)), '<- perde 2 like');

// Output:
// A rispetto a base : segue
// A rispetto a B    : concorrente
// LWW               : Bozza | Testo rivisto <- il titolo nuovo e' perso
// merge a tre vie   : Piano 2027 | Testo rivisto | conflitti: []
// stesso campo      : ["titolo"] <- va chiesto all'utente
// somma dei merge   : 5 | in ordine inverso: 5 | ripetuto: 5
// "ultimo valore"   : 3 <- perde 2 like

Le prime due righe sono la base: A segue la base, ma A e B sono concorrente. Il last-write-wins sceglie B (timestamp 30 contro 20) e il titolo «Piano 2027» sparisce: quella modifica non è mai stata in conflitto con B, sono campi diversi. Il merge a tre vie confronta ciascun campo con l'antenato comune e li tiene entrambi, senza conflitti. Quando entrambi toccano lo stesso campo (suB2 cambia il titolo) il conflitto va all'utente: nessun algoritmo sa quale titolo sia giusto.

Il contatore a sola crescita (G-Counter) mostra una terza via: se ogni nodo conta i propri incrementi e il merge prende il massimo per nodo, il risultato è 5 in qualunque ordine e anche ripetendo il merge, mentre «tieni il valore più alto» perderebbe due like. Le strutture con questa proprietà si chiamano CRDT. Non sono la risposta a tutto: funzionano per tipi di dato scelti.

Che cosa dire all'utente

I pattern d'interfaccia più usati per gestire l'intervallo sono questi:

  • un indicatore di aggiornamento («aggiornato 2 minuti fa»), per i dati che possono essere vecchi;
  • uno stato di salvataggio visibile, per le operazioni in volo;
  • una conferma dopo la risposta del server, non prima, per le operazioni che contano;
  • un ritentativo silenzioso per gli errori transitori, con messaggio solo se tutti falliscono;
  • la cancellazione mostrata subito ma eseguita dopo la conferma, per ciò che non si ripristina.

Sono cinque risposte a una stessa domanda: l'utente sa in che stato si trova il suo dato? La stessa idea esiste a livello HTTP: l'RFC 5861 definisce le estensioni stale-if-error e stale-while-revalidate di Cache-Control, che permettono a una cache di servire una risposta scaduta mentre la rinnova. L'indicatore di aggiornamento è la parte che tocca a te: se servi dati vecchi, dillo.

Dati diversi, garanzie diverse

Non tutto il sistema ha bisogno della stessa garanzia, e conviene scrivere l'elenco. Un saldo, una disponibilità di posti, un prezzo al momento dell'acquisto richiedono letture dal dato autorevole, o almeno un token di versione. Un profilo, un feed, un contatore di apprezzamenti, una statistica tollerano un ritardo di secondi, purché l'utente non veda tornare indietro quello che ha appena scritto. La decisione si prende con una domanda sola: qual è il danno se questo utente vede ora un dato vecchio di qualche secondo? Se il danno è un'irritazione, basta il read-your-writes sulle sue scritture; se è un addebito sbagliato, serve la lettura autorevole.

Che cosa sai fare adesso

Sai nominare le garanzie intermedie (read your writes, monotonic reads, causale) e sai quale costo ha ciascuna. Sai portare un token di versione nelle letture, costruire un'interfaccia ottimistica con rollback senza lampeggi e distinguere un conflitto vero da due modifiche indipendenti.

Con questo articolo la serie ha percorso gli eventi di dominio, l'event sourcing, il CQRS e la sua combinazione con gli eventi, la saga, i servizi di messaggistica (EventBridge e SQS e SNS), la dead letter queue, l'idempotenza e l'outbox. L'ordine di lettura sensato per chi parte da zero è quello della serie: ogni pezzo presuppone i precedenti.

Fonti

I tre programmi (repliche.ts, ottimistica.ts, conflitti.ts) sono stati eseguiti con Node.js v24.11.0; le uscite sono reali per i modelli, e i tempi sono secondi virtuali, non misure. Il comportamento di useOptimistic in React 19 è riportato dalla documentazione di React e non è stato eseguito qui.

Prova Questi Strumenti

Strumenti gratuiti correlati a questo argomento.

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti