最終的な一貫性: 戦略、トレードオフ、UX パターン
ユーザーがプロフィールを更新し、[保存] をクリックしても、古い名前が表示されたままになります。 ページをリロードすると、新しい名前が表示されます。真ん中: イベント駆動型システムが立っていた サービス間で更新を伝達します。これは、最終的な整合性 動作中 — そして間違った答えは、システムが持っているとユーザーが思っている答えです バグです。アーキテクトの正しい答えは、次のようなシステムを設計することです。 この不一致の範囲は目に見えないか、許容できるものです。
可能な一貫性は、隠すべき妥協ではなく、アーキテクチャ上の選択です。 実際の利点 (可用性、拡張性、回復力) と実際の課題を認識する (複雑さ、テスト、UX)。一貫性のパターンと管理戦略を理解する 一時的な不整合は、機能する分散システムを構築するために不可欠です 本当に制作中です。
何を学ぶか
- CAP 定理と BASE 対 ACID: 理論的枠組み
- 一貫性パターン: 強いものから最終的なものまで
- Read-Your-Writes: 書き込み直後に古いデータを表示しない戦略
- 楽観的な UI: サーバーが応答する前にインターフェイスを更新します。
- 同時に変更されたデータの競合の検出と調整
- ベクトルクロックによるバージョン管理による相違の検出
- ユーザーを混乱させることなく、可能な一貫性をユーザーに伝える方法
CAP 定理と BASE: 理論的枠組み
Il CAP定理 パーティションを備えた分散システムでは次のように述べています。 ネットワーク (本番環境では P は避けられません)、どちらかを使用できます。 C一貫性 (すべてのノードが同じデータを参照します) o A可用性(システム 常に応答します)。ただし、パーティション中に両方を同時に行うことはできません。
| 財産 | ACID(リレーショナルDB) | BASIC (分散システム) |
|---|---|---|
| 一貫性 | 強力: すべての読み取りで最後の書き込みが確認されます。 | 最終的: ノードは時間の経過とともに収束します |
| 可用性 | 分割中は保証されません | 高: システムは常に応答します |
| 滞在する | アトミック: 全か無か | ソフトステート: 入力なしでも状態を変更できます。 |
| Examples | PostgreSQL、MySQL、Oracle | DynamoDB、Cassandra、CouchDB |
一貫性モデル: スペクトル
強整合性と結果整合性の間には、多くの中間モデルがあります。 それらを理解すると、各ユースケースに適切なトレードオフを選択できるようになります。
// 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
Read-Your-Write: 書き込み直後に古いデータを表示しない
運用環境における結果整合性に関して最も頻繁に発生する問題は、ユーザーです。 何かを更新すると、詳細ページにリダイレクトされ、再度表示されます レプリカが変更をまだ反映していないため、古い値になります。 読み取りと書き込みの一貫性 クライアントが確実に見ることができるようにする 常に最新の文章を。
// 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
楽観的な UI: 最初に更新し、後で調整する
L'楽観的なUI システムの基本的な UX パターン 最終的な整合性を保つため、インターフェイスをすぐに更新します (仮定すると、 書き込みが成功することを確認してから、サーバーと調整します。を作ります ネットワーク遅延が高くても、システムは瞬時に認識されます。
// 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>
);
}
ベクトルクロックによる競合検出
2 人のユーザーがシステム上で同じデータを同時に変更する場合 複数のレプリケーションを使用すると、競合が発生します。ザ ベクトル時計 相違を自動的に検出し、何をすべきかを決定できるようにする 自動的にマージするか、競合をユーザーに提示します。
// 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 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;
}
最終的な一貫性のための UX パターン
最終的な整合性の最も過小評価されている部分は、ユーザーとのコミュニケーションです。 ユーザーは整合性モデルを理解していません (理解すべきではありません) が、次のように認識しています。 何かが期待どおりに機能しない場合はすぐに。
推奨される UX パターン
- 古いインジケーター: 遅延を非表示にする代わりに、「2 分前に更新されました」と表示します。ユーザーは、データが最新ではない可能性があることを理解しています。
- 保留状態のビジュアル: スピナー、グレーのインジケーター、または「保存中...」テキストを使用して、操作の進行中を示します。
- 成功の確認: サーバーの確認後にのみ確認トースト/バナーが表示され、重要な操作については楽観的に表示されません。
- 自動再試行: 重要でない操作の場合は、サイレント再試行を実行します。すべての再試行が失敗した場合にのみエラーを表示します。
- ハード削除の前にソフト削除: アイテムはすぐに削除されたものとして表示されますが、実際の削除はサーバーの確認後にのみ実行されます。
// 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');
考慮すべきトレードオフ
すべてのデータが結果整合性を許容するわけではありません。商品価格、販売状況 当座預金口座の状況、航空券の入手可能性: これらの操作には次のことが必要です。 強い一貫性。結果整合性はソーシャル フィード、カウンターに適しています いいね、分析データ、ユーザー プロファイル、重要ではないインベントリなど。特定する どのデータがアーキテクチャ内でどのレベルの一貫性を必要とするかを明示的に示します。
結論: 意識的な選択としての結果的一貫性
結果整合性は分散システムの弱点ではなく、選択です。 ACID システムのスケーラビリティ、可用性、復元力を可能にする意図的なもの 彼らは提供できません。重要なのは、不整合が生じないようにシステムを設計することです。 一時的にユーザーには表示されない (オプティミスティック UI、Read-Your-Writes)、または やむを得ない場合には明示的に伝えます。
この記事でイベント駆動アーキテクチャ シリーズは終了です。もう理解できましたね 信頼性の高い分散システムを構築するためのツールとパターンを備えています。 ドメイン イベントから送信ボックス パターンまで、DLQ からコンシューマー冪等性まで、 本番環境の一貫性を管理するための戦略まで。







