mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1361 字
4 分钟
LSM 树深入
2025-10-10

B+ 树每次更新都是一次随机写入:找到目标页面、加 Latch、原地修改、写 WAL、刷盘。写入 QPS 一高,磁盘随机 I/O 跟不上,延迟飙升。这就是 B+ 树的致命伤——原地更新把写入绑死在随机 I/O 上

LSM 树(Log-Structured Merge Tree)换了一条路:所有写入都是追加,随机写变成顺序写。代价是读取时要检查多个层级,以及后台 Compaction 持续消耗资源。这是一场用读性能和空间换写吞吐的交易。

一、LSM 树的核心思想#

1.1 从 B+ 树到 LSM 树#

graph TB subgraph B+树写入["B+ 树写入流程"] B_WRITE["写入请求"] --> B_FIND["查找目标页面<br/>随机 I/O"] B_FIND --> B_LATCH["获取 Latch"] B_LATCH --> B_UPDATE["原地更新页面"] B_UPDATE --> B_WAL["写入 WAL"] B_WAL --> B_FLUSH["刷盘"] end subgraph LSM写入["LSM 树写入流程"] L_WRITE["写入请求"] --> L_WAL["写入 WAL<br/>顺序 I/O"] L_WAL --> L_MEM["写入 MemTable<br/>内存操作"] L_MEM --> L_DONE["返回成功"] L_MEM -->|"MemTable 满"| L_FLUSH["Flush 为 SSTable<br/>顺序 I/O"] L_FLUSH --> L_COMPACT["后台 Compaction<br/>顺序 I/O"] end style B_FIND fill:#ffcdd2,stroke:#c62828 style L_MEM fill:#c8e6c9,stroke:#2e7d32
维度B+ 树LSM 树
写入方式原地更新追加写入
写入 I/O随机 I/O顺序 I/O
写入延迟高(需查找+加锁+修改)低(追加到内存)
读取 I/OO(log N)O(L × log N),L 为层级数
空间回收原地删除Compaction 合并
后台开销Compaction 持续运行

1.2 LSM 树的层级结构#

graph TB subgraph LSM["LSM 树层级结构"] WAL["WAL 日志<br/>崩溃恢复"] MEM["MemTable<br/>内存中的有序结构<br/>跳表/红黑树"] IMMU["Immutable MemTable<br/>正在刷盘的只读 MemTable"] L0["Level 0<br/>直接从 MemTable 刷盘<br/>键范围重叠"] L1["Level 1<br/>Compaction 后有序<br/>键范围不重叠"] L2["Level 2<br/>大小 = L1 × 10"] L3["Level 3<br/>大小 = L2 × 10"] end WAL --> MEM --> IMMU --> L0 --> L1 --> L2 --> L3 style MEM fill:#c8e6c9,stroke:#2e7d32 style L0 fill:#fff9c4,stroke:#f9a825 style L1 fill:#e3f2fd,stroke:#1565c0 style L2 fill:#e3f2fd,stroke:#1565c0 style L3 fill:#e3f2fd,stroke:#1565c0

二、MemTable#

2.1 MemTable 的实现#

MemTable 是 LSM 树的写入缓冲,通常用跳表(Skip List)实现:

// 跳表节点
struct SkipNode {
Key key;
Value value;
int level;
SkipNode *forward[]; // 每层的前向指针
};
// 跳表
struct SkipList {
int max_level;
int current_level;
SkipNode *header;
double probability; // 通常 0.25
};
// 查找:O(log N)
SkipNode *skiplist_find(SkipList *list, Key key) {
SkipNode *current = list->header;
for (int i = list->current_level - 1; i >= 0; i--) {
while (current->forward[i] && current->forward[i]->key < key) {
current = current->forward[i];
}
}
current = current->forward[0];
if (current && current->key == key) return current;
return NULL;
}
// 插入:O(log N)
void skiplist_insert(SkipList *list, Key key, Value value) {
SkipNode *update[MAX_LEVEL];
SkipNode *current = list->header;
// 查找插入位置
for (int i = list->current_level - 1; i >= 0; i--) {
while (current->forward[i] && current->forward[i]->key < key) {
current = current->forward[i];
}
update[i] = current;
}
// 随机决定层数
int new_level = random_level(list->probability);
SkipNode *new_node = create_node(key, value, new_level);
// 更新指针
for (int i = 0; i < new_level; i++) {
new_node->forward[i] = update[i]->forward[i];
update[i]->forward[i] = new_node;
}
}

