本节摘要:写入不断制造"混乱"——同一个键的多个版本散落各处、删除的数据只留了墓碑。Compaction 就是后台的"清洁与重组引擎":Minor Compaction 负责把内存固化到磁盘,Major Compaction 负责在磁盘内部多路归并、清理冗余、让数据逐层下沉。本节讲清两级压缩的触发、过程与代价,以及它如何塑造 LevelDB 的写放大与读放大。
阅读完本节,你应当能够:
LSM-Tree 的写入方式有个代价:数据从不覆盖,只是不断追加。结果就是磁盘上积累了大量冗余、过时、无序的数据碎片。同一个键可能有五个版本躺在五个文件里;被删除的键还留着"墓碑"占空间。如果放任不管,读取性能会如坠泥潭,空间也会被吞光。
Compaction 就是解决这个矛盾的后台引擎。它的使命有三:归并排序(把重叠的小文件合成有序的大文件)、垃圾回收(清掉旧版本和墓碑)、数据下沉(把数据从浅层推到深层,形成稳定金字塔)。
它不是可选的维护任务,而是系统正确性、性能与效率的基石。
当活跃 MemTable 达到阈值(write_buffer_size),转为 Immutable,后台启动 Minor Compaction——把 Immutable 中所有键排序,完整地、顺序地写入磁盘,生成一个 L0 SSTable。
关键特性:触发由写入负载驱动;对象是一个完整 MemTable;生成的 L0 文件内部有序,但多个 L0 文件之间键范围可能重叠。
Major Compaction 在磁盘内部做深度清洁,是压缩的主体。触发策略基于层级大小阈值:每层有个目标容量(指数增长,如 L1 为 10MB、L2 为 100MB),某层实际大小超限就触发它与下层的合并。
六步过程:
💡 关键直觉:把 Compaction 想成图书馆定期整理书架。新书(L0 文件)随意插在入口处,书架越堆越乱。管理员(Major Compaction)定期把入口的书按编号归位到主书架(深层),顺手扔掉破损的旧版本和借阅记录(墓碑)。整理期间入口暂时更挤(空间放大),但整理完书架找书就快了。
在 LSM 中,"删除"是插入墓碑标记。真正的物理删除要等到:包含该键墓碑的文件与包含其真实数据的文件在同一次 Compaction 中被合并,且墓碑序列号最新。这个"删除持久性"的语义延迟,是空间放大和时间回收延迟的根源。
| 现象 | 根源 | 调整方向 |
|---|---|---|
| 写放大高 | 数据多次重写 | 减少压缩频率或层级 |
| 读放大高 | 需查多个文件 | 加快 L0 清理、保持层内有序 |
| 空间放大高 | 旧版本未清 | 更频繁/更深的压缩 |
| 参数 | 作用 | 调优方向 |
|---|---|---|
| level0_file_num_compaction_trigger | 触发 L0->L1 的 L0 文件数 | 调低加速清理,但增写放大 |
| max_bytes_for_level_base / multiplier | 控制各层目标大小 | 决定数据金字塔形状 |
| compression | 块压缩算法(如 Snappy) | 省空间 I/O,但耗 CPU |
⚠️ 常见坑:认为压缩只是"后台小事",从不监控它的节奏。实际上 Compaction 的 I/O 会与前台读写竞争——尤其是在 HDD 上,压缩风暴能把前台请求的延迟打出一个大尖峰。第 6 章的监控指标会专门讲怎么盯住它。
写放大因子通常在 10 到 20 之间甚至更高——一个键从 L0 最终沉降到底层,要经历多次重写,具体取决于数据规模和压缩策略。这直接解释了为什么 SSD 场景下写放大是核心关注点:它不仅消耗 I/O 带宽,还实实在在缩短闪存寿命。另外,LevelDB 在压缩时会"善待"缓存——Compaction 读取的数据通常不放入 Block Cache,因为后台任务的数据访问模式是一次性顺序读,塞进面向随机读优化的 LRU 缓存只会"污染"它、挤掉真正有价值的热点数据。

