☰
为什么LSM树写比读快?存储引擎核心原理深度解析
2026/10/16 4:14:15 网站建设 项目流程

1. 黑胶唱片的启示:介质特性决定了你优化什么

1.1 为什么黑胶适合“一次刻录,万次播放”

老式黑胶唱片有个很有意思的特性:压制过程极其昂贵,一块母盘要经过电铸、制版、压片多道工序,生产线一旦开动,成本几乎固定。但播放过程极其廉价,一张唱片可以在唱机上转几千次,唱针在沟槽里滑过,拾取的是几十年前刻下的物理纹路。

存储系统里也存在同样的取舍逻辑。当你面对一个“写多读少”或者“写频繁读分散”的业务时,最忌讳的就是拿“黑胶思维”去设计:什么都想一次写入、永久高效读取,结果写路径被拖垮。反过来,LSM树(Log-Structured Merge Tree)的设计哲学与黑胶唱片恰好相反——它把写入变成廉价的追加操作,把读取变成“多付出一点查找成本”的层级检索,换来的是整体吞吐的大幅提升。

这篇文章想聊清楚一个问题:为什么在LSM树这类存储引擎里,“写”会比“读”快?这个结论反直觉,因为绝大多数业务系统里,读操作都远比写操作快——缓存、索引、预取全是为读服务的。但到了LSM树这里,剧本完全颠倒。

1.2 从唱针到磁盘磁头:随机访问的物理代价

黑胶唱片的读取是纯顺序的:沟槽一圈圈往外扩展,唱针跟着沟槽走,不需要跳来跳去。如果唱歌中途想听第三首,得手动把唱针抬起来挪过去。这个“挪唱针”的动作,就是随机访问的物理代价。

磁盘也一样。磁头在盘片上定位一个数据块,需要做三件事:寻道(把磁头臂移到目标磁道)、旋转等待(盘片转到目标扇区)、传输(读取数据)。前两步纯粹是机械运动,微秒到毫秒级别;第三步才是真正的数据读取,速度取决于盘片转速。机械硬盘的顺序读能达到每秒一两百兆,但随机读的IOPS往往只有一两百——差了三个数量级不止。

后来SSD普及,机械寻道的成本消失了,但随机与顺序的差距依然存在。闪存的读写以页(Page)为单位,擦除以块(Block)为单位,小块随机写入会触发更复杂的映射和垃圾回收,延迟依然明显高于纯顺序追加。这也是为什么直到今天,存储引擎设计者依旧死磕顺序IO。

1.3 数据结构的终极使命:把物理特征放大成性能优势

数据结构存在的意义,不是“长得好看”,而是匹配底层介质的物理特性。B+树把数据组织成有序的树形结构,每一个节点对应磁盘上的一个页,树高只有三四层,做点查时从根到叶子走一次就够——这是为“读多写少”场景量身定制的,但代价是每一次写操作都可能触发页分裂、节点重平衡,还要把随机写落到磁盘的多个位置。

LSM树反着来:不维护一个始终有序的全局结构,而是先把写入累积在内存里,再以顺序追加的形式批量落盘,后续通过后台合并(Compaction)慢慢把数据整理成有序的层级。写路径被简化为“加到内存”和“追加写文件”两步,每一步都是顺序操作。这就是LSM树写比读快的物理根源。

2. LSM树的写路径:把每一次随机写都改成追加写

2.1 核心构件拆解:WAL、MemTable、Immutable MemTable、SSTable

要理解LSM树的写入,先认识四个角色:

  • WAL(Write-Ahead Log,预写日志):每次写入先追加到磁盘日志文件,保证崩溃恢复,不丢数据。它就是“记账本”上的流水。
  • MemTable(内存表):写入在内存中的有序结构,通常是一棵跳表或红黑树,维护当前所有活跃写入的有序视图。
  • Immutable MemTable(不可变内存表):当MemTable满了,把它冻结成只读状态,等待后台刷盘;新写入继续进新的MemTable。
  • SSTable(Sorted String Table,有序字符串表):Immutable MemTable落盘后生成的文件,内部数据按Key有序,分层管理。

画个简单流程就是:写请求 → 追加WAL → 更新MemTable → MemTable满了 → 变成Immutable → 后台刷盘成SSTable → 后续Compaction合并。

