設計 Key-Value Store

問題定義

設計一個分散式 Key-Value Store,支援 put(key, value)get(key) 操作,具備高可用性、高擴展性和可調整的一致性保證。

ℹ️經典案例

Amazon DynamoDB、Apache Cassandra、Redis Cluster 都是分散式 Key-Value Store 的實際產品。這題考的是理解 CAP 定理在實務中的取捨。

需求分析

注意事項

⚠️一致性與可用性的取捨無法逃避

分散式 Key-Value Store 的每個設計決策都是 CAP 取捨。寫入要等幾個副本確認?讀取要查幾個副本?副本之間的衝突怎麼解?沒有完美答案,只有根據業務需求做出的合理選擇。

設計流程

1

資料分區

用 Consistent Hashing 把 Key 分配到不同節點

2

資料複製

每個 Key 複製 N 份到順時針方向的 N 個節點

3

一致性控制

設定 Quorum 參數 W(寫確認數)和 R(讀確認數)

4

衝突偵測

用 Vector Clock 追蹤資料的因果關係

5

故障處理

Gossip Protocol 偵測節點狀態,Hinted Handoff 暫存資料

6

反熵修復

Merkle Tree 比對副本差異,背景修復不一致

架構設計

Consistent Hashing 與資料分區

Consistent Hashing 圖解說明:想像一個圓環(0 到 2^32),每個節點(Node A/B/C)被 Hash 到環上的某個位置。每個 Key 也被 Hash 到環上,然後順時針走,遇到的第一個節點就是它的「歸屬節點」。

為什麼比 hash % N 好?假設有 3 個節點,現在加入第 4 個。hash % 3 變成 hash % 4,幾乎所有 Key 的歸屬都會改變,需要大量資料搬移。但在 Consistent Hashing 中,新節點只「接管」它逆時針方向到前一個節點之間的 Key,只有約 1/4 的資料需要搬移。

Virtual Node(虛擬節點):如果只有 3 個節點直接放到環上,資料分佈可能很不均勻(某個節點分到的區間特別大)。解法是每個物理節點映射 100-200 個虛擬節點到環上,讓分佈更均勻。虛擬節點越多分佈越均勻,但查找表的記憶體開銷也越大。

python
# 每個 Key 被分配到 Hash 環上順時針方向的第一個節點
# 資料複製到順時針方向的 N 個不同節點
 
class DistributedKVStore:
    def __init__(self, nodes: list[str], replicas: int = 3):
        self.consistent_hash = ConsistentHash(nodes)
        self.replicas = replicas
 
    def get_responsible_nodes(self, key: str) -> list[str]:
        """取得負責儲存此 Key 的 N 個節點"""
        primary = self.consistent_hash.get_node(key)
        nodes = [primary]
        # 順時針找下 N-1 個不同的節點
        current = primary
        while len(nodes) < self.replicas:
            current = self.consistent_hash.get_next_node(current)
            if current not in nodes:
                nodes.append(current)
        return nodes

