LSM-Tree(Log-Structured Merge-Tree)是与 B+ 树并驾齐驱的存储引擎范式,被 RocksDB、LevelDB、Cassandra、HBase、TiKV 等大量写密集系统采用。它的核心哲学是"把随机写变成顺序写":所有写入先追加到内存中的 MemTable,再批量刷成不可变的 SSTable,最后通过后台 Compaction 分层合并。对机械盘或 SSD 而言,顺序写远比随机写快,这让 LSM-Tree 在写入吞吐上轻松碾压 B+ 树——代价是读路径要检查多层文件,存在读放大。本指南从零构建 LSM-Tree 的心智模型,深入 MemTable、WAL、SSTable、Compaction 策略、Bloom Filter 与读写放大权衡,并给出 RocksDB 的生产调优实践。
一、为什么需要 LSM-Tree
1.1 B+ 树的写痛点
B+ 树写入路径:
写入 → 定位叶子页 → 就地修改(随机写)→ 页分裂/合并
痛点:
· 随机写性能差(磁盘寻道 / SSD 写放大)
· 页分裂导致写放大与碎片
· 高并发写下索引更新竞争激烈
LSM-Tree 的思路:
不立刻落盘 → 先写内存(顺序)→ 批量刷盘(顺序)→ 后台合并
→ 随机写变顺序写,吞吐量提升 1-2 个数量级
ℹ️ 核心洞察:LSM-Tree 把"写"的成本从"访问磁盘任意位置"(随机)变成"追加到日志末尾"(顺序),用一个内存缓冲区把随机写先"攒起来",再成批落盘。
1.2 适用场景与代价
| 场景 | LSM-Tree 优势 | B+ 树优势 |
|---|---|---|
| 写密集(日志、时序、IoT) | ✅ 写入吞吐高 | ❌ 随机写慢 |
| 读密集(点查) | ❌ 读放大 | ✅ 稳定 O(logN) |
| 空间利用率 | 合并后有空洞 | 紧凑 |
| 实现复杂度 | Compaction 复杂 | 相对简单 |
结论:写多读少的系统选 LSM;读多写少、对点查延迟敏感的系统选 B+ 树。
二、LSM-Tree 的核心结构
2.1 分层结构
内存层: MemTable(可写)→ 满后转 Immutable
↓ flush
磁盘层(L0): SSTable 文件(无序,多个)
↓ compaction
磁盘层(L1+): SSTable 文件(有序,按 key 范围划分)
↓ compaction(逐层下沉)
Lmax(最底层)
每个 SSTable:Sparse Index + Bloom Filter + 数据块
WAL(Write-Ahead Log):落盘前先写日志,崩溃恢复用
2.2 一次写入的生命周期
Put(key, value):
1. 追加到 WAL(顺序写,持久化保障)
2. 写入 MemTable(有序结构,内存)
3. MemTable 达到阈值 → 冻结为 Immutable
4. 后台线程将 Immutable flush 为 SSTable(L0)
5. 后续 compaction 将 L0 合并到 L1、L2...
删除(Tombstone):
Delete(key) → 写入一个删除标记(tombstone)
→ 真正物理删除要等 compaction 时把被标记的旧版本清掉
# lsm_put.py — 写入路径伪代码
def lsm_put(wal, memtable, key, value):
wal.append(key, value) # 1. 先写 WAL(顺序追加)
memtable.put(key, value) # 2. 写内存表
if memtable.size > threshold: # 3. 满则冻结
immutable = memtable.freeze()
schedule_flush(immutable) # 4. 异步刷盘
def lsm_get(memtable, immutables, sstables, bloom_filters, key):
if key in memtable: return memtable.get(key) # 1. 内存最新
for imm in reversed(immutables): # 2. 冻结表(新→旧)
if key in imm: return imm.get(key)
for sst in level_order(sstables): # 3. 磁盘层(L0→Lmax)
if bloom_filters[sst].might_contain(key): # 布隆过滤
if val := sst.get(key): return val
return None # 4. 未找到
三、MemTable 与 WAL
3.1 MemTable 的实现选择
| 结构 | 特点 | 使用 |
|---|---|---|
| SkipList | 有序、并发友好 | RocksDB 默认 |
| 平衡树 | 有序、实现复杂 | 较少 |
| 哈希表 | 无序、快但 range 慢 | LevelDB 变体 |
// RocksDB MemTable 相关选项
Options options;
options.write_buffer_size = 64 * 1024 * 1024; // 单 MemTable 64MB
options.max_write_buffer_number = 4; // 最多 4 个(含冻结)
options.min_write_buffer_number_to_merge = 2; // flush 前至少 2 个
3.2 WAL:崩溃恢复的保障
WAL 的作用:
· 数据先落 WAL 再进内存 → 进程崩溃不丢已确认的写
· 恢复时重放 WAL 重建 MemTable
WAL 的代价:
· 每次写多一次磁盘 fsync(可调:每次/批/关闭)
· 可用 group commit 合并 fsync 提升吞吐
RocksDB 选项:
WriteOptions.sync = true/false # 是否强制刷盘
WAL 与 MemTable flush 后 WAL 可清理
3.3 WAL 配置实践
WriteOptions write_options;
write_options.sync = true; // 强持久化(金融场景)
// 或
write_options.sync = false; // 高吞吐,靠 OS 缓存 + 周期刷
四、SSTable 文件格式
4.1 SSTable 内部布局
SSTable 文件结构:
┌──────────────────────────┐
│ Data Block 0 (有序 keys) │
│ Data Block 1 │
│ ... │
│ Sparse Index(块级索引) │
│ Bloom Filter(块级/整体)│
│ Metadata │
│ Footer(索引位置) │
└──────────────────────────┘
查询一个 key:
1. Bloom Filter 快速判断"是否存在"(不存在的直接返回)
2. Sparse Index 二分定位可能包含 key 的 Data Block
3. 读取该 Data Block,块内二分/线性扫描定位
ℹ️ Bloom Filter 是 LSM 读性能的灵魂:它可以以极小内存代价(1% 误判率 ≈ 每 key 约 10bit)过滤掉绝大多数"不存在"的 key 查询,显著降低读放大。
4.2 分层与有序性
L0:文件无序(来自不同 flush),查询需扫描多个文件
L1+:文件内部有序 + 文件间按 key 范围互不重叠(可用稀疏索引定位单个文件)
→ L0 越少越好,compaction 优先把 L0 压到 L1
→ 查询优先从内存 → L0 → 低层逐层搜索
五、Compaction:LSM 的心脏
5.1 为什么需要 Compaction
数据永远在膨胀:
· 旧版本 key 需要清理(多版本 + tombstone)
· 小 SSTable 太多,查询要扫的文件越来越多
· 无界增长导致读放大失控
Compaction 做三件事:
1. 合并小文件成大文件(减少文件数)
2. 清理旧版本与 tombstone(回收空间)
3. 保持层级有序(优化读路径)
5.2 三种 Compaction 策略
| 策略 | 思路 | 优缺点 |
|---|---|---|
| Size-Tiered | 等大小文件合并成大文件 | 写放大低、读放大高 |
| Leveled | 每层固定大小、逐层下沉 | 读放大低、写放大较高 |
| FIFO | 按时间淘汰(不合并) | 适合时序/日志 |
// RocksDB 选择 Leveled(默认)
options.compaction_style = CompactionStyle::kCompactionStyleLevel;
// Size-Tiered(写密集时序场景可选)
options.compaction_style = CompactionStyle::kCompactionStyleUniversal;
5.3 Compaction 对性能的影响
写放大(Write Amplification):
实际写入磁盘的数据量 / 用户写入数据量
Leveled 通常 10-30x;Size-Tiered 5-15x
→ 高写放大磨损 SSD、限制写吞吐
读放大(Read Amplification):
一次查询实际读取的数据量 / 目标数据量
→ 越高点查越慢
权衡:压缩越激进(Leveled),读放大越低但写放大越高
需按 workload 调 L0 大小、层数、触发阈值
六、Bloom Filter 与点查优化
6.1 配置 Bloom Filter
// RocksDB 每个 SSTable 启用 Bloom Filter
options.filter_policy = rocksdb::NewBloomFilterPolicy(10); // 10bit/key
// 不同级别的 filter 可差异化:
// L0/L1 关闭(小层查询成本低),高层开启(省内存)
// 也支持整表前缀 Bloom(针对前缀查询)
6.2 Bloom Filter 效果
命中分析:
存在 key:Bloom 几乎必过 → 进入索引定位
不存在 key:Bloom 大概率拒绝(1% 误判才多读一层)
收益:
· 点查不存在 key 的成本 ≈ O(1)
· 点查存在 key 的成本大幅下降(跳过无关文件)
· 代价:每个 SSTable 增加 10-20% 内存/存储
七、读写放大权衡与调优
7.1 关键旋钮
// RocksDB 生产调优常用参数
options.write_buffer_size = 128 << 20; // MemTable 128MB
options.max_write_buffer_number = 4; // 内存缓冲深度
options.min_write_buffer_number_to_merge = 2;
options.max_background_compactions = 4; // 并行 compaction
options.max_background_flushes = 2;
options.level0_file_num_compaction_trigger = 8; // L0 触发 compaction
options.level0_slowdown_writes_trigger = 20; // 写降速阈值
options.level0_stop_writes_trigger = 36; // 写暂停阈值(防雪崩)
options.soft_pending_compaction_bytes_limit = 64GB;
options.hard_pending_compaction_bytes_limit = 128GB;
// 压缩算法
options.compression = rocksdb::kSnappyCompression; // 快速
options.bottommost_compression = rocksdb::kZSTD; // 底层高压缩
7.2 调优思路
写吞吐不足:
· 增大 write_buffer_size(攒更多再刷)
· 增大 max_write_buffer_number(容忍内存延迟)
· 增 max_background_compactions(并行压缩)
· 检查写放大(Universal 是否更合适)
点查变慢(读放大):
· 开 Bloom Filter
· 加快 compaction(缩小 L0,减少小文件扫描)
· 减少层级(压缩更激进)
空间使用高:
· 用 zstd/lz4 压缩底层
· 检查 tombstone 清理节奏(是否 compaction 太慢)
写停顿(stall):
· pending compaction 达到 hard 阈值会暂停写
· 提前扩容 / 增加压缩线程 / 用更轻的压缩算法
7.3 监控关键指标
RocksDB 关键指标(Prometheus):
rocksdb_compaction_num # 压缩次数
rocksdb_compaction_bytes_written # 写放大字节
rocksdb_slow_write_count # 写停顿
rocksdb_memtable_total_size # 内存占用
rocksdb_compaction_pending # 待压缩量
rocksdb_block_cache_hit_ratio # 缓存命中率
八、LSM-Tree 变体与生态
8.1 生态一览
| 系统 | 引擎 | 特点 |
|---|---|---|
| RocksDB | LSM | Facebook 出品,键值基石,支持事务/列族 |
| LevelDB | LSM | 谷歌原型,简单可靠 |
| Cassandra | LSM | 列族 + 分区,写优化 |
| HBase | LSM | 大数据生态,Region 服务 |
| TiKV | RocksDB | 分布式 KV,多副本 |
| MySQL MyRocks | RocksDB | 把 InnoDB 换成 LSM |
8.2 与 B+ 树的混合趋势
演进方向:
· MySQL 8.x:可选 RocksDB 引擎(MyRocks),写密集业务受益
· RocksDB 引入"持久化 MemTable / 分块索引"改善读
· 很多系统 LSM + 内存加速层(缓存热点)缓解读放大
→ 选型不是"二选一",而是按 workload 混用
九、生产落地清单
9.1 从理论到工程
落地 Checklist:
□ 明确 workload:写多读少 / 读多写少
□ 选择 compaction 策略(Leveled 通用 / Universal 写密集)
□ MemTable 与 WAL 参数匹配写入速率
□ Bloom Filter 按层差异化开启
□ 压缩算法:热层 Snappy/LZ4,冷层 ZSTD
□ 设置写停顿阈值(防止 compaction 追不上写入)
□ 监控 read/write amplification 与 stall
□ 定期做 compaction 健康检查与空间回收
9.2 常见陷阱
| 陷阱 | 表现 | 对策 |
|---|---|---|
| 写停顿 | 周期性写阻塞 | 提高压缩并行度/阈值 |
| 读放大失控 | 点查慢 | 开 Bloom + 加快合并 |
| tombstone 累积 | 空间不回收 | 触发 compaction / 排查删除风暴 |
| 内存飙升 | MemTable 积压 | 调 write_buffer 与触发阈值 |
| 缓存抖动 | 热点 miss | 扩大 block cache / 前缀布隆 |
9.3 选型决策速查
写密集 + 点查少(日志/时序/Feed):LSM(RocksDB/Universal)
写密集 + 点查多:LSM + 大缓存层 + Bloom
读密集 + 低延迟:B+ 树(InnoDB/PostgreSQL)
需要事务/外键:B+ 树为主,写热点表可用 MyRocks
大数据分布式:TiKV/Cassandra(LSM 底座)
总结:LSM-Tree 核心决策表
| 环节 | 关键决策 |
|---|---|
| 写入 | MemTable + WAL(顺序写,攒批刷盘) |
| 读取 | 内存→L0→高层 + Bloom Filter 过滤 |
| 合并 | Compaction:Leveled(读优)vs Universal(写优) |
| 放大 | 读/写放大权衡,按 workload 校准 |
| 持久化 | WAL 先行,group commit 平衡吞吐 |
| 调优 | 内存缓冲、压缩并行、Bloom、压缩算法 |
LSM-Tree 用"以读换写、以空间换时间“换来了碾压式的写入吞吐——把随机写转为顺序写,把索引维护从热路径搬到后台 Compaction。它不是银弹:点查与空间放大是它的阿喀琉斯之踵,但配合 Bloom Filter、合理分层与压缩算法,这些代价可以被精确控制。理解 LSM-Tree 不只是理解一个数据结构,而是理解现代写密集系统的核心 trade-off——当你面对"为什么 RocksDB 写入这么快却偶尔变慢"“为什么 Cassandra 空间比想象中大"这类问题时,答案都藏在本指南的读写放大与 Compaction 权衡里。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。