44.1 存储引擎:B+ 树与 LSM 树
一句话
B+ 树是 “就地更新 + 原地读”:写入改页、读取走索引;LSM 是 “只追加 + 后台合并”:写入进内存表、满了刷成有序文件、后台合并——前者读快写稳,后者写快、读要付出 “查多层” 的代价。
两种结构
| 维度 | B+ 树 | LSM 树 |
|---|---|---|
| 写路径 | 找到页 → 修改 → 刷页(可能随机写) | 写内存表 → 刷成 SSTable → 后台 compaction |
| 读路径 | 从根到叶 O(log n) 次页访问 | 内存表 → L0 → L1…(可能要查多层) |
| 写放大 | 低(约 1–2×,取决于页大小与分裂) | 高(compaction 反复重写,常见 10–30×) |
| 读放大 | 低(一次索引定位) | 高(多层 + 无索引时线性扫) |
| 空间放大 | 低(页内空隙) | 中(多版本文件 + 待合并数据) |
| 典型 | MySQL InnoDB、PostgreSQL | RocksDB、LevelDB、Cassandra、HBase |
实测:写路径与读路径的差别(本机)
5 万条 100B 记录:
| 引擎 | 写入耗时 | 文件大小 | 点读 |
|---|---|---|---|
| B+ 树(sqlite3,WAL 模式) | 86ms | 5.4MB | 2 万次 71ms |
| LSM 模拟(内存表 1000 条 + 4 层合并) | 21ms | — | 5000 次 7ms(无布隆)/ 5ms(有布隆) |
两个可迁移的结论:① 写路径 LSM 快约 4 倍(追加 vs 就地改页 + 随机 IO);② LSM 的读路径依赖 “布隆过滤器 + 层内有序”——实测布隆过滤器让 5000 次点读少读了 2521 个文件(33%)。没有索引的 LSM(直接线性扫)在本机 2000 次点读要 2981ms、扫了 4900 万条记录——这就是 “读放大” 的真实样子。
# LSM 的读路径:逐层找,先过布隆过滤器
def get(k):
if k in memtable: return memtable[k]
for level in levels: # L0 → L1 → ...
for run in level:
if k not in run.bloom: continue # 90%+ 的文件在这一步被跳过
i = bisect(run.keys, k) # 层内有序 → 二分
if i < len(run) and run.keys[i] == k: return run.values[i]
反模式
| 反模式 | 后果 |
|---|---|
| 用 LSM 存 “大量小记录 + 频繁点查” | 读放大 + compaction 抖动,P99 飙升 |
| 用 B+ 树存 “写多读少 + 顺序追加” | 随机写放大,写吞吐上不去 |
| LSM 不做布隆过滤器 | 每次点查扫多层文件 |
| compaction 不限速 | 后台合并抢 IO,前台延迟尖刺 |
| 大 value(> 1MB)直接进 LSM | compaction 搬运巨大,写放大爆表 |
| 只看平均延迟 | compaction 尖峰被平均掩盖 |
三条铁律
🔑 写多读少选 LSM,读多写少选 B+ 树;两者都没有 “全面更优”。
🔑 LSM 的读性能靠 “索引 + 布隆 + 层数控制”,不靠 “文件少”。
🔑 compaction 必须限速与可观测,否则它会在你最忙的时候抢 IO。
自检
Q1 为什么 LSM 的写放大比 B+ 树高,写入却更快?
▸ 答案:因为 写放大发生在后台:前台只写内存表(顺序追加,极快),compaction 在后台慢慢重写。B+ 树则是 “前台就地改页”,可能触发随机 IO 与页分裂。所以 “写快” 指前台延迟,“写放大” 指总 IO 量——两者不是一回事。
Q2 LSM 的读放大从哪来?
▸ 答案:来自 “要查多层”:一条记录可能在 memtable、L0、L1…直到最底层,最坏要查遍所有层。缓解手段是布隆过滤器(跳过不含该 key 的文件)、层内二分、以及控制层数(fanout 越大层越少)。
Q3 实测中布隆过滤器省了 33% 的文件读取,为什么不是 90%?
▸ 答案:因为布隆过滤器只能排除 “确定不包含” 的文件,命中率取决于 key 的分布与每层文件数:本实验里 5000 次查询涉及 7462 次文件读取,布隆跳过 2521 次(33%)。真实系统中如果 key 集中或层数少,跳过比例会变化——所以 “布隆能省多少” 必须实测,不能假设。
Q4 什么场景下 B+ 树明显优于 LSM?
▸ 答案:点查多、写少、要求稳定低延迟:读路径只需一次索引定位,没有多层查找与 compaction 抖动。典型如 OLTP 主库、账户/订单查询。反过来,写入吞吐要求极高、能接受读放大时,LSM 更合适。
Q5 LSM 的 “空间放大” 指什么?
▸ 答案:指同一份数据在多个文件中存在多个版本(旧版本要等 compaction 才清理),以及 “待合并的临时空间”。加上删除标记(tombstone)也要占空间,所以磁盘占用会高于逻辑数据量,直到 compaction 完成。
Q6 为什么 “大 value” 不适合直接进 LSM?
▸ 答案:因为 compaction 要把 value 整体搬运、重写多次:value 越大,写放大与内存压力越大。常见做法是 “大 value 存单独的文件/对象存储,LSM 只存引用(路径 + 偏移)”,把 “元数据” 与 “大块数据” 分开。
Q7 怎样判断 “该换存储引擎了”?
▸ 答案:看三个信号:写放大导致的 IO 饱和(compaction 长期追不上)、读放大导致的 P99 恶化(层数多、布隆失效)、空间放大导致的磁盘告警。三者任一持续出现,说明当前引擎与负载不匹配,而不是 “参数没调好”。
关联
- 前置:44.2 存储引擎:WAL 与崩溃恢复
- 并列:44.5 存储引擎:读写放大与空间放大、44.8 索引原理 (B+ 树 / 哈希 / 覆盖索引 / 最左前缀)
- 下游:44.16 OLAP 与列式存储、44.21 数据库选型方法