跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 案例:分散式鍵值儲存

案例:分散式鍵值儲存

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 與節點故障

2
2
副本掛掉時
寫入成功
90.6%
讀取成功
92.4%
讀到舊值
0
交還的代存寫入
0 / 0

W + R = 4 > N = 3:每次讀取都和最新的寫入有交集,所以沒有任何一次讀到舊值。代價是可用性:有節點掛掉時,9.4% 的寫入和 7.6% 的讀取湊不到足夠的副本而失敗。

固定亂數種子:6 台節點、N = 3、40 個鍵上 2,000 次操作,讀寫各半;節點不時故障(最多同時兩台),至少停 60 個時間單位,恢復後 40 個時間單位代存節點才交還寫入。

② 同時的寫入變成 siblings

  1. 購物車寫入 milk{A:1}讀到:“milk”
  2. 手機透過 A 加 eggs{A:2}讀到:“milk, eggs”
  3. 筆電透過 B 加 bread(讀的是同一個舊版本){A:1, B:1}讀到:“milk, eggs” 和 “milk, bread”
  4. 下一次讀取拿到兩個 siblings,合併後寫回{A:3, B:1}讀到:“milk, eggs, bread”

{A:2} 和 {A:1, B:1}:各有對方沒有的計數,誰也不包含誰,系統分不出哪個比較新,只好兩個都留。用戶端把它們合併成「milk, eggs, bread」,時鐘 {A:3, B:1} 同時包含兩者,siblings 就消失了。如果用「最後寫入勝出」,其中一樣東西會直接不見。

③ 新節點會搬走多少資料

平均搬走
14.1%
最少
0.3%
最多
44.6%

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 On = 16n = 256n = 4,096n = 65,536成長倍數:實測(理論)
環上查找O(log n)481216×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,sloppy100.0%100.0%129 (13.0%)
W=2、R=1,嚴格90.6%100.0%88 (8.8%)
W=2、R=1,sloppy100.0%100.0%129 (13.0%)
W=2、R=2,嚴格90.6%92.4%0 (0.0%)
W=2、R=2,sloppy100.0%100.0%12 (1.2%)
W=3、R=1,嚴格51.9%100.0%0 (0.0%)
W=3、R=1,sloppy100.0%100.0%129 (13.0%)
W=1、R=3,嚴格100.0%54.5%0 (0.0%)
W=1、R=3,sloppy100.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% 到四成多都有),要用虛擬節點拉平。
  • 向量時鐘會隨著寫過的節點變長;實際系統要修剪,修剪過頭又可能把同時的寫入誤判成先後。