設計 Key-Value Store
問題定義
設計一個分散式 Key-Value Store,支援 put(key, value) 和 get(key) 操作,具備高可用性、高擴展性和可調整的一致性保證。
ℹ️經典案例
Amazon DynamoDB、Apache Cassandra、Redis Cluster 都是分散式 Key-Value Store 的實際產品。這題考的是理解 CAP 定理在實務中的取捨。
需求分析
- 功能性需求:
put(key, value)、get(key)、delete(key),Key 和 Value 都是 bytes - 非功能性需求:自動擴展、可調一致性(Strong/Eventual)、高可用(即使節點故障)
- 容量估算:Key < 10KB、Value < 10MB、儲存數十 TB 資料
- CAP 選擇:AP 系統(優先可用性),透過 Quorum 機制調整一致性等級
注意事項
⚠️一致性與可用性的取捨無法逃避
分散式 Key-Value Store 的每個設計決策都是 CAP 取捨。寫入要等幾個副本確認?讀取要查幾個副本?副本之間的衝突怎麼解?沒有完美答案,只有根據業務需求做出的合理選擇。
設計流程
資料分區
用 Consistent Hashing 把 Key 分配到不同節點
資料複製
每個 Key 複製 N 份到順時針方向的 N 個節點
一致性控制
設定 Quorum 參數 W(寫確認數)和 R(讀確認數)
衝突偵測
用 Vector Clock 追蹤資料的因果關係
故障處理
Gossip Protocol 偵測節點狀態,Hinted Handoff 暫存資料
反熵修復
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 個虛擬節點到環上,讓分佈更均勻。虛擬節點越多分佈越均勻,但查找表的記憶體開銷也越大。
# 每個 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 nodesQuorum 讀寫(C#)
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)
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(任何節點都可以當 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 | 節點故障時暫存資料到其他節點,恢復後回傳 |