两种树,两种性格
B+ 树(MySQL InnoDB 的引擎)与 LSM-Tree(Log-Structured Merge-Tree)是存储引擎的两大流派:B+ 树为「读」优化(数据原地更新、索引有序——读是 O(log N) 的树查找——但每次写入都要「原地修改」(随机写——磁盘的随机 I/O 慢——B+ 树的写是随机的);LSM-Tree 为「写」优化(写入不原地改,顺序追加到内存与日志——顺序写的吞吐是随机写的几十倍——LSM 的写是顺序的——代价是读需要「多层查找」。
选型的直觉:写多读少的场景(日志、时序、消息、列存)选 LSM;读多写少的场景(OLTP 的点查)选 B+ 树——这篇拆解 LSM-Tree 为什么快、以及它的「放大三角」。
LSM-Tree 的结构:内存到磁盘的瀑布
写入的路径:MemTable(内存表)——写入先进内存的有序结构(跳表或红黑树——内存的写是快的);WAL(Write-Ahead Log)——同时追加到预写日志(防宕机丢失——顺序写);SSTable(Sorted String Table)——MemTable 满了之后整表刷盘成一个不可变的 SSTable 文件(「整表刷盘」是顺序写的关键——内存中排好序的整块数据一次性顺序写到磁盘——磁盘顺序写的吞吐极高)。
分层的结构(leveling):磁盘上的 SSTable 按「层」组织——Level 0(刚刷盘的文件,可能有重叠)、Level 1、Level 2……(每层数据不重叠、容量递增——「层」的规则(L1 的数据是 L2 的子集范围……——Compaction(合并):某一层满了,把它与下一层的重合数据归并排序合并成新的下一层文件——「合并」是 LSM 的核心后台活动——合并时删除被覆盖的旧版本、清除删除标记(tombstone)——「数据从热到冷的瀑布」:新数据在高层(小而热)、老数据在低层(大而冷)。
读取的路径:读一个 key——先查 MemTable → 再查不可变的内存表(immutable memtable)→ 然后从 L0 到 Ln 逐层查找(每层用布隆过滤器快速判断「这个 key 可能在这个文件吗」——布隆过滤器(Space-efficient 的「可能存在」判断——过滤掉大部分无关文件——找到就返回(高层的版本更新——读的成本 = 查找的层数——这就是 LSM 读慢的根源。
graph TD
A[LSM 写路径] --> B[MemTable 内存有序表]
B --> C[WAL 顺序追加日志]
B -->|满| D[SSTable 整表顺序刷盘 L0]
D -->|满| E[Compaction 归并到下一层<br/>清理旧版本与 tombstone]
F[LSM 读路径] --> G[MemTable → 逐层查找<br/>布隆过滤器跳过无关文件]
放大三角:写放大、读放大、空间放大
写放大(Write Amplification):实际写入磁盘的数据量与逻辑写入量的比值——LSM 的写放大:一个 key 写入后可能被 Compaction 反复重写(每层合并重写一次——写放大 = 层数的量级——B+ 树的写放大:原地更新 + 索引页的写——页的分裂与重写——「写放大」的度量(实际磁盘写字节 / 逻辑写字节——LSM 与 B+ 树的写放大对比:LSM 高(多层的重写)、B+ 树低——但 LSM 的「写吞吐」仍高(顺序写的效率弥补了放大的量——「放大的成本 vs 顺序的收益」的净账——LSM 的写吞吐通常仍数倍于 B+ 树。读放大(Read Amplification):读一个 key 需要的 I/O 次数——LSM 的读放大:逐层查找(可能查多个文件——B+ 树的读放大:树深度(3-4 层的页读——「读放大」的缓解:布隆过滤器(跳过 99% 的无关文件)、key 缓存(热 key 的内存命中——「读是 LSM 的弱项」——点读尚可、范围读稍差(多文件归并。空间放大(Space Amplification):实际占用空间与有效数据的比值——LSM 的空间放大:旧版本未合并前共存(同一 key 的多版本——tombstone 未清除——「空间放大」的缓解:Compaction 的清理(合并时清除——「Compaction 不及时=空间膨胀」——三角的权衡:写放大、读放大、空间放大三者互相牵制——减少一个必然增大另一个——「Compaction 的策略」就是这个三角的调节旋钮( leveled( leveled compaction:读友好写放大中、tiered(tiered:写友好读放大高、FIFO(纯淘汰:写最快——「策略的选择」按业务读写比。
Compaction 的调优与工程现实
Compaction 的代价:「后台的资源消耗」——Compaction 的 I/O 与 CPU(与前台写入争抢资源——「写入的停顿」(Compaction 时的写延迟毛刺——「毛刺」是 LSM 系统的典型现象(监控里的写延迟周期性尖峰——「限速的 Compaction」(rate limiter——后台合并的 I/O 限速——「限速的权衡」(限速太紧:层积压、读放大涨;太松:前台受挤压——「限速的调优」是 LSM 运维的核心。调优的参数清单:MemTable 大小(write_buffer_size——越大刷盘越少、宕机恢复越久——SSTable 的大小(target_file_size——层数与比例(max_level、level_ratio——触发阈值(level0_file_num_compaction_trigger——「参数的默认值」通常合理(RocksDB/RocksDB 的默认配置——先测默认再调——「业务特征的调优」:写多读少的场景调大 write_buffer(减少刷盘)、读敏感场景增加布隆过滤器的位数(提高过滤精度。工程现实的案例:RocksDB 的广泛应用(Kafka(KRaft 与内部存储)、TiKV(TiDB 的存储层)、Flink 的状态后端、CockroachDB 与 TiDB 的引擎——「LSM 的分布式化」:每个节点的 RocksDB + 分布式层的路由——「LSM+B+树的混合」(TiKV 的 Raft 与 RocksDB、「B+ 树的写优化」(WiscKey 的思路(key 与 value 分离——key 在 LSM、value 在 vLog——「value 分离」减写放大(大 value 的重写成本被剥离——「LSM 的进化」持续在发生**。
LSM 的进阶:RocksDB 的调优与「LSM 的变体」
LSM-Tree 的进阶:RocksDB 的调优实践与 LSM 的变体演进。RocksDB 的调优清单:「MemTable 的调优」:write_buffer_size 的权衡(默认 64MB——「调大的收益」(刷盘次数少、「调大的代价」(内存占用、flush 的暂停——「内存的预算」(列数×MemTable——「多 CF 的内存分配」(Column Family 各自的 MemTable——「CF 的内存份额」。「Bloom Filter 的调优」:bloom_filter_bits_per_key(默认 10——「10 bit 的误判率」(约 1%——「误判率与内存」的权衡——「Bloom 的放置」(Index/Filter Block 的缓存——「Bloom 缓存的优先」。「Compaction 的调优」:level_compaction_dynamic_level_bytes(「动态层」(层的容量动态——「动态层的收益」(写放大均匀——「universal compaction」(tiered 的 RocksDB 实现——「策略的选择」按读写比。「RocksDB 的监控」:「关键指标」:compaction pending(合并积压——「积压的告警」(写停顿的前兆——「write stall」(RocksDB 的写停顿机制——「stall 的原因」(L0 过多、MemTable 满——「stall 的监控」。LSM 的变体演进:「WiscKey 与 KV 分离」:key-value 分离的设计(key 在 LSM、value 在 vLog——「分离的收益」(大 value 不参与 Compaction——「分离的代价」(多一次查找——「GC 的机制」(vLog 的垃圾回收——「分离的适用」(value 大的场景——「Titan」(TiKV 的 KV 分离实现。「LSM 与 B+ 树的融合」:「混合引擎」的探索——「LSM 的 KV 特化」(LLSM、更激进的设计——「学术到产品」的距离。「LSM 在新硬件上的进化」:「持久内存(PMem)」(PMem 上的 LSM——「PMem 的特性」(字节寻址、持久——「PMem 的 LSM 设计」——「PMem 的退潮」(Intel 放弃 Optane——「新硬件的教训」。LSM 的进阶总结:RocksDB 的调优(MemTable、Bloom、Compaction、监控)+变体的演进(KV 分离、混合引擎、新硬件)——LSM 不是「一个算法」,是「一个可调优的引擎框架」——「调优的能力」=「业务特征×引擎参数」的匹配——监控 stall、理解放大三角、按业务调参——三步让 LSM 引擎从「能跑」到「跑得优」。
LSM 高频问答
收尾前回答三个高频问题。「LSM 能替代 B+ 树做 OLTP 吗?」——正在发生但要谨慎:「LSM 做 OLTP」的尝试(TiDB 的 TiKV 是 LSM、CockroachDB 也是 LSM——「LSM OLTP 的可行」——「LSM 的点读优化」后(布隆+缓存——「点读已接近 B+ 树」——「事务与范围扫描」仍是 B+ 树强项——「结论」:LSM OLTP 在快速成熟、但「替换 InnoDB」要评估全面——「LSM 适合新系统」的判断。「Compaction 期间会影响写入吗?」——会:「write stall」的机制(L0 文件数超限、MemTable 满——「stall 的两级」:减速与停止——「stall 的监控」(compaction_pending、is-stop——「避免 stall」的方法:Compaction 加速(限制写入——「写入的 self-limiting」——「stall 的指标」在 Grafana 的配置。「LSM 的 TTL 支持呢?」——支持:「TTL 的 compaction 清理」(过期的 key 在 Compaction 时删除——「TTL 的场景」(日志类有时效的数据——「TTL 与空间放大」(过期数据的清理靠 Compaction——「不 Compact 不清理」——「TTL 的监控」(过期数据的残留——LSM 的问答收束:LSM 的三个心智模型——「写入是瀑布(内存→L0→Ln)、读取是侦探(逐层排查)、Compaction 是保洁(不干保洁全乱)」——运维 LSM 的三件事:盯 stall、调 Bloom、按业务选 Compaction 策略——以及一个终极判断:「写多读少」选 LSM、「读多写少」选 B+ 树——选错了,后期调参都救不回来。
LSM 的选型检查清单
选型时的检查清单:「选 LSM 前的五问」:一问读写比(写:读 > 1:1 才考虑 LSM);二问点查延迟(P99 要求 5ms 内慎选);三问范围扫描(大范围扫描多则慎选);四问数据大小(value 大则考虑 KV 分离);五问运维能力(Compaction 的调优能力)。五问过后,再选 Compaction 策略与参数。「LSM 的告警清单」:stall 频率、历史列表、磁盘空间、Compaction 队列——四个指标天天看。
小结
LSM-Tree 的机制与权衡:写路径的瀑布(MemTable+WAL 顺序追加→整表刷盘 SSTable→Compaction 分层归并——顺序写是快的核心);读取的多层查找(布隆过滤器缓解——读是弱项);放大三角(写放大——Compaction 的重写、读放大——逐层查找、空间放大——旧版本共存——三者互相牵制,Compaction 策略是调节旋钮);工程现实(Compaction 的资源消耗与写毛刺、参数调优按业务读写比、RocksDB 的广泛应用与 WiscKey 的进化)。给选型者的判断框架:「写吞吐敏感 + 读可容忍多层查找」的场景选 LSM(消息、时序、日志、元数据)、「读延迟敏感 + 写可控」的场景选 B+ 树(OLTP 事务)——以及一个运维提醒:监控 LSM 系统(RocksDB)时,Compaction 的队列深度与写延迟毛刺是健康度的第一指标——毛刺变长,通常是 Compaction 跟不上了。
B+ 树把「写」变难换「读」的优雅,LSM 把「读」变难换「写」的吞吐——存储引擎没有更好的树,只有更匹配业务读写比的树。
读者留言
COMMENTS 暂无还没有留言,来说第一句?