一、为什么需要 LSM 树
传统 B+ 树在随机写入时性能很差。每次写入可能落在磁盘的任意位置,意味着一次写入就要做一次磁盘寻道。机械硬盘的随机写延迟约 10ms,SSD 虽然好一些,但随机写仍然比顺序写慢一个数量级。如果你要每秒写入 10 万条记录,B+ 树的随机 I/O 就成了硬瓶颈。
更根本的问题是 B+ 树的更新方式:原地修改意味着每次写入都要先读出旧页面、修改、再写回。这在写入密集的场景下产生了大量读放大——你明明只想写入,却被迫先读取。
LSM 树的核心洞察:把随机写变成顺序写。写入先进入内存中的有序结构(memtable),满了之后刷到磁盘成为不可变的有序文件(SSTable),后台再按层级合并(compaction)。写入路径上永远是追加操作,没有原地修改,没有随机 I/O。
二、现实类比
一套归档系统:先把笔记写在便签纸上(memtable),定期整理到排好序的文件夹里(SSTable)。隔一段时间,在空闲时把小文件夹合并成大的(compaction)。合并时丢弃重复和已删除的条目,保持文件夹整洁。查找时先翻便签纸,再按文件夹从新到旧查找。
三、核心思想
LSM 树将写入吸收到内存中的有序结构(memtable)。当 memtable 达到大小阈值时,被刷写到磁盘作为不可变的有序段(SSTable)。后台 compaction 合并多个有序段以限制文件数量并回收已删除/覆盖的键的空间。读取先检查 memtable,然后检查每个层级的有序段。
删除操作不原地修改,而是写入一个墓碑标记(tombstone)。查找时遇到墓碑就知道该键已被删除。墓碑在 compaction 时才真正清理。
| 属性 | 值 |
|---|---|
| 写放大 | 由于 compaction 为 O(level_count) |
| 读放大 | 最坏情况 O(level_count) |
| 写入吞吐 | 非常高——仅顺序 I/O |
| 空间放大 | compaction 期间临时数据重复 |
3.1 Compaction 策略对比
Compaction 策略直接决定了 LSM 树在写放大、读放大和空间放大之间的取舍。三种主流策略各有侧重:
| 策略 | 同层内键范围 | 核心特点 | 写放大 | 读放大 | 空间放大 | 典型使用者 |
|---|---|---|---|---|---|---|
| Leveled Compaction | 有重叠,层间有序 | 逐层合并,每层维护一个有序 run | 高 | 低 | 低 | LevelDB、RocksDB 默认 |
| Tiered Compaction | 无重叠,每层多个 run | 同层 SSTable 不合并,积攒到阈值再整体合并 | 低 | 高 | 高 | Cassandra、HBase |
| FIFO Compaction | N/A | 按时间丢弃最旧的 SSTable | 最低 | 高 | 取决于保留量 | 时序数据库、RocksDB 选项 |
Leveled Compaction(LevelDB 风格):L0 的 SSTable 之间键范围有重叠,L1 及以上每层维护一个全局有序的 run。当某层大小超过阈值,就选一个 SSTable 和下一层的重叠部分合并。这种方式让每层的数据高度有序,读操作最多检查每层一个 SSTable,读放大最低。但数据被反复搬移——同一个键可能参与多次合并——写放大最高。假设 7 层、每层大小 10 倍增长,一条数据最多被重写 7 次。
Tiered Compaction(Cassandra 风格):每层的 SSTable 之间键范围不重叠(类似”层内分区”),积攒到一定数量后再整体合并到下一层。合并频率低,写放大小,但同一层的多个 run 都可能包含目标键,读放大显著增加。而且合并前同一键的新旧版本分散在不同 SSTable 中,空间放大也更高——最坏情况下磁盘上可能同时存在 2 倍的实际数据量。
FIFO Compaction:最简单的策略——按写入时间排序,最旧的 SSTable 直接丢弃。不做键范围合并,写放大最低。但代价是只能保证”最近写入的数据”可查,更早的数据可能已被丢弃。适合时序数据、日志等天然按时间访问的场景。
三种策略的本质取舍可以归结为一个”不可能三角”:写放大、读放大、空间放大三者无法同时最小化。Leveled 牺牲写放大换取低读放大和低空间放大;Tiered 牺牲读放大和空间放大换取低写放大;FIFO 牺牲读放大和数据完整性换取极低的写放大。实际系统中,RocksDB 允许在不同 column family 上使用不同策略,Cassandra 也在 4.0 引入了增量 compaction 来缓解 Tiered 的空间放大问题。
四、变体与对比
| 模式 | 关系 | 区别 |
|---|---|---|
| B+ 树(B+ Tree) | 读优化的索引 | B+ 树原地修改读快写慢,LSM 追加写快读慢 |
| 跳表(Skip List) | 跳表充当 memtable 的内存有序缓冲区 | 跳表是内存结构,LSM 是完整的存储引擎 |
| 布隆过滤器(Bloom Filter) | 每个 SSTable 上的布隆过滤器避免不必要的磁盘读取 | 布隆过滤器是 LSM 的加速组件 |
| 预写日志(WAL) | WAL 确保 memtable 写入在刷盘前幸存崩溃 | WAL 是持久化保障,LSM 是存储模型 |
| 墓碑(Tombstone) | LSM 树使用墓碑标记删除 | 墓碑是延迟删除策略,compaction 时清理 |
| 合并迭代器(Merge Iterator) | compaction 使用合并迭代器合并多个有序 SSTable | 合并迭代器是 compaction 的核心算法 |
五、多语言实现
5.1 Go 实现
package lsm
import "sort"
// KVEntry 键值条目,Value 为 nil 表示墓碑(已删除)type KVEntry struct { Key string Value *string // nil = tombstone Seq int // 序列号,越大越新}
// Memtable 内存有序表,写入的第一站type Memtable struct { entries map[string]KVEntry size int}
func NewMemtable() *Memtable { return &Memtable{entries: make(map[string]KVEntry)}}
func (m *Memtable) Put(key, value string, seq int) { m.entries[key] = KVEntry{Key: key, Value: &value, Seq: seq} m.size++}
func (m *Memtable) Delete(key string, seq int) { m.entries[key] = KVEntry{Key: key, Value: nil, Seq: seq} m.size++}
// Flush 将 memtable 刷写为有序段func (m *Memtable) Flush() []KVEntry { result := make([]KVEntry, 0, len(m.entries)) for _, e := range m.entries { result = append(result, e) } sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key }) m.entries = make(map[string]KVEntry) m.size = 0 return result}
// LSMTree 简化的 LSM 树实现type LSMTree struct { memtable *Memtable runs [][]KVEntry // L0 有序段,新在前 seq int flushThreshold int maxRuns int}
func NewLSMTree(flushThreshold, maxRuns int) *LSMTree { return &LSMTree{ memtable: NewMemtable(), flushThreshold: flushThreshold, maxRuns: maxRuns, }}
func (t *LSMTree) Put(key, value string) { t.memtable.Put(key, value, t.seq) t.seq++ if t.memtable.size >= t.flushThreshold { t.flushMemtable() }}
func (t *LSMTree) Get(key string) (string, bool) { // 先查 memtable if e, ok := t.memtable.entries[key]; ok { if e.Value == nil { return "", false // 墓碑 } return *e.Value, true } // 再查有序段(从新到旧) for _, run := range t.runs { if e := binarySearch(run, key); e != nil { if e.Value == nil { return "", false } return *e.Value, true } } return "", false}
func (t *LSMTree) flushMemtable() { run := t.memtable.Flush() t.runs = append([][]KVEntry{run}, t.runs...) if len(t.runs) > t.maxRuns { t.compact() }}
func (t *LSMTree) compact() { merged := make(map[string]KVEntry) // 从旧到新遍历,新值覆盖旧值 for i := len(t.runs) - 1; i >= 0; i-- { for _, e := range t.runs[i] { merged[e.Key] = e } } // 过滤墓碑并排序 result := make([]KVEntry, 0) for _, e := range merged { if e.Value != nil { result = append(result, e) } } sort.Slice(result, func(i, j int) bool { return result[i].Key < result[j].Key }) if len(result) > 0 { t.runs = [][]KVEntry{result} } else { t.runs = nil }}5.2 TypeScript 实现
// 键值条目,value 为 null 表示墓碑interface KVEntry { key: string; value: string | null; // null = tombstone seq: number;}
// 简化的 LSM 树class LSMTree { private memtable = new Map<string, KVEntry>(); private runs: KVEntry[][] = []; // L0 有序段 private seq = 0;
constructor( private flushThreshold = 4, private maxRuns = 4, ) {}
put(key: string, value: string): void { this.memtable.set(key, { key, value, seq: this.seq++ }); if (this.memtable.size >= this.flushThreshold) { this.flushMemtable(); } }
get(key: string): string | undefined { // 先查 memtable const memEntry = this.memtable.get(key); if (memEntry) return memEntry.value ?? undefined; // 再查有序段 for (const run of this.runs) { const entry = this.binarySearch(run, key); if (entry) return entry.value ?? undefined; } return undefined; }
private flushMemtable(): void { const sorted = [...this.memtable.values()].sort( (a, b) => a.key.localeCompare(b.key), ); this.runs.unshift(sorted); // 新段在前 this.memtable.clear(); if (this.runs.length > this.maxRuns) this.compact(); }
private compact(): void { const merged = new Map<string, KVEntry>(); // 从旧到新,新值覆盖旧值 for (let i = this.runs.length - 1; i >= 0; i--) { for (const entry of this.runs[i]) { merged.set(entry.key, entry); } } const compacted = [...merged.values()] .filter((e) => e.value !== null) // 移除墓碑 .sort((a, b) => a.key.localeCompare(b.key)); this.runs = compacted.length > 0 ? [compacted] : []; }}关键设计决策:读取时先查 memtable(最新数据),再按层级从新到旧查找 SSTable。compaction 时从旧到新遍历,新值自然覆盖旧值。墓碑在 compaction 时过滤掉,释放空间。
六、生产验证
- LevelDB — db_impl.cc#L1241-L1368 中
DBImpl::Write是核心写入路径:将写入批量分组,追加到 WAL,插入 memtable。当 memtable 超过write_buffer_size时,MakeRoomForWrite触发刷写,当前 memtable 变为不可变并创建新的。 - RocksDB — memtable.cc#L458-L534 中
MemTable::Add将带序列号和类型的键值对插入基于跳表的 memtable。RocksDB 扩展了 LevelDB 的设计,支持并发 memtable 写入、column family 和可插拔 memtable 实现。 - Apache Cassandra — 基于 LSM 的分布式 NoSQL 数据库,跨节点分片存储 SSTable,支持多数据中心复制。
七、小结
何时使用:
- 写密集工作负载——日志记录、时序数据、事件流
- 键值存储——LevelDB、RocksDB、Cassandra、HBase
- 嵌入式数据库——空间高效、实现简单
- SSD 优化存储——顺序写入最大化 SSD 寿命
何时不用:
- 读密集工作负载——读取可能需要检查多个层级,快速读取请用 B+ 树
- 小数据集——LSM 的开销(compaction、多文件)对于能放入 B+ 树的数据不值得
- 有严格延迟要求的范围扫描——compaction 可能导致延迟尖峰
- 频繁更新 + 点读——对同一键的重复更新在 compaction 期间产生写放大
八、参考资料
- LevelDB 源码 - Google 出品的 LSM 树参考实现
- RocksDB 源码 - Facebook 基于 LevelDB 的高性能扩展
- The Log-Structured Merge-Tree - O’Neil et al., 1996, LSM 树原始论文
- Apache Cassandra 架构 - 分布式 LSM 存储引擎
- BadgerDB - Go 原生的 LSM 键值存储,支持值分离
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






