B+ 树每次更新都是一次随机写入:找到目标页面、加 Latch、原地修改、写 WAL、刷盘。写入 QPS 一高,磁盘随机 I/O 跟不上,延迟飙升。这就是 B+ 树的致命伤——原地更新把写入绑死在随机 I/O 上。
LSM 树(Log-Structured Merge Tree)换了一条路:所有写入都是追加,随机写变成顺序写。代价是读取时要检查多个层级,以及后台 Compaction 持续消耗资源。这是一场用读性能和空间换写吞吐的交易。
一、LSM 树的核心思想
1.1 从 B+ 树到 LSM 树
| 维度 | B+ 树 | LSM 树 |
|---|---|---|
| 写入方式 | 原地更新 | 追加写入 |
| 写入 I/O | 随机 I/O | 顺序 I/O |
| 写入延迟 | 高(需查找+加锁+修改) | 低(追加到内存) |
| 读取 I/O | O(log N) | O(L × log N),L 为层级数 |
| 空间回收 | 原地删除 | Compaction 合并 |
| 后台开销 | 无 | Compaction 持续运行 |
1.2 LSM 树的层级结构
二、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) | 一般 | 高 |
跳表的核心优势是并发友好:插入只需修改局部指针,不需要像红黑树那样做旋转操作。RocksDB 和 LevelDB 都使用跳表实现 MemTable。
三、SSTable
3.1 SSTable 文件格式
SSTable(Sorted String Table)是 LSM 树在磁盘上的存储格式:
// 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 树的追加写入会导致两个问题:
- 空间放大:同一个 Key 可能在多个 SSTable 中存在(旧版本)
- 读放大:读取时需要检查多个 SSTable
Compaction 通过合并 SSTable 解决这两个问题:
4.2 Leveled Compaction
RocksDB 默认策略,也是 LevelDB 使用的策略:
| 层级 | 大小限制 | SSTable 数量 | 键范围 |
|---|---|---|---|
| L0 | 4 × MemTable | ≤ 4 | 重叠 |
| L1 | 10 × L0 | ≤ 10 | 不重叠 |
| L2 | 10 × L1 | ≤ 100 | 不重叠 |
| L3 | 10 × 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)先累积再合并:
4.4 Compaction 策略对比
| 维度 | Leveled | Tiered | FIFO |
|---|---|---|---|
| 写放大 | 高(每层重写) | 低(累积后合并) | 最低(不合并) |
| 读放大 | 低(层级有序) | 较高(同层重叠) | 高(需检查所有) |
| 空间放大 | 低(及时清理) | 较高(等待合并) | 高(旧版本保留) |
| Compaction 开销 | 高(持续运行) | 中(批量运行) | 无 |
| 适用场景 | 通用 | 写入密集 | 时序/日志 |
| 典型系统 | LevelDB/RocksDB | Cassandra | InfluxDB |
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")) # Trueprint(bf.might_contain("user:9999")) # False(大概率)5.2 布隆过滤器的假阳性率
| 位数/键 (m/n) | 哈希数 (k) | 假阳性率 | 空间开销 |
|---|---|---|---|
| 4 | 3 | 14.7% | 4 bits/key |
| 8 | 6 | 2.2% | 8 bits/key |
| 10 | 7 | 0.82% | 10 bits/key |
| 12 | 8 | 0.31% | 12 bits/key |
| 16 | 10 | 0.05% | 16 bits/key |
| 20 | 14 | 0.006% | 20 bits/key |
RocksDB 默认每个 Key 分配 10 位(假阳性率 ~0.82%)。对于 1 亿个 Key,布隆过滤器约需 120MB 内存。这是用空间换读取性能的经典权衡。
六、LSM 树的读取流程
6.1 读取路径
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 层文件数触发 Compactionopts.level0_file_num_compaction_trigger = 4
# L0 层文件数触发慢写入opts.level0_slowdown_writes_trigger = 20opts.level0_stop_writes_trigger = 36
# 每层大小倍数opts.max_bytes_for_level_base = 256 * 1024 * 1024 # 256MBopts.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/keytable_opts.block_cache = rocksdb.LRUCache(512 * 1024 * 1024) # 512MBopts.table_factory = rocksdb.BlockBasedTableFactory(table_opts)
# 压缩opts.compression = rocksdb.CompressionType.lz4_compressionopts.bottommost_compression = rocksdb.CompressionType.zstd_compression
db = rocksdb.DB("/data/rocksdb", opts)7.2 Compaction 调优矩阵
| 参数 | 写密集 | 读密集 | 均衡 |
|---|---|---|---|
write_buffer_size | 128MB | 32MB | 64MB |
max_write_buffer_number | 4 | 2 | 3 |
level0_file_num_compaction_trigger | 4 | 2 | 4 |
max_bytes_for_level_base | 512MB | 128MB | 256MB |
compaction_style | Tiered | Leveled | Leveled |
bloom_bits_per_key | 6 | 16 | 10 |
block_cache_size | 256MB | 2GB | 512MB |
7.3 RocksDB 统计信息
# 查看 RocksDB 统计# 通过 db.GetProperty() 获取rocksdb.statsrocksdb.compaction-statsrocksdb.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 | 有序文件格式,索引块 + 数据块 + 布隆过滤器 | 索引块, 有序文件 |
| Compaction | Leveled 牺牲写放大换低读放大,Tiered 牺牲空间放大换低写放大 | Leveled, Tiered |
| 布隆过滤器 | 用 10 bits/key 的空间换取 90%+ 的无效 I/O 消除 | 误判率, 空间效率 |
| 调优 | 写密集用 Tiered + 大 Buffer,读密集用 Leveled + 大 Cache + 强 Bloom | 读写取舍 |
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