2.2 一次写入请求走过的完整路径

以RocksDB或LevelDB这类经典实现为例,一条写请求的完整路径大致是:

  1. 追加WAL日志:把写入记录以顺序追加的方式写到磁盘日志文件。这一步是纯顺序IO,而且可以批量合并,多个写入共用一次fsync。
  2. 写入MemTable:在内存跳表里插入记录,维持Key有序。内存操作纳秒级完成,不需要触碰磁盘。
  3. 返回确认:如果WAL刷盘成功,系统就可以向客户端返回成功。数据此刻还没有真正落到SSTable里,但已经有WAL保底。
  4. 后台刷盘:MemTable达到阈值后变成Immutable,后台线程把它整个写成SSTable文件。因为内存表内部有序,落盘文件天然有序,写入过程是连续的地址空间延伸。

这套流程的精髓在于:无论写多少数据,最终落盘的形态都是顺序追加的新文件,而不是在已有文件的中间某个位置做原地更新。原地更新意味着寻道+读改写,顺序追加意味着只要挪一下“文件末尾指针”。LSM树把随机写拆解成“内存里随机”+“磁盘上顺序”,而这个随机最终也被跳表的有序插入吸收了,整体呈现的就是写路径的高吞吐。

2.3 删除也是“写”,不是“抹掉”

还有一个新手容易忽略的点:LSM树里没有物理删除。

删除一条记录,不是去SSTable里把对应数据“抹掉”,而是写入一条带有删除标记(Tombstone)的记录。真正的清理发生在后台Compaction,那时候带有删除标记的数据才会被丢弃。为什么这么设计?因为原地删除本质上是一次随机写,代价太高;而追加一条删除标记是顺序写,代价极低。用户感知到的“删除”瞬间完成,底层只是记了一笔账。

这带来一个有意思的副作用:如果你在短时间内写一条、删一条交替进行,数据量非但不会减少,反而会膨胀——因为删除标记本身也是数据。不少人在实践里踩过这个坑:线上删了大量过期数据,磁盘空间却不降反升,直到下一次Compaction执行完才真正回收。

3. 写为什么真的快:磁盘顺序IO的物理优势

3.1 一组数字带来的震动

我直接给一组经验数据,大家感受一下数量级差异。普通机械硬盘顺序写吞吐大约100~200MB/s,随机写IOPS大致在100~300之间。假设平均每次写入4KB,随机写每秒只能写入0.4~1.2MB,顺序写每秒能写25万次4KB。差距是百倍级的。

SSD的情况好一些,但顺序与随机之间依然有几倍到十几倍的差距,尤其当写入块比较小(4KB、8KB)时,随机写会触发闪存页的读改写流程,性能显著下滑。只要把随机写变成顺序写,吞吐的提升都是肉眼可见的。

LSM树的全部设计,本质上都在做这一件事:把写操作从随机IO池子里捞出来,扔进顺序IO的传送带。黑胶唱片刻录很慢,但播放很快;LSM树的写入很快,代价是后端的Compaction慢慢整理——这不只是类比,底层逻辑是相通的。

3.2 MemTable缓冲与批量落盘:两级加速引擎

第一级加速在内存:MemTable吸收全部随机写,把无序的请求在内存里整理有序。第二级加速在磁盘:刷盘时不是一条一条写,而是把整个有序集合连续写成SSTable,一次刷盘几百MB甚至上GB,全是顺序IO。

批量带来的另一个好处是减少写放大。写放大(Write Amplification)指“用户写入1字节,底层实际写入了多少字节”。如果没有批量,每一条写入都对应一次磁盘IO,写放大接近1但效率极低;有了批量,数据可以在内存里积攒,落盘时整块输出,虽然由于Compaction还有额外写入,但单位数据分摊的IO成本大幅下降。合理的LSM配置下,写放大可以控制在几倍以内,换来的是远高于B+树的写入吞吐。

3.3 一个容易被误解的点:写放大不是坏事而是交换条件

说到写放大,很多人一听到就皱眉。但实际上,写放大是LSM树用“稍微多写一点”交换“大量随机写变顺序写”的代价。类比来说,黑胶翻录一次需要把整段音轨重新刻一遍,看似浪费,但换来了后续播放时无需跳轨的高效。