2.2 为什么选择跳表#

数据结构查找插入范围扫描并发友好实现复杂度
跳表O(log N)O(log N)O(K + log N)
红黑树O(log N)O(log N)O(K + log N)一般
B+ 树O(log N)O(log N)O(K)一般
Note

跳表的核心优势是并发友好:插入只需修改局部指针,不需要像红黑树那样做旋转操作。RocksDB 和 LevelDB 都使用跳表实现 MemTable。

三、SSTable#

3.1 SSTable 文件格式#

SSTable(Sorted String Table)是 LSM 树在磁盘上的存储格式:

graph LR subgraph SSTable["SSTable 文件格式"] direction LR DATA["数据块 Data Blocks<br/>按 Key 有序<br/>4KB/块"] META["元数据块 Meta Blocks<br/>Bloom Filter<br/>属性统计"] INDEX["索引块 Index Block<br/>每个 Data Block 的<br/>最小 Key + 偏移"] FOOTER["文件尾 Footer<br/>索引偏移<br/>元数据偏移<br/>魔数"] end DATA --> META --> INDEX --> FOOTER style DATA fill:#c8e6c9,stroke:#2e7d32 style INDEX fill:#e3f2fd,stroke:#1565c0 style FOOTER fill:#fff9c4,stroke:#f9a825
// SSTable 文件布局(简化)
// ┌─────────────────────────────────────┐
// │ Data Block 0 [key0, val0] ... │ 4KB
// │ Data Block 1 [keyN, valN] ... │ 4KB
// │ ... │
// │ Data Block M │ 4KB
// ├─────────────────────────────────────┤
// │ Meta Block 0 (Bloom Filter) │
// │ Meta Block 1 (Properties) │
// ├─────────────────────────────────────┤
// │ Index Block │
// │ [block_min_key → offset, size] │
// ├─────────────────────────────────────┤
// │ Footer (48 bytes) │
// │ meta_index_offset, index_offset │
// │ magic_number │
// └─────────────────────────────────────┘
struct SSTableFooter {
uint64_t meta_index_offset; // 元数据索引偏移
uint64_t meta_index_size;
uint64_t index_offset; // 数据索引偏移
uint64_t index_size;
uint8_t magic[8]; // 魔数校验
};

3.2 SSTable 中的键值编码#

# RocksDB 的键值编码
# Internal Key = User Key + Sequence Number + Type
# Type: Put(0) 或 Delete(1)
def encode_internal_key(user_key, seq_num, op_type):
"""编码内部键"""
# User Key: 用户可见的键
# Sequence Number: 7 字节,单调递增
# Type: 1 字节,Put 或 Delete
internal_key = user_key + struct.pack(">Q", (seq_num << 8) | op_type)
return internal_key
# 键排序规则:
# 1. 按 User Key 升序
# 2. 相同 User Key,按 Sequence Number 降序(新的在前)
# 3. 相同 User Key + Sequence Number,Put 优先于 Delete
# 这保证了读取时先看到最新版本

四、Compaction 策略#

4.1 为什么需要 Compaction#

LSM 树的追加写入会导致两个问题:

  1. 空间放大:同一个 Key 可能在多个 SSTable 中存在(旧版本)
  2. 读放大:读取时需要检查多个 SSTable

Compaction 通过合并 SSTable 解决这两个问题:

