Skip to content

第5章:LSM-Tree 深度


元数据卡

维度
难度(进阶)
前置第4章 B+树与存储引擎
关键词LSM-Tree、MemTable、SSTable、Compaction、写放大、读放大、Bloom Filter、RocksDB
代码语言SQL / 伪代码 / C++

你的进度

老陈带你穿过地下室,推开另一扇门——里面叮叮当当,几个工人在流水线上不断把散落的纸片装订成册、合并成厚卷。'这叫 LSM 工坊。不追求每次写入都整整齐齐排进架子,而是先堆在台面上,等空闲了再合并整理。写入快,但读的时候得多翻几本。'

破局 · 溯源

写入的瓶颈——B+树的随机写问题

B+树每插入一行数据,都要定位到正确的叶节点页。如果 Buffer Pool 没命中,就要从磁盘读这个页,修改后再写回去。

这看起来没什么——一次 I/O 而已。但问题在于:

  1. 插入顺序和物理位置无关。按时间戳插入的数据,可能分散在各个索引页。
  2. 页分裂更糟糕。一个满的页分裂成两个半满的页,两个新页的写入位置也是随机的。
  3. 二级索引的灾难。每写一行数据,所有索引都要更新。

对于每秒 10 万次写入的负载,B+树存储引擎的随机 I/O 就变成了瓶颈。

LSM-Tree 的核心理念——把随机写转顺序写

你站在工坊门口,看着工人们把散落在工作台上的纸片随手夹进文件夹就丢到一边,而不是像地下室的 B+树档案员那样一页一页往柜子里插。

'看出来区别了?'老陈问。'B+树每次写入都要找到正确的抽屉位置插进去——随机 I/O。我们这里先把所有东西扔到桌上(MemTable),等桌子堆满了,一起打包搬进仓库。'

LSM-Tree 的思路:不把数据立即写入有结构的 B+树。先把所有写操作追加到一个内存中的有序结构(MemTable),当 MemTable 满了,就一次性刷成磁盘上的不可变文件(SSTable)。

写入请求
 
 
 满了 
 MemTable Immutable MemTable 
 (内存中的跳表/B树) (只读,准备落盘) 
 
 插入 O(log n) 
 查询 O(log n) 
 
 SSTable 文件 (Level 0)
 (排序的、不可变的 
 键值对文件)

三个关键特性:

  1. 所有写入都是顺序追加——先写 WAL(预写日志),再写 MemTable,最后定期刷新成 SSTable。没有随机 I/O。
  2. SSTable 一旦生成就不变了——没有页分裂、没有原地更新。这简化了并发控制和崩溃恢复。
  3. 读取需要合并多个文件——因为数据可能分散在 MemTable、Immutable MemTable 和多个 SSTable 中。这是 LSM-Tree 的主要代价。

MemTable → Immutable MemTable → SSTable 的写入管道

来看一个具体的写入过程:

pseudo
// LSM-Tree 写入流程
function lsm_write(key value):
 // 1. 先写 WAL(崩溃恢复用)
 append_to_wal(key value)

 // 2. 写入内存中的 MemTable
 memtable.insert(key value)

 // 3. 如果 MemTable 满了
 if memtable.size() >= memtable_size_limit:
 // 3a. MemTable 变为不可变,新建一个空的 MemTable
 immutable = memtable
 memtable = new MemTable()

 // 3b. WAL 也切换
 flush_wal()

 // 3c. 后台线程将 Immutable MemTable 刷成 SSTable
 schedule_flush(immutable)

这个管道的设计保证了:

  • 写入从不阻塞(除非 MemTable 刷盘跟不上写入速度)
  • WAL 是纯顺序写入——追加写,没有寻道时间
  • MemTable 是内存操作——纳秒级

当 MemTable 被刷成 SSTable 后,它进入 Level 0。然后通过后台的 Compaction(合并压缩)过程逐渐下沉到更深的层级。

