案例:分散式鍵值儲存
Dynamo 式的鍵值儲存:一致性雜湊決定資料放在哪、Quorum 決定讀寫要幾台確認、gossip 傳遞誰還活著、向量時鐘處理衝突,叢集設定則由 Raft 管理。前面好幾個主題在這裡組成一個完整的系統。
Dynamo 式的鍵值儲存沒有主節點:任何一台都能協調請求,雜湊環決定每個鍵放在哪 N 台,Quorum 決定讀寫要幾台回覆才算數。只有「叢集裡有誰」這種必須全體同意的事,才交給 Raft。選一個流程一步步看;點方塊進到元件的主題。
type Clock = Record<string, number>;type Version = { value: string; clock: Clock }; function position(name: string): number { let h = 0x811c9dc5; for (const b of new TextEncoder().encode(name)) h = Math.imul(h ^ b, 0x01000193); h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); return (h ^ (h >>> 16)) >>> 0;} function descends(a: Clock, b: Clock): boolean { return Object.keys(b).every((n) => (a[n] ?? 0) >= b[n]);} function addVersion(list: Version[], v: Version): Version[] { if (list.some((x) => descends(x.clock, v.clock))) return list; return [...list.filter((x) => !descends(v.clock, x.clock)), v];} class KvStore { ring: string[]; alive: Set<string>; data = new Map<string, Map<string, Version[]>>(); hints = new Map<string, Array<[string, string, Version]>>(); // holder -> [target, key, version] counters = new Map<string, Map<string, number>>(); // key -> coordinator -> last count constructor(nodes: string[], private n: number, private w: number, private r: number, private sloppy: boolean) { this.ring = [...nodes].sort((a, b) => position(a) - position(b) || (a < b ? -1 : 1)); this.alive = new Set(nodes); for (const node of nodes) { this.data.set(node, new Map()); this.hints.set(node, []); } } walk(key: string): string[] { const h = position(key); let start = this.ring.findIndex((node) => position(node) >= h); if (start === -1) start = 0; return this.ring.map((_, i) => this.ring[(start + i) % this.ring.length]); } store(node: string, key: string, v: Version): void { const table = this.data.get(node)!; table.set(key, addVersion(table.get(key) ?? [], v)); } put(key: string, value: string, context: Clock = {}, via?: string): boolean { const order = this.walk(key); const targets: Array<[string, string | null]> = []; // [node, hint for] const standIns = order.slice(this.n).filter((x) => this.alive.has(x)); for (const node of order.slice(0, this.n)) { if (this.alive.has(node)) targets.push([node, null]); else if (this.sloppy && standIns.length) targets.push([standIns.shift()!, node]); } if (targets.length < this.w) return false; const coord = via && targets.some((t) => t[0] === via) ? via : targets[0][0]; const counters = this.counters.get(key) ?? new Map<string, number>(); const observed = Math.max(0, ...(this.data.get(coord)!.get(key) ?? []).map(v => v.clock[coord] ?? 0)); const count = Math.max(context[coord] ?? 0, counters.get(coord) ?? 0, observed) + 1; counters.set(coord, count); this.counters.set(key, counters); const clock = { ...context, [coord]: count }; for (const [node, hintFor] of targets) { this.store(node, key, { value, clock }); if (hintFor) this.hints.get(node)!.push([hintFor, key, { value, clock }]); } return true; } get(key: string, order: string[]): Version[] | null { const up = this.walk(key).filter((x) => this.alive.has(x)); const asked = this.sloppy ? up.slice(0, this.n) : this.walk(key).slice(0, this.n).filter((x) => this.alive.has(x)); asked.sort((a, b) => order.indexOf(a) - order.indexOf(b)); const answered = asked.slice(0, this.r); if (answered.length < this.r) return null; let versions: Version[] = []; for (const node of answered) for (const v of this.data.get(node)!.get(key) ?? []) versions = addVersion(versions, v); for (const node of answered) for (const v of versions) this.store(node, key, v); return versions; } handoff(target: string): number { if (!this.alive.has(target)) return 0; let delivered = 0; for (const [holder, list] of this.hints) { if (!this.alive.has(holder)) continue; const keep = list.filter(([t, key, v]) => { if (t !== target) return true; this.store(target, key, v); delivered += 1; return false; }); this.hints.set(holder, keep); } return delivered; }}① W、R 與節點故障
W + R = 4 > N = 3:每次讀取都和最新的寫入有交集,所以沒有任何一次讀到舊值。代價是可用性:有節點掛掉時,9.4% 的寫入和 7.6% 的讀取湊不到足夠的副本而失敗。
固定亂數種子:6 台節點、N = 3、40 個鍵上 2,000 次操作,讀寫各半;節點不時故障(最多同時兩台),至少停 60 個時間單位,恢復後 40 個時間單位代存節點才交還寫入。
② 同時的寫入變成 siblings
- 購物車寫入 milk{A:1}讀到:“milk”
- 手機透過 A 加 eggs{A:2}讀到:“milk, eggs”
- 筆電透過 B 加 bread(讀的是同一個舊版本){A:1, B:1}讀到:“milk, eggs” 和 “milk, bread”
- 下一次讀取拿到兩個 siblings,合併後寫回{A:3, B:1}讀到:“milk, eggs, bread”
{A:2} 和 {A:1, B:1}:各有對方沒有的計數,誰也不包含誰,系統分不出哪個比較新,只好兩個都留。用戶端把它們合併成「milk, eggs, bread」,時鐘 {A:3, B:1} 同時包含兩者,siblings 就消失了。如果用「最後寫入勝出」,其中一樣東西會直接不見。
③ 新節點會搬走多少資料
6 台變成 7 台時,新節點平均搬走 14.1% 的鍵,接近 1/7 = 14.3%,遠少於「雜湊值取餘數」那種幾乎全部重新分配。但每台只有一個位置時,搬多少完全看新節點落在環上哪裡:試了 24 個可能的新節點,從 0.3% 到 44.6% 都有。實際的系統會給每台很多個虛擬位置來把它拉平。
模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 每節點一個環位置;記憶體內寫入與確認瞬間完成。同一 key/協調者的計數器單調增加,避免空或舊 context 重用版本;其他分量依用戶端 context,未看過的並行版本可能仍留為 siblings。重啟後須持久保存計數器,未模擬這層。
什麼時候用
- 寫入不能停比讀到最新值更重要的時候:購物車、使用者偏好、裝置狀態。
- 資料量和流量都大到要放在很多台機器上,存取幾乎都是「用鍵拿值」,不需要跨鍵的交易或 JOIN。
- 想用 N、W、R 對每種資料調整「多快」和「多一致」:設定檔要 W + R > N,點擊計數可以 W = R = 1。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 找偏好清單 M 是環上的位置數,二分搜尋;程式裡為了簡短用逐一比較 | O(log M) | O(log M) |
| 寫入 送給 N 台、等最快的 W 台 | O(N) | O(N) |
| 讀取與合併 s 是 siblings 數,通常是 1 | O(R·s) | O(R·s) |
| 加入一台 K 個鍵、n 台;最差是單一位置剛好接手一大段 | O(K/n) | O(K) |
空間:O(N·K),每個鍵存 N 份,加上每個版本一個向量時鐘
Big O 實測:n 變大時步數怎麼長
數的是:在 n 個環位置上找鍵的負責節點:二分搜尋的比較次數
| Big O | n = 16 | n = 256 | n = 4,096 | n = 65,536 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|---|
| 環上查找 | O(log n) | 4 | 8 | 12 | 16 | ×4.0 (×4.0) |
每台機器有上百個虛擬位置時環會很大,但查找只是對排序好的位置做二分搜尋:位置從 16 變成 65,536(4,096 倍),比較次數只從 4 次變成 16 次。
和其他做法比
| 寫入成功 | 讀取成功 | 讀到舊值 | |
|---|---|---|---|
| W=1、R=1,嚴格 | 100.0% | 100.0% | 106 (10.7%) |
| W=1、R=1,sloppy | 100.0% | 100.0% | 129 (13.0%) |
| W=2、R=1,嚴格 | 90.6% | 100.0% | 88 (8.8%) |
| W=2、R=1,sloppy | 100.0% | 100.0% | 129 (13.0%) |
| W=2、R=2,嚴格 | 90.6% | 92.4% | 0 (0.0%) |
| W=2、R=2,sloppy | 100.0% | 100.0% | 12 (1.2%) |
| W=3、R=1,嚴格 | 51.9% | 100.0% | 0 (0.0%) |
| W=3、R=1,sloppy | 100.0% | 100.0% | 129 (13.0%) |
| W=1、R=3,嚴格 | 100.0% | 54.5% | 0 (0.0%) |
| W=1、R=3,sloppy | 100.0% | 100.0% | 0 (0.0%) |
同一串 2,000 次操作與同一組節點故障,N = 3。只有嚴格 quorum 加上 W + R > N 時讀取保證不舊;sloppy quorum 換來的可用性,是用偶爾讀到舊值付的。W=3、R=1 讀很快但寫入要每一台都在;W=1、R=3 反過來。
真實世界裡的它
- Amazon 2007 年的 Dynamo 論文定義了這整套做法;Cassandra、Riak、Voldemort 都是它的後代。
- Cassandra 每次查詢可以指定一致性等級(ONE、QUORUM、ALL),就是在選 R 和 W。
- 叢集成員與設定常交給 etcd 或 ZooKeeper 這類 Raft/ZAB(ZooKeeper Atomic Broadcast)系統管,資料本身則不經過共識。
取捨與陷阱
- sloppy quorum 讓 W + R > N 失效:代存的寫入交還之前,讀取可能拿到舊值(上面量得到)。
- siblings 一定要合併:用戶端忘了處理,siblings 會越積越多;用「最後寫入勝出」則會靜靜地丟資料。
- 每台只有一個環位置時,新節點搬走的資料量差異很大(上面從不到 1% 到四成多都有),要用虛擬節點拉平。
- 向量時鐘會隨著寫過的節點變長;實際系統要修剪,修剪過頭又可能把同時的寫入誤判成先後。