graph TB subgraph Compaction前["Compaction 前"] SST1["SSTable 1<br/>key=A(v1), key=B(v1)"] SST2["SSTable 2<br/>key=A(v2), key=C(v1)"] SST3["SSTable 3<br/>key=B(v2), key=D(v1)"] end subgraph Compaction后["Compaction 后"] SST_NEW["新 SSTable<br/>key=A(v2), key=B(v2),<br/>key=C(v1), key=D(v1)"] end SST1 --> MERGE["合并"] --> SST_NEW SST2 --> MERGE SST3 --> MERGE style Compaction前 fill:#ffe0b2,stroke:#e65100 style Compaction后 fill:#c8e6c9,stroke:#2e7d32

4.2 Leveled Compaction#

RocksDB 默认策略,也是 LevelDB 使用的策略:

层级大小限制SSTable 数量键范围
L04 × MemTable≤ 4重叠
L110 × L0≤ 10不重叠
L210 × L1≤ 100不重叠
L310 × L2≤ 1000不重叠
# Leveled Compaction 触发条件
def should_compact_leveled(level, level_size, max_size):
"""判断是否需要 Compaction"""
# L0: SSTable 数量超过 4
if level == 0 and len(sstables[level]) >= 4:
return True
# L1+: 层级大小超过限制
if level_size > max_size:
return True
return False
# Leveled Compaction 过程
def leveled_compaction(level):
"""Leveled Compaction"""
# 1. 选择一个 SSTable 作为输入
input_sst = pick_compaction_input(level)
# 2. 找到下一层与输入范围重叠的 SSTable
overlap_ssts = find_overlaps(level + 1, input_sst.key_range)
# 3. 多路归并排序
new_ssts = merge_sort([input_sst] + overlap_ssts)
# 4. 写入新的 SSTable
write_new_sstables(level + 1, new_ssts)
# 5. 删除旧的 SSTable
delete_old_sstables([input_sst] + overlap_ssts)

4.3 Tiered Compaction#

Tiered Compaction(也称为 Size-Tiered)先累积再合并:

graph TB subgraph Tiered["Tiered Compaction"] FLUSH1["Flush 1"] --> T1["Tier 1<br/>SST 1"] FLUSH2["Flush 2"] --> T2["Tier 1<br/>SST 2"] FLUSH3["Flush 3"] --> T3["Tier 1<br/>SST 3"] FLUSH4["Flush 4"] --> T4["Tier 1<br/>SST 4"] T1 --> MERGE["合并"] T2 --> MERGE T3 --> MERGE T4 --> MERGE MERGE --> NEW["新 Tier 2<br/>大 SSTable"] end style T1 fill:#fff9c4,stroke:#f9a825 style T2 fill:#fff9c4,stroke:#f9a825 style T3 fill:#fff9c4,stroke:#f9a825 style T4 fill:#fff9c4,stroke:#f9a825 style NEW fill:#c8e6c9,stroke:#2e7d32

4.4 Compaction 策略对比#

维度LeveledTieredFIFO
写放大高(每层重写)低(累积后合并)最低(不合并)
读放大低(层级有序)较高(同层重叠)高(需检查所有)
空间放大低(及时清理)较高(等待合并)高(旧版本保留)
Compaction 开销高(持续运行)中(批量运行)
适用场景通用写入密集时序/日志
典型系统LevelDB/RocksDBCassandraInfluxDB
Important

Compaction 策略的选择本质上是写放大 vs 读放大 vs 空间放大的权衡。Leveled 牺牲写放大换取低读放大和低空间放大;Tiered 牺牲空间放大换取低写放大;FIFO 牺牲读放大和空间放大换取最低写放大。

4.5 写放大分析#