写放大真正需要警惕的是失控:如果Compaction策略配置不合理,同一份数据可能在短时间内被反复合并,磁盘磨损和IO消耗都会飙升。这个放到第五节细讲。这里想强调的是,判断一个存储引擎适不适合你的业务,不是看它有没有写放大,而是看它在你的读写模型下总体代价是否可接受。

4. 读为什么没那么快:层层查找的代价

4.1 一次点查要翻多少层

写入有多顺滑,读取就有多费劲。LSM树没有一个“全局有序结构”可以直接定位数据,数据分散在内存MemTable、零层的SSTable、一层、二层……每一层里。读一条数据时,系统按“最新数据优先”的原则,从上往下层层查找:先查MemTable,再查Immutable MemTable,然后从第0层开始逐层往下,每一层的SSTable都要看数据可能在哪个文件里,然后读文件定位。

最坏情况下,一次点查要翻遍所有层,这就是所谓的读放大(Read Amplification)。层数越多,读放大越严重。这也是为什么LSM引擎都不建议把层数调得太大,默认配置通常控制在6~7层以内,再往上读性能就难看了。

4.2 布隆过滤器:拦截“一定不存在”的查找

LSM树用布隆过滤器(Bloom Filter)来缓解读放大。它的思路很简单:每一层、甚至每个SSTable文件,都挂一个位图数组,用来回答“某个Key是否可能在这个文件里”。布隆过滤器可以明确说“不存在”,没法保证“一定存在”,但这就够了。

查一层之前先问布隆过滤器:如果它说不在,直接跳过这个文件,省一次IO上的文件读。布隆过滤器本身在内存里,几十字节就能挡住百万级Key的判定,代价极小。实测下来,开启布隆过滤器之后,LSM树的读性能能提升好几倍——尤其是那些大量“读不存在的Key”的场景(比如防穿透查询、去重判断),收益尤其明显。

4.3 缓存与融合表:尽量把读命中留在内存

再往下优化读,思路就是“让热点数据别落到磁盘”。Block Cache缓存了SSTable中被读取过的数据块,SkipList的内存结构天然提供高速查询。如果把一个热点表完全加载到Block Cache里,读性能可以达到纯内存级别。

还有一类优化叫“融合表”(Merged SSTable)或者通过Compaction把查询频繁的数据提前合并到高层,减少查过的层次。这些本质上都是同一个方向:读操作要尽量少穿越层级,少碰磁盘。

4.4 点查和范围扫描的差异

点查(Point Query)按单个Key定位,可以靠布隆过滤器快速裁剪范围。范围扫描(Range Scan)则完全不同,它需要遍历多个SSTable,每个文件内部虽然有序,但跨文件的数据顺序不是全局有序的,需要在多路归并中合并。数据分布在多少层,扫描就要同时归并多少个有序流。

所以LSM树的“写比读快”在范围扫描场景下体现得尤为明显——扫描性能往往比B+树差不少。如果业务里有大量范围查询需求,选择LSM树要非常谨慎,除非你可以用更小的SSTable文件、更激进的分层策略来压制读放大。

5. Compaction后台合并——你躲不掉的“整理账单”

5.1 为什么必须合并:层级失控会拖垮一切

如果只追加不合并,LSM树会迅速崩溃。原因很简单:SSTable文件会越来越多,每一层越来越乱,读一条数据的成本随文件数量线性增长;同时,过期版本和删除标记永远无法清理,磁盘空间被无意义的数据占满。Compaction就是LSM树的“保洁人员”,定期把多层文件合并成更少、更大的文件,去掉过期版本,按Key有序重写。

5.2 两种压缩策略的取舍:水平压缩与大小分层

主流的Compaction策略有两大类:

  • Level Compaction(水平压缩):每层维护Key范围互不重叠的SSTable,层数固定,合并时选取与某个Overlap范围相关的文件做多路归并。读放大稳定,但合并时可能涉及较多文件,写放大偏高。
  • Size Tiered Compaction(大小分层压缩):把大小相近的文件归并成更大的文件,桶之间不强制不重叠。写放大低、合并简单,但读放大较高,因为一个Key可能出现在多个未合并文件里。