SSTable 格式——翻开打包好的记录册

老陈从流水线上拿起一本刚装订好的册子,封面写着 'SST_0042'。'这就是 SSTable,'他翻开给你看,'打包好的数据册子,一旦合上就不再改动了。'

一个 SSTable 文件内部的组织方式:

 
 Data Block 0 (key range: a-f) 
 [key value] × N 
 
 Data Block 1 (key range: g-m) 
 [key value] × M 
 
 ... 
 
 Filter Block (Bloom Filter) ← 快速判断 key 是否存在
 
 Index Block ← 每个 Data Block 的最小 key + 偏移
 [a → offset_0 g → offset_1 ...]
 
 Footer ← 指向 Index Block 和 Filter Block

读取一个 key 时:

  1. 从 Footer 读取 Index Block 的位置
  2. 在 Index Block 中找到目标 key 属于哪个 Data Block
  3. (如有 Bloom Filter)检查 key 是否可能存在于这个 SSTable
  4. 读取对应的 Data Block,二分查找 key

注意:一个 key 可能在多个 SSTable 中存在(同一个 key 被更新过)。读取时取最新的版本

Compaction——桌满了,该整理书架了

'有个问题,'你看着流水线,'这些 SSTable 越堆越多,要找东西不是越来越麻烦?'

老陈点头。随着时间推移,Level 0 的 SSTable 越来越多。你要找的 SSTable 也越来越多。读性能会持续下降。

Compaction 就是工人们的整理工作:在后台把多个小的 SSTable 合并成一个大的 SSTable,丢弃已被覆盖或删除的旧版本数据。

Level 0: [SST_1] [SST_2] [SST_3] [SST_4] ← 多个小文件
 
 
 Compaction
 
Level 1: [ SST_large ] ← 一个大文件

'就像把桌上散落的纸条定期装订成一本大册子,扔掉的旧版纸条就当废纸处理了。'老陈拍了拍那本新装订的大册子。

Compaction 的实现方式决定了 LSM-Tree 的读写放大特性。主要有两种策略。


深入冒险

Leveled Compaction——分层书架

'第一种方法,'老陈把你带到一面墙前,整面墙从下到上分成了几层书架。'叫 Leveled Compaction,RocksDB 的默认选择。'

多级结构,每一级的大小是上一级的固定倍数(默认 10 倍):

L0: [S1][S2]... → 单个文件大小约 64MB
L1: [ ≈512MB ] → 单个文件 可以是多个文件的总和
L2: [ ≈5GB ] → ...
L3: [ ≈50GB ]

规则

  • L0 比较特殊——SSTable 之间有 key 范围重叠(因为刷盘是无序的)
  • 从 L1 开始,每个层级内的 SSTable 之间 key 范围不重叠
  • Compaction 时,从 Ln 选一个 SSTable,与 Ln+1 中 key 范围重叠的所有 SSTable 合并

Leveled Compaction 的特点

  • 写放大较大:一个键写入后可能被多次 Compaction 搬移到更深的层级
  • 空间放大较小:陈旧数据被高效清理,大部分层级只有 1-2 倍的数据冗余
  • 读放大较小:因为每个 key 在每个层级最多只出现在一个 SSTable 中

Tiered Compaction——散装堆叠

'另一种方法,'老陈走到另一边——几摞文件堆在桌子上,每摞厚度差不多。'叫 Tiered Compaction。不急着整理,先堆着,等堆得太高了再合并。'

不将数据下沉到单文件,而是允许多个文件共存于一个层级,直到文件数达到阈值才合并:

Tier 0: [S1]
Tier 1: [S2][S3] ← 达到数量阈值时触发合并
Tier 2: [S4][S5][S6][S7]

规则

  • 每个层级允许最多 N 个 SSTable(比如 4 个)
  • 当层级内的 SSTable 数量超过阈值,将它们合并成一个大的 SSTable 并放到下一层级