# Leveled Compaction 写放大分析
def leveled_write_amplification(n_levels=7, size_ratio=10):
"""计算 Leveled Compaction 的写放大"""
total_wa = 0
for level in range(n_levels):
# 每层的数据需要被重写的次数
# 数据从 L0 移到 L1 时,L1 的数据被重写
# 数据从 L1 移到 L2 时,L2 的数据被重写
# ...
wa = size_ratio # 每层重写约 size_ratio 倍数据
total_wa += wa
print(f"L{level} → L{level+1}: 写放大贡献 = {wa}x")
print(f"\n总写放大: {total_wa}x")
return total_wa
leveled_write_amplification()
# L0 → L1: 写放大贡献 = 10x
# L1 → L2: 写放大贡献 = 10x
# L2 → L3: 写放大贡献 = 10x
# L3 → L4: 写放大贡献 = 10x
# L4 → L5: 写放大贡献 = 10x
# L5 → L6: 写放大贡献 = 10x
# L6 → L7: 写放大贡献 = 10x
# 总写放大: 70x

五、布隆过滤器#

5.1 布隆过滤器原理#

布隆过滤器是 LSM 树减少读放大的关键:在查找 SSTable 之前,先检查布隆过滤器,如果过滤器说”不存在”,则跳过该 SSTable。

# 布隆过滤器实现
import mmh3 # MurmurHash3
class BloomFilter:
def __init__(self, expected_items, false_positive_rate=0.01):
# 计算需要的位数
self.size = self._optimal_size(expected_items, false_positive_rate)
self.num_hashes = self._optimal_hashes(self.size, expected_items)
self.bit_array = [0] * self.size
def _optimal_size(self, n, p):
"""计算最优位数组大小"""
# m = -(n * ln(p)) / (ln(2)^2)
import math
return int(-(n * math.log(p)) / (math.log(2) ** 2))
def _optimal_hashes(self, m, n):
"""计算最优哈希函数数量"""
# k = (m/n) * ln(2)
import math
return int((m / n) * math.log(2))
def add(self, key):
"""添加键"""
for i in range(self.num_hashes):
index = mmh3.hash(str(key), i) % self.size
self.bit_array[index] = 1
def might_contain(self, key):
"""检查键是否可能存在"""
for i in range(self.num_hashes):
index = mmh3.hash(str(key), i) % self.size
if self.bit_array[index] == 0:
return False # 一定不存在
return True # 可能存在(有假阳性)
# 使用示例
bf = BloomFilter(expected_items=100000, false_positive_rate=0.01)
bf.add("user:1001")
print(bf.might_contain("user:1001")) # True
print(bf.might_contain("user:9999")) # False(大概率)

5.2 布隆过滤器的假阳性率#

位数/键 (m/n)哈希数 (k)假阳性率空间开销
4314.7%4 bits/key
862.2%8 bits/key
1070.82%10 bits/key
1280.31%12 bits/key
16100.05%16 bits/key
20140.006%20 bits/key
Note

RocksDB 默认每个 Key 分配 10 位(假阳性率 ~0.82%)。对于 1 亿个 Key,布隆过滤器约需 120MB 内存。这是用空间换读取性能的经典权衡。

六、LSM 树的读取流程#

6.1 读取路径#

graph TB READ["读取请求<br/>Get(key)"] --> MEM_CHECK["检查 MemTable"] MEM_CHECK -->|"命中"| RETURN1["返回最新值"] MEM_CHECK -->|"未命中"| IMMU_CHECK["检查 Immutable MemTable"] IMMU_CHECK -->|"命中"| RETURN2["返回值"] IMMU_CHECK -->|"未命中"| L0_CHECK["检查 L0 SSTable<br/>(Bloom Filter 过滤)"] L0_CHECK -->|"命中"| RETURN3["返回值"] L0_CHECK -->|"未命中"| L1_CHECK["检查 L1 SSTable<br/>(Bloom Filter + 索引)"] L1_CHECK -->|"命中"| RETURN4["返回值"] L1_CHECK -->|"未命中"| LN_CHECK["检查更深层级..."] style READ fill:#e3f2fd,stroke:#1565c0 style RETURN1 fill:#c8e6c9,stroke:#2e7d32 style RETURN2 fill:#c8e6c9,stroke:#2e7d32 style RETURN3 fill:#c8e6c9,stroke:#2e7d32 style RETURN4 fill:#c8e6c9,stroke:#2e7d32

