LSM树:一次写放大与Compaction风暴的救赎记录

为什么我差点砸了键盘——LSM树的写放大之痛

为什么我差点砸了键盘——LSM树的写放大之痛
为什么我差点砸了键盘——LSM树的写放大之痛

去年深秋,凌晨三点,我在公司盯着监控大盘发呆。一个时序数据库的存储节点,在写入量达到120万点/秒后,磁盘I/O突然飙到100%,延迟从3ms涨到2秒。查了半天才发现,是LSM树的compaction把磁盘带宽吃光了。当时我就想——这破树,谁发明的?

不过冷静后复盘,其实问题早有预兆。LSM树(Log-Structured Merge-tree)的核心思想太迷人了:把随机写转成顺序写,充分利用机械盘或SSD的顺序写带宽。机械盘时代,顺序写比随机写快两个数量级;即使现代NVMe SSD,顺序写也能达到3GB/s,而4KB随机写可能只有200MB/s——差距不是一倍两倍,是十倍百倍。这一下子就把写入吞吐救活了。但是,读呢?还有空间放大呢?全都丢给compaction去擦屁股。好吧,这就是万恶之源。

说起来,我第一次接触LSM树是在LevelDB源码里。看到MemTable跳表、immutable MemTable、SSTable分层,觉得设计真优雅。然而真正上了生产环境,才发现那些论文里轻描淡写的东西,能把人折磨到怀疑人生。

一颗树,两份钱——写入的高速与读取的暗坑

用大白话说,LSM树就像你随手在便签上记东西,写满了就贴到本子里,然后过一段时间把一堆便签整理成一份整洁的目录。写的时候超级爽——不就是往内存里扔吗?MemTable就是一个内存里的有序结构(比如跳表),数据来了往里塞,达到一定大小(比如64MB)就冻结,变成immutable MemTable,然后后台线程把它刷成SSTable文件。这个文件就是顺序写的产物,一次性连续写入磁盘,快得飞起。

但读的时候就惨了。你要找一条记录,得先查MemTable,再查所有未合并的immutable MemTable,然后一层层翻SSTable——从L0到L6。L0的文件之间可能key区间重叠,所以每个都得查;L1以下每层内部不重叠,可以二分查找。最坏情况,读一次要访问十几个文件。这就是读放大(read amplification)。

我一个项目里对比过:用B+树引擎(InnoDB)做小数据量(500G)的读写混合场景,点读延迟稳定在0.2ms;换成RocksDB默认配置,同样key分布,延迟直接飙到2ms,P99到了15ms——因为那一次读可能触发多层SST的bloom filter判断和block fetch。但写入吞吐呢?InnoDB只能跑到80MB/s,RocksDB轻松300MB/s。数据不会撒谎,就是一场交换。

RocksDB LSM tree structure with memtable and sstable levels
RocksDB LSM tree structure with memtable and sstable levels

这个图说明了一切:写入像瀑布,读取像爬山。工程上的平衡点,全看你怎么调参数,怎么选择compaction策略。说到底,LSM树的美感就在这里:用简单的顺序写和归并排序,撑起海量数据的写入,但你必须付出读性能的代价,以及精心维护这场永不停歇的归并。

三个大坑,我踩过,你绕开——Compaction的工程陷阱与破解

在LSM树的落地中,Compaction是核心,也是万恶之源。它负责把多个小SST合并成一个大SST,删除掉过期的tombstone,回收空间。但搞不好,系统就废了。下面是我用血泪换来的三个最关键的陷阱和解决方案。

陷阱一:写放大失控,磁盘寿命狂掉

什么是写放大?你实际写1MB数据,磁盘却写入了10MB。因为在compaction时,同一个key可能被反复读出、合并、写回新文件。Leveled Compaction(如RocksDB默认)最夸张:在最坏情况下,L0到L1的合并可能导致20倍的写放大。有一次我发现SSD的DWPD(每日全盘写入次数)在三个月内就用光了一半,吓得赶紧查——其实才写了5TB用户数据,但磁盘写入总量已经超过100TB。