Compaction 的触发条件是按层级容量(每层目标大小),而不是按时间或文件数。为什么?因为"整理到什么程度"本质上取决于"数据有多乱",而数据量是混乱度的最好代理。每层容量超限就触发合并,相当于给系统的"熵"设了阈值——熵到一定程度就强制降熵。
这个设计也解释了为什么容量按 10 倍递增:每层容量差距越大,Compaction 频率越低(写放大小),但层数相同时能容纳的总数据越大;差距越小,Compaction 越频繁但读放大越好。10 倍是"写放大、读放大、实现简单"三者权衡出来的经验值。理解了触发逻辑,你就知道调 max_bytes_for_level_base 这类参数时,实际上是在调整"系统容忍多少熵"。
多路归并排序是 Compaction 的核心计算:同时读多个有序文件,用堆或类似结构选出当前最小的键,写进新文件。这个操作把"读取多个文件的 I/O"转化为"一次顺序写新文件的 I/O",本质是用 CPU 的排序工作换取 I/O 的规律性。
但 CPU 不是免费的。极端情况下,Compaction 占用的 CPU 会让前台请求变慢——这就是为什么说压缩是"双刃剑"。RocksDB 引入多线程 Compaction,本质上也是想"用更多 CPU 并行度"去对冲"单线程压缩慢"的问题。理解了这个权衡,你就能理解为什么调优时要在"压缩频率"和"CPU 预算"之间找平衡。
写放大来自数据的重复搬运,但它不是均匀分布的。一个只写一次、从不更新的"日志型"键,它的写放大主要来自 Compaction 过程中的逐层迁移;一个被反复更新的"热键",每次更新都会在刷新时多存一份,加上迁移,写放大会更高。
所以从应用层看,减少写放大的第一个抓手是减少不必要的更新——比如把频繁的小字段更新合并成周期性的大批更新。第二个抓手是让数据尽早"沉到底层"(减少迁移次数),这取决于 Compaction 策略。这两个抓手一个在键值设计(第 6.2 节)、一个在引擎配置(第 6.1 节),都是实际可操作的。
Compaction 需要临时空间:合并时新旧文件并存,空间占用短暂上升。如果磁盘接近写满,压缩可能被卡住,进而引发写停顿甚至写入失败。所以 LSM 系引擎的运维铁律是:预留至少相当于一倍数据量的空闲空间。这条铁律源自空间放大的本质,不是 LevelDB 特有的缺陷——凡是"后台整理"的引擎都得遵守。
问:Minor 和 Major 是串行执行的吗? 标准实现里由同一个后台线程处理,但优先级不同——Immutable 落盘(Minor)优先,因为不尽快释放内存会阻塞写入;层级合并(Major)次之。
问:Compaction 会丢数据吗? 不会。多路归并的规则是"保留同键序列号最大的版本",这是严格定义的;只有"墓碑序列号大于任何现存数据版本"时该键才被整体丢弃——这正是删除语义的正确执行。任何数据丢失都意味着实现 bug,而测试体系(第 8.3 节)正是为杜绝这类 bug 而设的。
问:可以手动触发 Compaction 吗? 可以,通过 CompactRange 之类的接口。这在"删除大量数据后想立刻回收空间"的场景很有用,但要注意它是一次完整操作,可能耗时较长,期间 I/O 占用会上升。
Compaction 是整个 LSM 系统的"心脏",它的健康度直接决定读写表现。从它反推,调优方向可以归纳成三条主线:
这三条线背后的决策逻辑,都是"在写放大、读放大、空间放大这个三角里挪位置"。而挪位置的依据,永远是监控数据——先知道现在站在三角的哪个角落,再决定往哪挪。第 6 章的调优方法论,就是这套"看数据、定位置、做取舍"流程的完整展开。
一个不常被提起但重要的细节:Compaction 是异步的,它生成新文件、更新元数据的过程一旦崩溃,恢复靠的是什么?靠 Manifest——先写日志再切指针的机制保证:如果 Manifest 没写,新文件成为孤儿文件被清理;如果 Manifest 写了,回放后新版本生效。这意味着 Compaction 的任何中间态都是"可恢复的",系统不会停在"半合并"状态。
这给运维一个安心:即使频繁触发 Compaction,也不怕崩溃导致数据损坏。正确性由"日志先行"的模式兜底,Compaction 只是效率手段,不影响安全。理解了这一点,你就不会因为"压缩任务繁重"而恐慌——压缩再忙,也只是性能问题,不是数据安全问题。
从信息论的角度看,写入持续制造"熵":数据散落、重复、无序。如果没有后台整理,系统会越来越乱,直到读取成本高到不可用。Compaction 就是"熵减器"——它周期性地把乱的数据归并成有序结构,让系统维持在一个"可用的乱度"上。
这个视角解释了几个现象:为什么 LSM 引擎长期运行后读性能依然稳定(因为 Compaction 在持续降熵);为什么写入尖峰后读延迟可能短暂恶化(熵突然增加,整理还没跟上);为什么调优本质上是在控制"熵的产生速度与整理速度的平衡"。把 Compaction 理解为对抗熵增的机制,你就抓住了它存在的根本理由——它不是可选项,而是维持 LSM 系统"活着"的必要代谢。
读写压缩三条路径齐了。下一节我们把代价摆上桌面——三放大到底是怎样此消彼长的。