Tiered Compaction 的特点

  • 写放大较小:数据被合并的次数少
  • 空间放大较大:旧数据滞留时间长,多个 SSTable 可能包含相同的 key
  • 读放大较大:每个 key 可能出现在多个 SSTable 中

三种放大的代价——每一次整理都有成本

'每次合并都有代价,'老陈擦了擦手,拿出一张纸画了三个圈。'写放大、读放大、空间放大——三角不可能全占。'

写放大(Write Amplification):物理写入量 / 逻辑写入量。

sql
-- 在 RocksDB 中查看写放大
-- 通过 DB::GetProperty("rocksdb.estimate-pending-compaction-bytes")
-- 或通过内嵌的统计信息

-- 假设你写入了 1MB 数据
-- 但经过多次 Compaction 后,实际写入磁盘了 10MB
-- 写放大 = 10x

读放大(Read Amplification):一次逻辑读取需要物理读取的次数。

sql
-- B+树读放大 = 树高度 ≈ 3-4
-- LSM-Tree 读放大 = MemTable(1) + 各层级 SSTable 数量
-- Leveled Compaction 下 ≈ 层级数 + 每层文件数
-- 如果 L0 有 4 个 SSTable,L1-L3 各 1 个
-- 读放大 = 1(MemTable) + 4(L0) + 1(L1) + 1(L2) + 1(L3) = 8

-- 这就是为什么 LSM-Tree 的点查比 B+树慢
-- 也是为什么需要 Bloom Filter

空间放大(Space Amplification):磁盘上实际占用的空间 / 有效数据大小。

特性Leveled CompactionTiered Compaction
写放大高(10-40x)低(4-10x)
读放大低(~层级数)高(~槽位数 × 层级数)
空间放大低(1.1-2x)高(可达 10x+)
适用场景读多写少、SSD写多读少、HDD

SSD 的寿命与写放大:SSD 有写入寿命(TBW),写放大直接缩短 SSD 寿命。一次逻辑上的 1MB 写入如果物理上写了 40MB,意味着 SSD 的磨损是 40 倍。这也是为什么 RocksDB 在 SSD 上会做大量优化来降低写放大。

RocksDB——工业级 LSM-Tree 的工程实践

'理论说完了,看看真家伙。'老陈打开一个标着 'RocksDB' 的工具箱。'Facebook 基于 Google LevelDB 改进的 LSM-Tree 实现——写放大和读放大都做了大量优化。'

1. Bloom Filter——过滤掉不存在的查问

'还记得读放大那个8次IO的问题吗?RocksDB 用了一个小把戏大幅降低它。'

Bloom Filter 是一个概率性数据结构:判断"key 肯定不存在"或"key 可能存在"。

cpp
// RocksDB 中配置 Bloom Filter
Options options;
options.table_factory.reset(NewBlockBasedTableFactory());
BlockBasedTableOptions table_options;
table_options.filter_policy.reset(NewBloomFilterPolicy(10 false));
options.table_factory.reset(
 NewBlockBasedTableFactory(table_options));

// 效果:对一个 key 的查询,
// Bloom Filter 可以快速排除 99% 不包含该 key 的 SSTable
// 每次 Bloom Filter 检查是 O(k) 哈希,比磁盘 I/O 快几个数量级
查询 key = 42

检查 MemTable → 没找到

检查 Bloom Filter(SST_1) → "不在" → 跳过
检查 Bloom Filter(SST_2) → "可能在" → 读盘搜索 → 找到 

检查 Bloom Filter(SST_3) → "不在" → 跳过

这次查询只读了 1 个 SSTable,而不是 10 个。

Bloom Filter 的假阳性:它会说"key 可能存在"但其实不存在。但不会出现假阴性(说"不存在"但实际存在)。假阳性率取决于位数组大小和哈希函数数量。配置中的 10 表示每个 key 占用 10 bits,假阳性率约 1%。