6.2 读取优化#

优化说明效果
Bloom Filter跳过不包含目标 Key 的 SSTable减少 90%+ 的无效 I/O
Block Cache缓存热点 Data Block减少磁盘读取
分区索引索引分片,只加载需要的分片减少内存占用
前缀 Bloom对 Key 前缀做 Bloom Filter加速前缀扫描
Point Lookup 优化先查 Bloom,再查索引,最后读数据三级过滤

七、实战:RocksDB 调优#

7.1 RocksDB 关键配置#

# RocksDB 配置示例(Python)
import rocksdb
opts = rocksdb.Options()
opts.create_if_missing = True
# 写缓冲区(MemTable 大小)
opts.write_buffer_size = 64 * 1024 * 1024 # 64MB
# L0 层文件数触发 Compaction
opts.level0_file_num_compaction_trigger = 4
# L0 层文件数触发慢写入
opts.level0_slowdown_writes_trigger = 20
opts.level0_stop_writes_trigger = 36
# 每层大小倍数
opts.max_bytes_for_level_base = 256 * 1024 * 1024 # 256MB
opts.max_bytes_for_level_multiplier = 10
# Compaction 策略
opts.compaction_style = rocksdb.Options.LEVEL # Leveled
# 布隆过滤器
table_opts = rocksdb.BlockBasedTableOptions()
table_opts.filter_policy = rocksdb.BloomFilterPolicy(10) # 10 bits/key
table_opts.block_cache = rocksdb.LRUCache(512 * 1024 * 1024) # 512MB
opts.table_factory = rocksdb.BlockBasedTableFactory(table_opts)
# 压缩
opts.compression = rocksdb.CompressionType.lz4_compression
opts.bottommost_compression = rocksdb.CompressionType.zstd_compression
db = rocksdb.DB("/data/rocksdb", opts)

7.2 Compaction 调优矩阵#

参数写密集读密集均衡
write_buffer_size128MB32MB64MB
max_write_buffer_number423
level0_file_num_compaction_trigger424
max_bytes_for_level_base512MB128MB256MB
compaction_styleTieredLeveledLeveled
bloom_bits_per_key61610
block_cache_size256MB2GB512MB

7.3 RocksDB 统计信息#

# 查看 RocksDB 统计
# 通过 db.GetProperty() 获取
rocksdb.stats
rocksdb.compaction-stats
rocksdb.dbstats
# 关键指标:
# compaction.bytes_read: Compaction 读取字节数
# compaction.bytes_written: Compaction 写入字节数
# stall.micros: 写入停顿时间
# block.cache.hit: Block Cache 命中次数
# block.cache.miss: Block Cache 未命中次数
# bloom.filter.useful: Bloom Filter 有用次数

八、总结#

主题核心要点关键词
核心思想追加写入将随机 I/O 转化为顺序 I/O,代价是读取需检查多层级顺序写入, 多层查找
MemTable跳表实现,O(log N) 查找和插入,并发友好跳表, 并发
SSTable有序文件格式,索引块 + 数据块 + 布隆过滤器索引块, 有序文件
CompactionLeveled 牺牲写放大换低读放大,Tiered 牺牲空间放大换低写放大Leveled, Tiered
布隆过滤器用 10 bits/key 的空间换取 90%+ 的无效 I/O 消除误判率, 空间效率
调优写密集用 Tiered + 大 Buffer,读密集用 Leveled + 大 Cache + 强 Bloom读写取舍

支持与分享

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

LSM 树深入
https://blog.souloss.com/posts/storage/storage-lsm-tree-deep-dive/
作者
Souloss
发布于
2025-10-10
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时