Quorum 讀寫(C#)

csharp
public class QuorumKVStore
{
    private readonly int N = 3; // 副本數
    private readonly int W = 2; // 寫入成功需確認數
    private readonly int R = 2; // 讀取需查詢數
 
    // W + R > N → 保證 Strong Consistency
    // W = 1, R = 1 → Eventual Consistency(最快)
 
    public async Task<bool> Put(string key, byte[] value)
    {
        var nodes = GetResponsibleNodes(key); // 取得 N 個節點
        var tasks = nodes.Select(n => n.WriteAsync(key, value));
        var results = await Task.WhenAll(tasks);
        var successCount = results.Count(r => r);
        return successCount >= W; // 至少 W 個寫入成功
    }
 
    public async Task<byte[]?> Get(string key)
    {
        var nodes = GetResponsibleNodes(key);
        var tasks = nodes.Select(n => n.ReadAsync(key));
        var results = await Task.WhenAll(tasks);
        // 至少 R 個讀取成功,取最新版本
        var valid = results.Where(r => r != null).ToList();
        if (valid.Count < R) throw new Exception("Not enough replicas");
        return valid.OrderByDescending(r => r!.Timestamp).First()?.Data;
    }
}

Vector Clock 衝突偵測(TypeScript)

typescript
type VectorClock = Record<string, number>; // { "nodeA": 3, "nodeB": 1 }
 
function merge(vc1: VectorClock, vc2: VectorClock): VectorClock {
  const result = { ...vc1 };
  for (const [node, count] of Object.entries(vc2)) {
    result[node] = Math.max(result[node] ?? 0, count);
  }
  return result;
}
 
function isConflict(vc1: VectorClock, vc2: VectorClock): boolean {
  // 如果互相都有對方沒有的更新 → 衝突(需要客戶端解決)
  let vc1Ahead = false, vc2Ahead = false;
  const allNodes = new Set([...Object.keys(vc1), ...Object.keys(vc2)]);
  for (const node of allNodes) {
    if ((vc1[node] ?? 0) > (vc2[node] ?? 0)) vc1Ahead = true;
    if ((vc2[node] ?? 0) > (vc1[node] ?? 0)) vc2Ahead = true;
  }
  return vc1Ahead && vc2Ahead; // 同時為 true = 衝突
}

架構圖

Client
put/get
Coordinator Node
write (W=2)
Node A (Primary)
anti-entropy sync
Node B (Replica)
anti-entropy sync
Node C (Replica)
Gossip Protocol

架構圖解讀:Client 將 put/get 請求發送到 Coordinator Node(任何節點都可以當 Coordinator)。Coordinator 根據 Consistent Hashing 找到負責的 3 個節點(N=3),寫入時等待至少 W=2 個節點確認,第 3 個節點非同步複製。節點之間透過 anti-entropy sync(Merkle Tree 比對)修復資料不一致。Gossip Protocol 讓每個節點定期交換心跳,偵測哪些節點存活、哪些掛了。

實戰補充

💡Quorum 參數的選擇

核心公式:W + R > N 保證讀取至少命中一個最新副本(因為寫入的節點集合和讀取的節點集合一定有交集)。

| 配置 | W | R | 特性 | 適合場景 | |------|---|---|------|---------| | 最快最不一致 | 1 | 1 | Eventual Consistency | 日誌、瀏覽計數器 | | 讀快寫慢 | N | 1 | Strong,寫入等所有副本 | 讀遠多於寫(如設定資料) | | 寫快讀慢 | 1 | N | Strong,讀取查所有副本 | 寫遠多於讀(如感測器資料) | | 平衡 | 2 | 2 | Strong(N=3 時) | 通用場景(最常見) |

面試常見問題與參考答案

Q:Hinted Handoff 是什麼? A:Node A 負責某個 Key 但掛了,Coordinator 把資料暫時寫到 Node D(Hint:「這筆資料其實是 A 的」)。A 恢復後,D 把暫存的資料交還給 A。好處是寫入不會因為單節點故障而失敗。

Q:Merkle Tree 怎麼用在反熵修復? A:每個節點對自己負責的 Key 建一棵 Merkle Tree(Hash 樹)。兩個副本節點交換 Tree 的根 Hash — 如果相同,資料完全一致;如果不同,逐層往下比對找出不一致的 Key,只同步那些 Key。比逐筆比對高效得多。

Q:為什麼不用 Raft/Paxos 而用 Gossip? A:Raft/Paxos 是 Consensus Protocol,保證 Strong Consistency 但需要 Leader 選舉,適合 CP 系統(如 etcd)。Gossip 是去中心化的,沒有 Leader,最終一致,適合 AP 系統(如 Cassandra/DynamoDB)。本設計選 AP 所以用 Gossip。

理解測驗

🤔 在 N=3 的系統中,W=2, R=2 能保證 Strong Consistency 嗎?

🤔 Vector Clock 的用途是什麼?

🤔 Gossip Protocol 在分散式 KV Store 中的角色是什麼?

重點整理

💡一句話記住

Hash 環分資料、Quorum 控一致、Vector Clock 解衝突、Gossip 偵故障。 記憶口訣:「環控鐘探」— 四個機制各管一件事,缺一不可。

| 概念 | 說明 | |------|------| | Consistent Hashing | 資料分區策略,節點增減時只搬移少量資料 | | Quorum (W/R/N) | 控制一致性等級,W+R>N 保證 Strong Consistency | | Vector Clock | 追蹤因果關係,偵測 concurrent write 衝突 | | Gossip Protocol | 去中心化的節點狀態傳播和故障偵測 | | Hinted Handoff | 節點故障時暫存資料到其他節點,恢復後回傳 |

你可能也想看

設計限流器設計聊天系統

按 ← → 鍵切換課程