2. 前缀压缩——省掉重复的部分

'看看这些记录,'老陈指着一个 SSTable 里的 key 列表:

"abcdef123"
"abcdef456" ← 前缀 "abcdef" 重复了
"abcdef789" ← 前缀 "abcdef" 又重复了

'每个都存一遍完整字符串太浪费了。'老陈指着纸上的记录。'所以只存第一个完整的 key,后面的只存不同的部分。这叫前缀压缩,或者 key 增量编码。'

第一个 key: "abcdef123"(完整)
第二个 key: 增量 "456"(只存跟上一个不同的部分)
第三个 key: 增量 "789"

这能把 Data Block 的大小压缩 30-60%,减少磁盘 I/O。代价是读取 Block 时需要对增量进行重建。

3. 列族(Column Families)——给不同类型分柜子

'有时候你的数据需要分门别类存放,'老陈打开同一个工具箱里的几个隔层,'metadata 放一层,logs 放另一层。它们各自独立 SSTable,但共享同一个 WAL。'

RocksDB 支持多个列族(Column Family),每个列族有自己的 MemTable 和 SSTable 集合,但共享同一个 WAL。这在需要隔离不同数据的保持写入顺序时非常有用。

cpp
// RocksDB 多列族
DB* db;
std::vector<ColumnFamilyHandle*> handles;

Options options;
// 创建
DB::Open(options "/tmp/testdb" &db);

// 创建列族
ColumnFamilyHandle* cf_handle;
db->CreateColumnFamily(ColumnFamilyOptions() "cf_metadata" &cf_handle);

// 写入列族
db->Put(WriteOptions() cf_handle "key1" "value1");

// 不同列族可以有完全不同的 Compaction 策略
// 比如 metadata 列族用 Leveled(读优化)
// logs 列族用 Tiered(写优化)

4. Rate Limiter 和 Delayed Write——别让工坊被订单压垮

'最后一个,'老陈指了指工具箱底层的部件,'有时候任务请求太多,流水线处理不过来。你不能拒绝请求,但可以让请求慢一点进来。'

当写入速度超过 Compaction 的消化能力时,RocksDB 会主动限速——不是拒绝写入,而是让写入稍微等待,给 Compaction 留出时间。这叫 Write Stall

cpp
// RocksDB 的写入节流
Options options;
options.rate_limiter.reset(NewGenericRateLimiter(
 100 * 1024 * 1024 // 100 MB/s 写入限速
));

// 当 L0 文件数超过 soft_limit(默认 4) → 延迟写入
// 当 L0 文件数超过 hard_limit(默认 20)→ 停止写入
options.level0_slowdown_writes_trigger = 4;
options.level0_stop_writes_trigger = 20;

Write Stall 是 LSM-Tree 的固有特征:写入速度不能长期超过 Compaction 的处理速度,否则层级会无限膨胀。

B+树 vs LSM-Tree:你的场景选哪个

'所以到底用哪个?'你问。

老陈把两个文件夹并排摊在桌上,一页一页对比给你看。

维度B+树LSM-Tree
写性能随机 I/O,有限的写入吞吐顺序 I/O,极高的写入吞吐
读性能(点查)O(log n),3-4 次 I/OO(k log n),k=需检查的文件数
读性能(范围查询)叶节点链表,顺序扫描需要合并多个文件
写放大较低(1-3x)较高(10-40x leveled 4-10x tiered)
空间放大较低(1-2x)Leveled 低,Tiered 高
崩溃恢复WAL REDO,较复杂WAL REDO + SSTable 原子替换
并发控制页级锁,复杂SSTable 不可变,无需锁
内置压缩页级压缩,容易SSTable 级压缩,更高效
代表系统PostgreSQL MySQL InnoDB SQLiteRocksDB LevelDB Apache Cassandra HBase ScyllaDB

