«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
- Jepsen, Read Your Writes - definizione e proprietà «sticky available», consultata il 2026-11-12
- Jepsen, Monotonic Reads - definizione e limite alle letture dello stesso processo, consultata il 2026-11-12; Causal Consistency - ordine delle operazioni causalmente legate
- Amazon S3 User Guide, What is Amazon S3? - consistenza forte read-after-write per PUT e DELETE, consultata il 2026-11-12
- Amazon DynamoDB Developer Guide, Read consistency - letture eventualmente consistenti (default) e fortemente consistenti, consultata il 2026-11-12
- React, useOptimistic - la funzione di aggiornamento va chiamata dentro una Action, consultata il 2026-11-12
- RFC 5861, HTTP Cache-Control Extensions for Stale Content -
stale-if-errorestale-while-revalidate, consultata il 2026-11-12
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.

Commenti
Caricamento commenti...
Accedi per commentare