选择哪种策略,实质是在“读的稳定性”和“写的开销”之间做权衡。RocksDB默认用Level Compaction,写多读少、数据量大、期望长尾延迟可控的用Level更稳;日志型、时序型、写入要求最大吞吐且读压力小的场景,Size Tiered反而更好。

5.3 合并风暴怎么防:限速、分片、错峰

Compaction最怕的是“合并风暴”——后台任务占用大量IO和CPU,把前台读写拖垮。这里有几个通用手段:

  • IO限制(Rate Limiter):给Compaction设置最大IO带宽,宁可让合并慢一点,也不让它抢占前台流量。
  • 分片(Partition/Shard):把数据按Key范围拆成多个独立分片,每个分片独立维护层和Compaction,单分片的合并压力被天然打散。
  • 错峰调度:把合并任务调度到业务低峰期执行,配合限速实现更平稳的运行曲线。

这几个手段看起来简单,但实际线上调优时很讲究。比如限速设置得过低,落后数据太多,最终仍然会触发紧急合并;分片切得太碎,单片的Compaction效率又上不去。我见过不少系统,功能跑得好好的,跑了几个月之后突然IO爆炸,查到最后都是Compaction队列积压造成的。

6. 从原理到实战:LSM树的选型与调优心得

6.1 什么场景适合LSM树

从上面的分析可以直接推断LSM树的适用场景:

  • 写入密集型业务:比如日志采集、埋点上报、物联网数据、时序数据库。这类业务写流量大、数据结构简单,读请求往往也是按时间范围扫最新的数据,LSM天然契合。
  • 消息队列和流处理:消息持久化本质上也是追加写,消费时按偏移量顺序读,LSM的顺序IO优势非常明显。
  • 缓存/键值存储的底层引擎:大量写操作需要快速落盘,不要求立刻对全量历史数据做复杂查询的,比如某些工业级键值组件。

在这些场景下,你会明显感觉到写路径极其顺畅,吞吐量轻松跑满磁盘带宽,读请求在布隆过滤器和缓存的加持下也能维持较好的表现。

6.2 什么场景别硬上LSM

反过来,如果业务有这些特征,选择LSM树要三思:

  • 读多写少,热点读密集:比如报表查询、用户中心的配置读取。B+树的原地更新和树形查找在稳定点查上更占优。
  • 范围扫描密集且要求低延迟:LSM的读放大在范围扫描时会被放大,特别是数据分布在多层时,一次扫描可能触发大量文件IO。
  • 对尾延迟极端敏感:Compaction的“后台波动”难以完全消除,读长尾会比B+树明显。

这不是说LSM在这些场景下不能跑,而是性价比不高。存储引擎选型没有最好,只有最适合。

6.3 几个值得一试的读优化组合拳

如果你的业务已经跑在LSM引擎上,又想提升读性能,我从实践中总结了几条有效路径:

  1. 开启并调大布隆过滤器:遇到大量“查无此Key”的请求时,收益立竿见影。
  2. 让MemTable和Block Cache尽量大一些:把热数据留在内存层。
  3. 对长期不写入的冷数据做一次手动Compaction,把它们合并到低层大文件,减少点查穿越的层数。
  4. 避免在业务高峰期触发大规模Compaction,用限速和错峰调度把后台干扰提前排掉。

值得提醒的是,第3条操作有代价,它会产生额外的写放大,只适合对读延迟极其敏感或数据已经稳定的场景,不能频繁做。

6.4 黑胶唱片的另一面:读慢不是缺陷,是设计

回到开头黑胶唱片的类比。唱片压制昂贵、播放廉价,所以它的物理形态天然为播放服务。LSM树恰好相反:写入廉价、读取稍贵,于是它把数据组织成“多层待整理”的状态——这看起来不如B+树“整整齐齐”优雅,但正是这种“没人打扫的房间”换来了极高的写入吞吐。

我自己用过LSM树之后有一个很深的体会:很多系统的问题不在存储引擎本身,而在于使用者拿着黑胶唱片式的“读优先”预期去衡量LSM树,才会觉得“怎么读这么慢”。理解了“写比读快”这个本质,你就能明白那些看似奇怪的设计——墓碑标记、Compaction风暴、读放大——都是同一个取舍逻辑的自然延伸。理解了这一点,你在选型的时候,心里也就有了一杆秤。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询