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
- 延伸閱讀
- 資料結構 · B-tree
出現在這些架構裡
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 寫入(進 memtable) 最差是剛好觸發一連串合併,要重寫整棵樹 | O(1) | O(n) |
| 每筆資料一生被寫進磁碟幾次 每往下一層合併一次 | O(log n) | O(log n) |
| 讀取:要檢查的檔案 | O(log n) | O(log n) |
| 讀取:有布隆過濾器時真的讀的檔案 1 個找到的檔案,加上少數誤判 | ≈ 1 | O(log n) |
| 範圍掃描 布隆過濾器幫不上忙:每層都得讀 | O(log n + k) | O(log n + k) |
空間:O(n),加上還沒被合併掉的舊版本和墓碑
Big O 實測:n 變大時步數怎麼長
數的是:n 個鍵各寫一次之後:每筆寫入寫進磁碟的筆數、磁碟上的檔案數
| Big O | n = 1,000 | n = 4,000 | n = 16,000 | 成長倍數:實測(理論) | |
|---|---|---|---|---|---|
| 寫入放大 | O(log n) | 5.9 | 6.7 | 7.4 | ×1.3 (×1.4) |
| 讀不存在的鍵要檢查的檔案 | O(log n) | 4 | 4 | 8 | ×2.0 (×1.4) |
檔案數是階梯狀的:每 3 個檔案就合併成下一層的一個,所以 n 變大時它時增時減,但整體跟著層數 log n 往上長。
和其他做法比
| 每筆寫入寫進磁碟幾筆 | 讀存在的鍵:讀幾個檔案/頁 | 讀不存在的鍵 | |
|---|---|---|---|
| LSM,沒有布隆過濾器 | 7.4 | 7.2 | 8.0 |
| LSM,有布隆過濾器 | 7.4 | 1.77 | 0.89 |
| B-tree(每頁 64 個鍵) | 47.3 | 3 | 3 |
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,這裡示範的)寫得少、讀和空間多。