选型决策树

你的工作负载以什么为主?
 读取密集型(Web 应用、用户中心)
 B+树(PostgreSQL/MySQL)
 写入密集型(日志、时序数据、消息队列)
 LSM-Tree(RocksDB/Cassandra)
 读写均衡
 要求强事务 → B+树
 要求高吞吐 → LSM-Tree
 大范围分析查询
 在 OLTP 上 → B+树 + 覆盖索引
 纯 OLAP → 列存(第6章)

真实世界混合使用:TiDB 和 YugabyteDB 使用两种结构——RocksDB(LSM-Tree)做底层存储,B+树风格的元数据索引。Google Spanner 使用类 B+树的存储,但融入了 LSM-Tree 的理念。


常见陷阱

1. LSM-Tree 不适合小数据量

如果你的数据只有几万条,LSM-Tree 的层级结构反而增加开销。B+树在数据量小时 Buffer Pool 能缓存所有热页,性能完全够用。LSM-Tree 的 Compaction 在这些场景下是多余的计算。

2. Write Stall 是 LSM-Tree 的阿克琉斯之踵

当你写入速度超过 Compaction 速度(比如突发写入 100GB 数据到空数据库),L0 文件数急速膨胀。一旦超过 level0_stop_writes_trigger,写入就会完全停止。这会导致应用超时和连接池爆炸。

解决方法

  • 预分配资源、提前触发 Compaction
  • 使用 Rate Limiter 让写入更平滑
  • 监控 rocksdb.is-write-stopped()

3. 读放大的代价比你想的更大

LSM-Tree 的点查延迟方差很大。如果 Bloom Filter 说"可能在"但实际不在(假阳性),就要读一次磁盘。如果多个 SSTable 都有该 key 的旧版本,就要读多次。

cpp
// RocksDB 的 P99 延迟优化
ReadOptions read_options;
read_options.read_tier = kBlockCacheTier; // 只从缓存读,不到磁盘
read_options.tailing = true; // 用于大范围扫描的优化

4. 不要对 LSM-Tree 的 SSTable 数量掉以轻心

每个 SSTable 有文件句柄开销。如果每个 SSTable 是 64MB,1TB 数据就是 16384 个 SSTable。操作系统有 ulimit -n 限制,需要合理配置。


通关挑战

** 热身(5 分钟,必做)**

  1. 安装 RocksDB 的 Python 绑定并体验基本操作:
bash
pip install python-rocksdb
python
import rocksdb

db = rocksdb.DB("test_lsm.db" rocksdb.Options(create_if_missing=True))
db.put(b"key1" b"value1")
db.put(b"key2" b"value2")

print(db.get(b"key1")) # b'value1'

# 批量写入(原子操作)
batch = rocksdb.WriteBatch()
batch.put(b"key3" b"value3")
batch.put(b"key4" b"value4")
batch.delete(b"key1")
db.write(batch)

print(db.get(b"key1")) # None
  1. 查看 SSTable 文件及其大小:
bash
ls -lh test_lsm.db/
# 你会看到 .log (WAL) .sst (SSTable) MANIFEST OPTIONS 等文件
  1. 写 100 万个 key 后查看层级结构:
python
for i in range(1000000):
 db.put(f"key_{i:08d}".encode() f"value_{i}".encode())

# 查看统计
print(db.get_property(b"rocksdb.num-files-at-level0"))
print(db.get_property(b"rocksdb.num-files-at-level1"))
print(db.get_property(b"rocksdb.num-files-at-level2"))
print(db.get_property(b"rocksdb.cur-size-all-mem-tables"))

** 挑战(30 分钟,选做)**

实现一个最小 LSM-Tree:

