跳到主要內容

系統設計

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

主題 · LSM tree

LSM tree

LSM 是 Log-Structured Merge 的縮寫:寫入先進記憶體、攢滿再整批寫成排序好的檔案,背景再合併:寫入很快,讀取要多查幾個檔案,所以用布隆過濾器跳過不可能的檔案。

memtable(記憶體;滿 4 筆就寫到磁碟)
9:v130:v1
L0
(空)
L1
#4
2:v15:v18:v112:v115:v119:v123:v127:v133:v136:v141:v150:v1
剛寫入/找到布隆過濾器跳過讀了但沒有(誤判)✕ = 墓碑(已刪除)

寫入先進記憶體裡的 memtable;滿 4 筆就整批寫到磁碟成一個排好序的檔案;同一層有 3 個檔案時,就合併成下一層的一個檔案。先寫幾筆,再查一個鍵看看。

磁碟上的檔案
1
寫入放大(至今)
1.71×
上次讀取:讀了幾個檔案
—
上次讀取:布隆跳過
—
亮起來的是這一步執行的程式碼
type Entry = [number, string | null]; // null is a tombstone
function fnv1a(s: string): number {
let h = 0x811c9dc5;
for (const byte of new TextEncoder().encode(s)) h = Math.imul(h ^ byte, 0x01000193);
return h >>> 0;
}
class Bloom {
bits: Uint8Array;
constructor(keys: number[]) {
this.bits = new Uint8Array(Math.max(8, keys.length * 6));
for (const k of keys) for (const p of this.positions(k)) this.bits[p] = 1;
}
positions(key: number): number[] {
const h1 = fnv1a(String(key)), h2 = (fnv1a(key + "#") | 1) >>> 0;
return [h1 % this.bits.length, (h1 + h2) % this.bits.length];
}
mightContain(key: number): boolean {
return this.positions(key).every((p) => this.bits[p] === 1);
}
}
class LSMTree {
memtable = new Map<number, string | null>();
levels: { entries: Entry[]; bloom: Bloom }[][] = []; // newest file first
put(key: number, value: string | null): void {
this.memtable.set(key, value);
if (this.memtable.size >= 4) this.flush();
}
flush(): void {
const entries = [...this.memtable].sort((a, b) => a[0] - b[0]);
this.memtable.clear();
this.addFile(0, entries);
}
addFile(level: number, entries: Entry[]): void {
(this.levels[level] ??= []).unshift({ entries, bloom: new Bloom(entries.map((e) => e[0])) });
if (this.levels[level].length >= 3) {
const merged = mergeRuns(this.levels[level].map((f) => f.entries));
this.levels[level] = [];
this.addFile(level + 1, merged);
}
}
get(key: number): string | null | undefined {
if (this.memtable.has(key)) return this.memtable.get(key);
for (const level of this.levels) {
for (const file of level) {
if (!file.bloom.mightContain(key)) continue;
const i = binarySearch(file.entries, key);
if (i >= 0) return file.entries[i][1];
}
}
return undefined;
}
}
// Runs come newest first; for a key in several, the newest value wins.
function mergeRuns(runs: Entry[][]): Entry[] {
const latest = new Map<number, string | null>();
for (let r = runs.length - 1; r >= 0; r--) {
for (const [k, v] of runs[r]) latest.set(k, v);
}
return [...latest].sort((a, b) => a[0] - b[0]);
}
function binarySearch(entries: Entry[], key: number): number {
let lo = 0, hi = entries.length - 1;
while (lo <= hi) {
const mid = (lo + hi) >> 1;
if (entries[mid][0] === key) return mid;
if (entries[mid][0] < key) lo = mid + 1; else hi = mid - 1;
}
return -1;
}

模型假設與範圍

  • 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
  • 小型 memtable、不可變 SSTable、Bloom filter 與合併;步數表示資料操作。未包含 WAL、fsync、背景 I/O 排程或磁碟效能。

什麼時候用

  • 寫入遠多於讀取:日誌、事件、時間序列、訊息。寫入只進記憶體再整批循序寫到磁碟,不用到處改寫頁面。
  • 儲存裝置對隨機寫很不友善時(SSD 的寫入壽命、雲端磁碟的 IOPS(Input/Output Operations Per Second)限制)。
  • 讀取以單筆查找為主、能接受偶爾多讀一兩個檔案;大量範圍掃描的工作通常 B-tree 比較穩。

和其他主題的關係

被這些用到
SQL 與 NoSQL

出現在這些架構裡

時間與空間複雜度(Big O)

操作平均最差
寫入(進 memtable)
最差是剛好觸發一連串合併,要重寫整棵樹
O(1)O(n)
每筆資料一生被寫進磁碟幾次
每往下一層合併一次
O(log n)O(log n)
讀取:要檢查的檔案O(log n)O(log n)
讀取:有布隆過濾器時真的讀的檔案
1 個找到的檔案,加上少數誤判
≈ 1O(log n)
範圍掃描
布隆過濾器幫不上忙:每層都得讀
O(log n + k)O(log n + k)

空間:O(n),加上還沒被合併掉的舊版本和墓碑

Big O 實測:n 變大時步數怎麼長

數的是:n 個鍵各寫一次之後:每筆寫入寫進磁碟的筆數、磁碟上的檔案數

Big On = 1,000n = 4,000n = 16,000成長倍數:實測(理論)
寫入放大O(log n)5.96.77.4×1.3 (×1.4)
讀不存在的鍵要檢查的檔案O(log n)448×2.0 (×1.4)

檔案數是階梯狀的:每 3 個檔案就合併成下一層的一個,所以 n 變大時它時增時減,但整體跟著層數 log n 往上長。

和其他做法比

每筆寫入寫進磁碟幾筆讀存在的鍵:讀幾個檔案/頁讀不存在的鍵
LSM,沒有布隆過濾器7.47.28.0
LSM,有布隆過濾器7.41.770.89
B-tree(每頁 64 個鍵)47.333

16,000 個隨機鍵各寫一次,再各讀 1,000 次存在與不存在的鍵;每讀一個檔案或一頁算一次磁碟讀取。LSM 每筆資料一生會被重寫約 7.4 次,但都是整批循序寫;B-tree 每次插入都改寫一整頁,平均約 47 筆,而且是隨機寫。布隆過濾器把不存在的鍵要讀的檔案從 8 個降到 0.89 個。

真實世界裡的它

  • RocksDB、LevelDB 是 LSM 的代表實作,很多資料庫把它當底層引擎。
  • Cassandra、ScyllaDB、HBase、Bigtable 的儲存層都是 LSM。
  • MySQL 的 InnoDB 和 PostgreSQL 則是 B-tree:讀取穩定,寫入要改寫頁面。

取捨與陷阱

  • 合併跟不上寫入速度時,檔案越積越多、讀取越來越慢,最後引擎只好暫停寫入等合併(write stall)。
  • 刪除不會馬上省下空間:墓碑要等合併到沒有更舊版本的那一層才能丟掉,大量刪除後空間反而先變大。
  • 布隆過濾器只對單筆查找有用;範圍掃描每一層都得讀。
  • 合併策略是取捨:分層(leveled)讀得少、寫得多;分級(size-tiered,這裡示範的)寫得少、讀和空間多。