概述

LSM 树(Log-Structured Merge-Tree)是一种针对写密集型工作负载优化的数据结构。与 B 树不同,LSM 树不进行原地更新,而是将所有修改顺序写入内存,然后异步合并到磁盘。

核心思想:将随机写转化为顺序写,最大化写吞吐量的同时接受读和空间放大作为权衡。

架构组件

1. MemTable(内存表)

  • 有序内存结构(通常为 SkipList 或 Red-Black Tree)
  • 接收所有写操作
  • 达到阈值后 flush 到磁盘

2. Immutable MemTable(只读内存表)

  • MemTable flush 期间的过渡结构
  • 正在被序列化到磁盘

3. SSTable(Sorted String Table)

  • 磁盘上的不可变有序文件
  • 包含数据块、索引块、布隆过滤器

4. WAL(Write-Ahead Log)

  • 保证 MemTable 数据的持久性
  • 崩溃恢复的依据

写入流程

┌──────────────────────────────────────────────────────────────┐
│                         写入流程                             │
└──────────────────────────────────────────────────────────────┘

1. 写入请求 ──► 写入 WAL(保证持久性)
              │
              ▼
2. 写入 MemTable(SkipList,按 key 排序)
              │
              ▼
3. 返回成功给客户端
              │
              ▼ (MemTable 达到阈值)
4. 转换为 Immutable MemTable
              │
              ▼
5. 后台线程 flush 到 L0 层 SSTable

代码示例(RocksDB 风格)

// 写入操作
void Put(const Slice& key, const Slice& value) {
    // 1. 写入 WAL
    log->AddRecord({key, value});
    
    // 2. 写入 MemTable
    memtable->Add(key, value);
}
 
// MemTable flush
void FlushMemTable() {
    ImmutableMemTable* imm = memtable_->RefAsImmutable();
    SSTFileWriter writer;
    
    // 按 key 排序写入 SSTable
    for (auto& kv : *imm) {
        writer.Add(kv.key, kv.value);
    }
    writer.Finish();
    
    // 添加到 L0
    AddSSTableToL0(writer.filename());
}

读取流程

读取需要从最新层到最旧层逐层查找:

┌─────────────────────────────────────────────┐
│              读取流程                        │
└─────────────────────────────────────────────┘

Get(key):
  1. 检查 MemTable(最新数据)
  2. 检查 Immutable MemTables
  3. 从 L0 到 Ln 按层查找 SSTable
     ├── 检查 Bloom Filter(快速判断是否存在)
     ├── 二分查找索引块定位数据块
     └── 在数据块中查找
  4. 返回最新版本(时间戳最大的)

时间复杂度

最坏情况下需要检查所有层:

但实际中:

  • Bloom Filter 可以 判断不存在
  • 通常只需检查少数几层

合并策略(Compaction)

Leveled Compaction

L0: [f1] [f2] [f3]     ← 新 flush 的文件,可能重叠
L1: [a-k] [l-z]        ← 按 key 范围分区,无重叠
L2: [a-f][g-m][n-t][u-z]
L3: ...

特点

  • 每层大小约为前一层的 10 倍
  • 90% 数据存储在最后一层
  • 空间放大约 10x

Tiered Compaction(Universal)

Level 0: [f1] [f2] [f3] [f4]  ← 多个重叠文件
Level 1: [a-z]                  ← 单个排序run
Level 2: [a-z]                  ← 更大的排序run

特点

  • 写放大更低
  • 读可能需要合并更多文件
  • 空间放大可能较高

LSM vs B-Tree

特性LSM TreeB-Tree
写放大低(顺序写)高(随机读写)
读放大高(多层查找)低(单次查找)
空间放大1.5-2x1.1-1.2x
写吞吐
读延迟不稳定稳定
内存较少较多(指针对象)

删除与 Tombstone

LSM 树不支持原地删除,而是使用 Tombstone 标记删除:

写入: key="foo", value="bar"
删除: key="foo", type=Tombstone  ← 墓碑标记

Compaction 时:
  key="foo"(tombstone) + key="foo"(v1) → 都丢弃(真正的删除)

问题:墓碑可能长期存在直到被压缩掉

变体与实现

LevelDB

  • Google 开发
  • 简单、高效
  • RocksDB 的前身

RocksDB

  • Facebook 开发,LevelDB 的优化版本
  • 多核 CPU 优化
  • 支持列族、事务、备份

Cassandra

  • 分布式 NoSQL
  • 使用 Leveled Compaction
  • 适合写入密集型

TiKV

  • 使用 RocksDB 作为存储引擎
  • 存储 Raft 日志和用户数据

配置参数(RocksDB)

Options options;
 
// MemTable 配置
options.write_buffer_size = 64 * 1024 * 1024;  // 64MB
options.max_write_buffer_number = 3;
 
// Compaction 配置
options.level_compaction_dynamic_level_bytes = true;
options.level0_file_num_compaction_trigger = 4;
options.level0_slowdown_writes_trigger = 20;
options.max_bytes_for_level_base = 256 * 1024 * 1024;  // 256MB
 
// Bloom Filter
options.filter_policy = NewBloomFilterPolicy(10);

优缺点总结

优点

  • ✅ 极快的顺序写入
  • ✅ 高写入吞吐
  • ✅ 优秀的磁盘空间利用
  • ✅ 崩溃恢复简单(WAL)

缺点

  • ❌ 读取需要合并多个源
  • ❌ 后台合并 I/O 开销
  • ❌ 合并期间延迟抖动
  • ❌ 写放大(同一数据被重写多次)

适用场景

  • 写入密集型负载(日志、时序数据、消息队列)
  • 需要高吞吐的键值存储
  • SSD/Flash 存储优化

参考