python
# min_lsm.py
# 实现:
# 1. MemTable(用内置的 sorted list 或 SortedDict)
# 2. MemTable 满后刷成 SSTable(直接写排序后的 JSON 或自定义二进制)
# 3. 简单的 Leveled Compaction(L0 超过 4 个文件就合并到 L1)
# 4. 读取时需要合并 MemTable + 所有 SSTable
# 5. 实现一个简单的 WAL(文件追加写入)

class SimpleLSMTree:
 def __init__(self dir_path memtable_size=4):
 self.dir_path = dir_path
 self.memtable = {} # 当前活跃 MemTable
 self.memtable_size = memtable_size
 self.l0_ssts = [] # Level 0 的 SSTable 文件列表
 self.l1_sst = None # Level 1 的 SSTable 文件
 # TODO: 其他层级…

 def put(self key value):
 # 你的实现在这里
 pass

 def get(self key):
 # 你的实现在这里
 pass

 def flush_memtable(self):
 # 你的实现在这里
 pass

 def compact(self):
 # 你的实现在这里
 pass

 def range_query(self low high):
 # 你的实现在这里
 pass

** 观察**:用 iostat 观察写入模式:

bash
# 在 RocksDB 写入时监控磁盘写入模式
iostat -x 1

# 对比 MySQL 的随机写和 RocksDB 的顺序写模式
# 看 await 和 %util 的差异

验收标准

读完本章后,你应该能:

  • [x] 描述 LSM-Tree 的三层写入管道:MemTable → Immutable → SSTable
  • [x] 解释为什么 LSM-Tree 的写入是顺序 I/O 而 B+树是随机 I/O
  • [x] 对比 Leveled Compaction 和 Tiered Compaction 的写放大/读放大/空间放大特性
  • [x] 解释 Bloom Filter 如何在 LSM-Tree 中降低读放大
  • [x] 理解 Write Stall 出现的原因及其应对方法
  • [x] 根据工作负载类型判断 B+树和 LSM-Tree 哪个更合适
  • [x] 说出 RocksDB 相比 LevelDB 的三项关键优化

常见卡点

卡点原因解决
"LSM-Tree 为什么叫 Tree 但不是树?"LSM-Tree 是一种数据结构思想,实际实现中用跳表/红黑树做 MemTable,用 SSTable 做磁盘文件名字中的 Tree 指的是 MemTable 中的有序结构和 SSTable 的有序性
"为什么 Compaction 不把数据直接写到最终层级?"数据可能又很快被删除或更新。逐级下沉是"给数据时间沉淀"写热数据的旧版本在短期内可能被覆盖,提前合并到深层是浪费
"Bloom Filter 的假阳性导致读盘没用——这是不是浪费?"是的,但 Bloom Filter 的假阳性率可以控制在 1% 以下检查 → 读盘失败 → 继续下一个 SSTable,每次读盘是全 I/O 的成本
"RocksDB 的写放大 40x 不是灾难吗?"对于普通写操作是的,但 RocksDB 使用 O_DIRECT 跳过 Page Cache、在 Write Ahead 级别做一些跳过写放大在写入 512B 小数据时尤其严重,通过键值合并可以减轻

现在不需要理解

  • WiscKey:USENIX ATC 2016 的论文,对大值场景的 LSM-Tree 优化
  • Pebble:CockroachDB 用的 Go 语言 LSM-Tree 实现
  • LSM-Tree 的三层 Bloom Filter 优化:RocksDB 的 Full Filter 和 Block-based Filter 区别
  • 透明压缩在 LSM-Tree 上的实现细节:ZSTD、Snappy 压缩策略和 SSTable 的关系

旅人笔记

LSM-Tree 用"先攒着再落盘"的策略把随机写变成顺序写,换来了极高的写入吞吐。代价是读操作需要在多个文件间合并,以及后台 Compaction 带来的写放大。B+树和 LSM-Tree 没有谁一定更好——读多写少用 B+树,写多读少用 LSM-Tree。真正的工程是做取舍。

下一站预告

Built with VitePress | Software Systems Atlas