Ostateczna spójność: strategie, kompromisy i wzorce UX
Użytkownik aktualizuje swój profil, klika „Zapisz” i nadal widzi swoją starą nazwę. Załaduj ponownie stronę: teraz widzisz nową nazwę. Pośrodku stał system sterowany zdarzeniami propagowanie aktualizacji pomiędzy usługami. To jestostateczna spójność w akcji — a błędną odpowiedzią jest ta, którą użytkownik uważa, że system ma błąd. Właściwą odpowiedzią, odpowiedzią architekta, jest zaprojektowanie systemu w taki sposób to okno niespójności jest niewidoczne lub akceptowalne.
Możliwa spójność nie jest kompromisem, który należy ukrywać: jest to wybór architektoniczny świadomi realnych korzyści (dostępność, skalowalność, odporność) i prawdziwych wyzwań (złożoność, testowanie, UX). Zrozumienie wzorców spójności i strategii zarządzania Tymczasowe niespójności są niezbędne do budowania działających systemów rozproszonych naprawdę w produkcji.
Czego się nauczysz
- Twierdzenie CAP i BAZA vs ACID: ramy teoretyczne
- Wzorce spójności: mocne lub ostateczne
- Read-Your-Writes: strategia polegająca na niewyświetlaniu starych danych natychmiast po zapisie
- Optymistyczny interfejs użytkownika: aktualizuje interfejs, zanim serwer odpowie
- Wykrywanie konfliktów i uzgadnianie jednocześnie modyfikowanych danych
- Wersjonowanie za pomocą zegarów wektorowych w celu wykrycia rozbieżności
- Jak zakomunikować użytkownikowi możliwą spójność, nie dezorientując go
Twierdzenie CAP i BAZA: Ramy teoretyczne
Il Twierdzenie CAP-a stwierdza, że w systemie rozproszonym z partycjami network (P nieuniknione w produkcji), możesz mieć jedno i drugie Cwytrwałość (wszystkie węzły widzą te same dane) o Adostępność (system zawsze odpowiada), ale nie oba jednocześnie podczas partycji.
| Nieruchomość | ACID (relacyjny DB) | PODSTAWOWY (systemy rozproszone) |
|---|---|---|
| Konsystencja | Silne: przy każdym odczycie widziany jest ostatni zapis | Ewentualne: węzły zbiegają się w czasie |
| Dostępność | Brak gwarancji podczas partycji | Wysoki: System zawsze odpowiada |
| Zostawać | Atomowy: wszystko albo nic | Stan miękki: stan może się zmieniać bez wprowadzania danych |
| Przykłady | PostgreSQL, MySQL, Oracle | DynamoDB, Cassandra, CouchDB |
Modele spójności: widmo
Pomiędzy silną spójnością a ostateczną spójnością istnieje wiele modeli pośrednich. Znajomość ich pozwala wybrać odpowiedni kompromis dla każdego przypadku użycia:
// Spettro dei modelli di consistenza (dal piu forte al piu debole)
// 1. STRONG (Linearizable) Consistency
// Ogni operazione sembra atomica e globalmente ordinata
// Esempio: PostgreSQL con una singola istanza
// Trade-off: latenza alta, disponibilita ridotta
// 2. SEQUENTIAL Consistency
// Le operazioni appaiono nell'ordine in cui vengono eseguite
// ma non necessariamente in tempo reale
// Esempio: spanner.google.com (TrueTime)
// 3. CAUSAL Consistency
// Le operazioni causalmente correlate sono viste nell'ordine corretto
// Le operazioni non correlate possono essere viste in ordine diverso
// Esempio: MongoDB con causally consistent sessions
// 4. READ-YOUR-WRITES (Session Consistency)
// Un client vede sempre le sue ultime scritture
// Altri client potrebbero vedere dati vecchi
// Esempio: DynamoDB con sticky sessions
// 5. MONOTONIC READ Consistency
// Una volta letto un valore, non vedrai mai un valore piu vecchio
// Non garantisce di vedere le proprie ultime scritture
// 6. EVENTUAL Consistency
// I nodi convergono allo stesso stato "eventualmente"
// Nessuna garanzia su quando o nell'ordine delle operazioni
// Esempio: DNS, DynamoDB default, AWS S3
Czytaj swoje zapisy: nie pokazuj starych danych natychmiast po napisaniu
Najczęstszy problem z ostateczną spójnością produkcji: użytkownik aktualizuje coś, zostaje przekierowany do strony szczegółów i widzi ponownie starą wartość, ponieważ replika nie rozpropagowała jeszcze zmiany. Spójność w czytaniu i pisaniu gwarantuje, że klient zobaczy zawsze najnowsze teksty.
// Strategia 1: Sticky routing — leggi sempre dallo stesso nodo
// Il client viene indirizzato sempre alla replica primaria per
// un periodo dopo la scrittura (es. 1-5 secondi)
// Implementazione con un token di versione
interface WriteResult {
entityId: string;
version: number; // numero di versione dopo la scrittura
timestamp: number; // timestamp della scrittura
}
// Il client salva il WriteResult e lo invia nelle successive letture
interface ReadRequest {
entityId: string;
minVersion?: number; // "voglio almeno questa versione"
}
// Il server: attendi che la replica raggiunga la versione richiesta
class ConsistentReadService {
async readWithVersion(
entityId: string,
minVersion?: number,
timeoutMs = 5000
): Promise<Entity> {
const startTime = Date.now();
while (true) {
const entity = await this.replica.findById(entityId);
if (!minVersion || entity.version >= minVersion) {
return entity;
}
if (Date.now() - startTime > timeoutMs) {
// Timeout: leggi dal primario come fallback
return await this.primary.findById(entityId);
}
await this.sleep(50); // Breve attesa prima del retry
}
}
private sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
}
// Strategia 2: Redirect al primario per N secondi dopo scrittura
// Il CDN/LB indirizza le letture del client alla primary replica
// per 2-3 secondi dopo una scrittura
// Strategia 3: Cache invalidation
// Dopo la scrittura, invalida la cache del client
// La prossima lettura va direttamente alla primary
Optymistyczny interfejs użytkownika: najpierw zaktualizuj, później uzgodnij
L'Optymistyczny interfejs użytkownika oraz podstawowy wzorzec UX dla systemów do ostatecznej spójności: natychmiast zaktualizuj interfejs (zakładając że zapis się powiedzie), a następnie uzgadnia z serwerem. Sprawia, że system postrzegany jako natychmiastowy, nawet przy dużych opóźnieniach sieci.
// Optimistic UI con React e reconciliazione
import { useState, useOptimistic } from 'react';
interface Todo {
id: string;
text: string;
completed: boolean;
synced: boolean; // false = in attesa di conferma server
}
function TodoList({ initialTodos }: { initialTodos: Todo[] }) {
const [todos, setTodos] = useState(initialTodos);
// useOptimistic: hook React 19 per Optimistic UI
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
(state: Todo[], newTodo: Todo) => [...state, newTodo]
);
async function handleToggle(todoId: string): Promise<void> {
const todo = todos.find((t) => t.id === todoId);
if (!todo) return;
const optimisticUpdate = { ...todo, completed: !todo.completed, synced: false };
// 1. Aggiorna UI immediatamente (ottimistico)
addOptimisticTodo(optimisticUpdate);
try {
// 2. Invia al server
const confirmed = await api.toggleTodo(todoId);
// 3. Sostituisci con il dato confermato dal server
setTodos((prev) =>
prev.map((t) => (t.id === todoId ? { ...confirmed, synced: true } : t))
);
} catch (error) {
// 4. ROLLBACK: ripristina lo stato originale se la scrittura fallisce
setTodos((prev) => prev.map((t) => (t.id === todoId ? todo : t)));
// Mostra feedback all'utente
showErrorToast('Impossibile aggiornare il task. Riprova.');
}
}
return (
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className={todo.synced ? '' : 'pending'}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => handleToggle(todo.id)}
/>
{todo.text}
{!todo.synced && <span className="sync-indicator">Salvataggio...</span>}
</li>
))}
</ul>
);
}
Wykrywanie konfliktów za pomocą zegarów wektorowych
Gdy dwóch użytkowników jednocześnie modyfikuje te same dane w systemach w przypadku replikacji wielokrotnej powstaje konflikt. TO Zegary wektorowe pozwalają automatycznie wykryć rozbieżności i podjąć decyzję, co zrobić automatyczne scalanie lub przedstawianie konfliktu użytkownikowi.
// Vector Clock: implementazione base in TypeScript
type VectorClock = Record<string, number>;
function increment(clock: VectorClock, nodeId: string): VectorClock {
return { ...clock, [nodeId]: (clock[nodeId] ?? 0) + 1 };
}
function merge(a: VectorClock, b: VectorClock): VectorClock {
const result: VectorClock = { ...a };
for (const [node, time] of Object.entries(b)) {
result[node] = Math.max(result[node] ?? 0, time);
}
return result;
}
type CompareResult = 'before' | 'after' | 'concurrent' | 'equal';
function compare(a: VectorClock, b: VectorClock): CompareResult {
const allNodes = new Set([...Object.keys(a), ...Object.keys(b)]);
let aGreater = false;
let bGreater = false;
for (const node of allNodes) {
const aTime = a[node] ?? 0;
const bTime = b[node] ?? 0;
if (aTime > bTime) aGreater = true;
if (bTime > aTime) bGreater = true;
}
if (aGreater && bGreater) return 'concurrent'; // conflitto
if (aGreater) return 'after';
if (bGreater) return 'before';
return 'equal';
}
// Utilizzo per rilevare conflitti di scrittura concorrente
interface Document {
id: string;
content: string;
vectorClock: VectorClock;
lastModifiedBy: string;
}
async function updateDocument(
docId: string,
newContent: string,
clientClock: VectorClock,
nodeId: string
): Promise<{ success: boolean; conflict?: { local: Document; remote: Document } }> {
const current = await db.findById(docId);
const comparison = compare(clientClock, current.vectorClock);
if (comparison === 'concurrent') {
// Conflitto! Entrambe le versioni hanno avanzato indipendentemente
return {
success: false,
conflict: {
local: { ...current, content: newContent, vectorClock: clientClock },
remote: current,
},
};
}
if (comparison === 'before') {
// Il client ha una versione stale: rifiuta e richiedi re-fetch
throw new Error('Stale write: fetch the latest version first');
}
// OK: scrivi con clock aggiornato
const newClock = increment(merge(clientClock, current.vectorClock), nodeId);
await db.update(docId, newContent, newClock, nodeId);
return { success: true };
}
Strategie pojednania
Kiedy wykryjesz konflikt, masz do dyspozycji kilka strategii pojednania, każdy odpowiedni dla różnych typów danych:
// Strategie di reconciliazione dei conflitti
// 1. Last-Write-Wins (LWW)
// Il timestamp piu recente vince. Semplice ma perde dati.
// Usato da: AWS DynamoDB (default), Apache Cassandra
function resolveWithLWW(a: Document, b: Document): Document {
return a.updatedAt > b.updatedAt ? a : b;
}
// 2. Merge automatico per strutture dati CRDT-friendly
// Counter: somma i delta (non i valori assoluti)
interface Counter {
nodeIncrements: Record<string, number>; // per ogni nodo: totale incrementi
}
function mergeCounters(a: Counter, b: Counter): Counter {
const result: Record<string, number> = { ...a.nodeIncrements };
for (const [node, count] of Object.entries(b.nodeIncrements)) {
result[node] = Math.max(result[node] ?? 0, count);
}
return { nodeIncrements: result };
}
function getCounterValue(counter: Counter): number {
return Object.values(counter.nodeIncrements).reduce((sum, v) => sum + v, 0);
}
// 3. Three-way merge (come Git)
// Confronta le due versioni divergenti con il loro antenato comune
function threeWayMerge(
ancestor: string,
versionA: string,
versionB: string
): { merged: string; hasConflicts: boolean } {
// Usa diff3 o similar per testi
// Per strutture dati: merge field-by-field
// Se un campo e stato modificato in A ma non in B: accetta la modifica di A
// Se un campo e stato modificato in entrambi: conflitto da risolvere manualmente
// Implementazione completa dipende dal tipo di dato
return { merged: versionA, hasConflicts: true }; // placeholder
}
// 4. User-driven conflict resolution
// Presenta entrambe le versioni all'utente e lascia scegliere
async function presentConflictToUser(
conflict: { local: Document; remote: Document }
): Promise<Document> {
const resolution = await showConflictDialog({
localVersion: conflict.local,
remoteVersion: conflict.remote,
message: 'Il documento e stato modificato da un altro utente. Quale versione vuoi mantenere?',
});
return resolution.chosen === 'local' ? conflict.local : conflict.remote;
}
Wzorce UX dla ostatecznej spójności
Najbardziej niedocenianą częścią ostatecznej spójności jest komunikacja z użytkownikiem. Użytkownicy nie rozumieją (ani nie powinni rozumieć) modeli spójności, ale postrzegają natychmiast, gdy coś nie działa tak, jak tego oczekują.
Polecane wzorce UX
- Nieaktualne wskaźniki: pokazuje „Zaktualizowano 2 minuty temu” zamiast ukrywać opóźnienie. Użytkownicy rozumieją, że dane mogą nie być najświeższe.
- Wizualny stan oczekujący: użyj pokręteł, szarych wskaźników lub tekstu „Zapisywanie…”, aby pokazać, kiedy operacja jest w toku.
- Potwierdzenie sukcesu: pokazuje toast/baner potwierdzający dopiero po potwierdzeniu serwera, co nie jest optymistyczne dla krytycznych operacji.
- Automatyczna ponowna próba: w przypadku operacji niekrytycznych należy ponowić próbę w trybie cichym. Pokaż błąd tylko wtedy, gdy wszystkie próby nie powiodą się.
- Miękkie usunięcie przed twardym usunięciem: natychmiast pokaż element jako usunięty, ale faktyczne usunięcie wykonaj dopiero po potwierdzeniu przez serwer.
// Pattern: Stale-While-Revalidate per dati non critici
async function useStaleWhileRevalidate<T>(
key: string,
fetcher: () => Promise<T>
): Promise<{ data: T; isStale: boolean }> {
// 1. Servi immediatamente i dati in cache (potenzialmente stale)
const cached = await cache.get<{ data: T; timestamp: number }>(key);
if (cached) {
const isStale = Date.now() - cached.timestamp > STALE_THRESHOLD_MS;
if (isStale) {
// 2. Avvia revalidation in background (non bloccante)
fetcher()
.then((freshData) => {
cache.set(key, { data: freshData, timestamp: Date.now() });
})
.catch(console.error);
}
return { data: cached.data, isStale };
}
// 3. Nessuna cache: fetch bloccante
const freshData = await fetcher();
await cache.set(key, { data: freshData, timestamp: Date.now() });
return { data: freshData, isStale: false };
}
// Utilizzo nel frontend:
// const { data: userProfile, isStale } = await useStaleWhileRevalidate(
// `profile:${userId}`,
// () => api.getUserProfile(userId)
// );
// if (isStale) showBanner('Dati potenzialmente non aggiornati');
Kompromis do rozważenia
Nie wszystkie dane tolerują ostateczną spójność. Ceny produktów, sprzedaż rachunków bieżących, dostępność biletów lotniczych: operacje te wymagają mocna konsystencja. Ewentualna spójność jest odpowiednia dla kanałów społecznościowych i liczników polubień, danych analitycznych, profili użytkowników, zapasów niekrytycznych. Zidentyfikuj wyraźnie określić, które dane wymagają jakiego poziomu spójności w architekturze.
Wnioski: Ostateczna spójność jako świadomy wybór
Ostateczna spójność nie jest słabością systemów rozproszonych: jest wyborem zamierzone, które zapewnia skalowalność, dostępność i odporność systemów ACID nie mogą zaoferować. Kluczem jest zaprojektowanie systemu tak, aby nie występowały niespójności tymczasowe są niewidoczne dla użytkownika (Optimistic UI, Read-Your-Writes) lub wyraźnie zakomunikowane, gdy jest to nieuniknione.
Artykuł ten kończy serię o architekturze sterowanej zdarzeniami. Teraz masz zrozumienie wraz z narzędziami i wzorcami do budowy niezawodnych systemów rozproszonych: od zdarzeń domeny do wzorca skrzynki nadawczej, od DLQ do idempotencji konsumenta, aż po strategie zarządzania dowolną spójnością produkcji.
Architektura sterowana zdarzeniami całej serii
- Podstawy EDA: zdarzenia domeny, polecenia i magistrala komunikatów
- Źródło zdarzeń: stan jako niezmienna sekwencja zdarzeń
- CQRS: Oddzielne czytanie i pisanie
- Wzór Saga: transakcje rozproszone
- AWS EventBridge: bezserwerowa magistrala zdarzeń
- Kolejka martwych listów i odporność
- Idempotencja u konsumentów
- Wzór skrzynki nadawczej z CDC