解决之道:第一,转向tiered compaction(比如Cassandra的Size-Tiered),它减少合并次数,但会带来更大的空间放大和读放大。第二,使用key-value分离存储(参考WiscKey论文),把value单独存在一个value log里,LSM树只存key和value的偏移,这样compaction时只搬动key,写放大骤降。我就在一个日志系统里用TiKV的Titan引擎,写放大从15倍降到了3倍,爽。第三,调大层级大小比例,比如让L1是L0的10倍而不是2倍,减少合并频率。

陷阱二:Compaction风暴导致延迟尖刺

最怕的就是白天业务高峰,后台compaction突然发力,占满磁盘IO和CPU,前台读写全堵死。有次压测,P99延迟从10ms瞬间跳到500ms,持续20秒——就因为L0文件数达到阈值,触发了一次紧急compaction。这种现象就像高速公路上突然来了辆保洁车,慢悠悠扫马路,所有车都慢下来。

解法:RocksDB提供了很多控制手段。设置rate_limiter限制compaction的写吞吐,我用了一个简单公式:rate_limiter = 磁盘顺序写带宽 * 0.6。比如磁盘能写1GB/s,就限制到600MB/s,留出余量给正常写入。另外,设置compaction的线程优先级为低(比如Linux的ionice),让它别跟前台线程抢。更精细的做法是,只在业务低峰期允许compaction全速,其他时间限速。我用crontab在凌晨2点调整options.compaction_style和max_background_compactions,让系统自己“趁没人时偷偷打扫”。

compaction storm latency spike monitoring graph
compaction storm latency spike monitoring graph

陷阱三:空间放大,磁盘默默被吃光

你发现没有,LSM树的数据文件永远比实际数据量大?因为旧版本的key还在未合并的文件里,还有tombstone标记。空间放大通常有1.5~2倍,但如果不做任何限制,可能会严重到3倍。我就遇到一次监控告警磁盘使用率从60%一夜之间到95%,原因是没有及时删除过期数据,compaction跟不上写入速度,大量陈旧数据堆积。

应对:首先,确保compaction能跟上写入,可以通过监控pending compaction bytes来预警。RocksDB有compaction_pending度量,如果持续上涨,就要加机器或调参。其次,使用dynamic leveled compaction(RocksDB的新功能),它根据数据量动态调整层级,减少空间浪费。最后,定期强制Full Compaction(谨慎!在低峰期),我用一个脚本每周日凌晨执行一次CompactRange,把所有SST合并一遍,空间回收效果显著,但写放大会有瞬时高峰,需监控。

这三个坑,每一个都能让系统雪崩。不过,一旦趟过去,LSM树真的能给你带来山呼海啸般的写入能力。

不是银弹——选型的冷静权衡

不是银弹——选型的冷静权衡
不是银弹——选型的冷静权衡

没有什么万能的技术。LSM树最适合写多读少、顺序读居多、value较大的场景,比如时序数据、日志收集、消息队列持久化。如果你要的是微秒级点查、大量随机读、而且数据量小于内存,那B+树(或其变种)可能更好。我常看到有人用RocksDB做OLTP,然后抱怨P99太高——兄弟,用错了地方。

另一个要命的问题:SSD的写擦除周期。LSM树顺序写很友好,但写放大加速磨损。如果有成本敏感的冷存储层,我宁愿把历史数据转存到对象存储,LSM只放热数据。这种冷热分离的实践,在云原生架构里已经成了标配。

说实话,每次看到磁盘上那一堆SST文件,按层级规整排列,就像一个强迫症整理过的书架,又有一种别样的美感。这种工程上的妥协与优化,比纯粹的理论完美更吸引我。

免责声明:市场有风险,选择需谨慎!此文仅供参考,不作买卖依据。如有侵权请联系删除。
文章名称:LSM树:一次写放大与Compaction风暴的救赎记录
文章链接:https://lfdjt.com/info_23_7742.html