mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2012 字
6 分钟
LSM 树(Log-Structured Merge Tree)
2026-06-13

一、为什么需要 LSM 树#

传统 B+ 树在随机写入时性能很差。每次写入可能落在磁盘的任意位置,意味着一次写入就要做一次磁盘寻道。机械硬盘的随机写延迟约 10ms,SSD 虽然好一些,但随机写仍然比顺序写慢一个数量级。如果你要每秒写入 10 万条记录,B+ 树的随机 I/O 就成了硬瓶颈。

更根本的问题是 B+ 树的更新方式:原地修改意味着每次写入都要先读出旧页面、修改、再写回。这在写入密集的场景下产生了大量读放大——你明明只想写入,却被迫先读取。

LSM 树的核心洞察:把随机写变成顺序写。写入先进入内存中的有序结构(memtable),满了之后刷到磁盘成为不可变的有序文件(SSTable),后台再按层级合并(compaction)。写入路径上永远是追加操作,没有原地修改,没有随机 I/O。

二、现实类比#

一套归档系统:先把笔记写在便签纸上(memtable),定期整理到排好序的文件夹里(SSTable)。隔一段时间,在空闲时把小文件夹合并成大的(compaction)。合并时丢弃重复和已删除的条目,保持文件夹整洁。查找时先翻便签纸,再按文件夹从新到旧查找。

三、核心思想#

LSM 树将写入吸收到内存中的有序结构(memtable)。当 memtable 达到大小阈值时,被刷写到磁盘作为不可变的有序段(SSTable)。后台 compaction 合并多个有序段以限制文件数量并回收已删除/覆盖的键的空间。读取先检查 memtable,然后检查每个层级的有序段。

flowchart TD W["写入 PUT k=v"] --> M["Memtable(内存有序表)"] M -->|"大小超限,刷写"| L0["Level 0(SSTable)"] L0 -->|"文件过多,压缩"| L1["Level 1(合并后)"] L1 --> L2["Level 2 ..."] R["读取 GET k"] --> M M -->|"未命中"| L0 L0 -->|"未命中"| L1 L1 -->|"未命中"| L2

删除操作不原地修改,而是写入一个墓碑标记(tombstone)。查找时遇到墓碑就知道该键已被删除。墓碑在 compaction 时才真正清理。

属性
写放大由于 compaction 为 O(level_count)
读放大最坏情况 O(level_count)
写入吞吐非常高——仅顺序 I/O
空间放大compaction 期间临时数据重复

3.1 Compaction 策略对比#

Compaction 策略直接决定了 LSM 树在写放大、读放大和空间放大之间的取舍。三种主流策略各有侧重:

策略同层内键范围核心特点写放大读放大空间放大典型使用者
Leveled Compaction有重叠,层间有序逐层合并,每层维护一个有序 runLevelDB、RocksDB 默认
Tiered Compaction无重叠,每层多个 run同层 SSTable 不合并,积攒到阈值再整体合并Cassandra、HBase
FIFO CompactionN/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 时过滤掉,释放空间。

六、生产验证#

  • LevelDBdb_impl.cc#L1241-L1368DBImpl::Write 是核心写入路径:将写入批量分组,追加到 WAL,插入 memtable。当 memtable 超过 write_buffer_size 时,MakeRoomForWrite 触发刷写,当前 memtable 变为不可变并创建新的。
  • RocksDBmemtable.cc#L458-L534MemTable::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 期间产生写放大

八、参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

LSM 树(Log-Structured Merge Tree)
https://blog.souloss.com/posts/programming/system-patterns/system-patterns-lsm-tree/
作者
Tsukimi
发布于
2026-06-13
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时