想象你经营一家没有固定货架的快递仓库。快递员不断把包裹送到前台,前台把它们堆在“暂存台”上;暂存台堆满后,大家把这一整摞包裹打包成一个标准纸箱,放进后仓。每个纸箱里的包裹都按编号排好顺序,看起来整齐,但日子一长,后仓纸箱越堆越多——同一个编号的包裹可能出现在好几个箱子里,有的甚至早就被客户退回了,却还占着位置。这时候你需要的是一位“仓库归并整理员”:把多个小纸箱拆开,挑出仍然有效的包裹,合并成新的大纸箱,顺手扔掉作废的纸箱。这个整理动作,就是存储引擎里的 Compaction(压缩合并)。 在数据库世界里,LSM-Tree 存储引擎正是这样一座仓库。内存中的 MemTable 是暂存台,落盘后的 SSTable 文件是纸箱。Compaction 负责把分散的小文件合并成大文件,清理删除标记、回收磁盘空间,同时小心翼翼地在“读数据要找多少个文件”和“写数据要多写多少遍”之间寻找平衡。Leveled、Tiered、FIFO 这些策略,本质上就是不同的仓库整理制度。理解它,你就掌握了现代 KV 存储(如 RocksDB、Cassandra、HBase)的底层脉搏。
对 AI 从业者而言,存储是模型训练和推理服务的底座;理解 Compaction 的权衡,能让你在调优数据库、设计大数据管线时,不再被莫名的磁盘暴涨和写入毛刺困住。
目录
可视化演示
三步展示LSM存储引擎Compaction核心策略:Size-Tiered大小合并、Leveled分层合并、四种策略读写空间放大对比,动态呈现SSTable合并过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
Size-Tiered:大小合并策略 同层积攒4个大小相似SSTable后触发合并 合并为1个更大SSTable,写放大低 WA ≈ N (合并次数少) 适合写密集型负载如日志收集和IoT数据写入,写吞吐量高但读放大较大。
仓库初建:LSM 与 SSTable
想象你经营着一家没有固定货架的快递仓库。快递员不断把包裹送到前台,前台把它们堆在“暂存台”上;暂存台堆满后,大家把这一整摞包裹打包成一个标准纸箱,放进后仓。整个仓库的运作方式由此确定:新包裹先在暂存台快速接单,再把成箱的货物按顺序入库,完全不需要在一堆旧箱子中间找空位。这就是 LSM-Tree 最核心的直觉——把随机散落的写入,变成顺序装箱的流水线。
在数据库术语中,暂存台对应内存里的 MemTable,负责吸收高频随机写;装箱动作叫 flush,把 MemTable 一次性刷成磁盘上不可变的 SSTable 文件。为了防丢,真正的写入会先追加一条 WAL 日志,就像快递员先在前台登记单号,再开始装箱。SSTable 一旦形成就不可修改,所以“删除一条数据”并不是把文件里的内容擦掉,而是追加一条“墓碑”标记,告诉后来者这条数据已经失效。
白天包裹越来越多,后仓的标准纸箱越来越多。同一个收件人的包裹可能散落在多个纸箱里,有的纸箱里甚至装着大量被客户拒收的过期包裹。为了不让仓库被无效纸箱撑爆,Compaction 登场:把多个小纸箱拆开,挑出仍然有效的包裹,合并成新的大纸箱,并且顺手扔掉作废的纸箱。一次 flush 的数据量,决定了后续整理的基本单位。
所以,Compaction 的第一步,是把这些已经封好的小纸箱重新打开、重新归类。理解了“仓库初建”,你就能明白为什么 LSM 能高效写入,却必须付出整理代价——没有整理,仓库迟早被小纸箱淹没。
- 刷盘总大小
S_flush表示一次把内存暂存台写满后,刷成 SSTable 文件的字节总数。 - 平均记录大小
r_avg是仓库里每件包裹的平均体积,即每条数据平均占多少字节。 - 记录条数
n_records是这批包裹的数量,也就是本次刷盘包含的数据条数。
核心账本:三种放大
整理仓库不是免费的,你要同时付三笔账。第一笔是“搬运账”:同样一件包裹,可能被从小箱搬进中箱、再搬进大箱,每搬一次就要多写一次磁盘。第二笔是“查找账”:客户来取包裹时,如果仓库里重叠的小箱太多,你可能要翻好几个纸箱才能找到目标。第三笔是“占地账”:那些已经被退回但还没来得及扔掉的过期包裹,仍然占着货架面积。
在存储引擎里,这三笔账分别叫写放大、读放大和空间放大。写放大衡量“实际写进磁盘的字节数”比“新产生的有效数据字节数”高了多少倍;读放大衡量一次读取平均要检查多少个 SSTable 文件;空间放大衡量磁盘实际占用比有效数据膨胀了多少。Compaction 就是在搬箱子、翻箱子和占面积之间做取舍:整理越勤快,查找和浪费的账越好看,但搬运的账就越难看;整理越偷懒,写放大很漂亮,但读放大和空间放大会迅速恶化。
仓库管理员最头疼的就是这三本账没法同时做到完美。理解了它们,你就能看懂为什么不同的 Compaction 策略,本质上是在为不同业务选择“哪本账可以稍微难看一点”。
- 写放大定义
W_total是 Compaction 实际搬进搬出的总字节数,W_new是用户真正新写入的字节数。 - 读放大定义
f_i是第 i 次查询时检查过的 SSTable 文件数,对所有查询取平均,得到一次典型读取要翻几个箱子。 - 空间放大定义
D_used是磁盘实际占用量,D_live是仍然有效的数据量,两者相除就是仓库里还剩多少“死重”。
Leveled:分层整洁
如果仓库管理员是个强迫症,他会选择 Leveled 整理法。这个方案把仓库分成一层一层:第 0 层是临时堆放区,所有新刷出来的小纸箱先丢在这里,允许彼此重叠;第 1 层、第 2 层则是正式货架,每一层的货架都被严格划分,各层的容量按固定倍数逐层扩大。关键是,除 L0 外,每一层的纸箱都互不重叠且按键排序,找包裹时每层最多只需要翻一个纸箱。
RocksDB 默认的 Leveled Compaction 就是这个思路。当某层累积的文件数量或总大小超过阈值时,后台会挑出本层的一部分纸箱,去和下一层 key 范围有交集的纸箱合并。合并完成后,新生成的纸箱放进下一层,并且保证下一层的纸箱依然互不重叠。这样每一层都像一本按字母顺序排列的大字典,查一个键非常快,磁盘空间也被压缩得很紧凑。
Leveled 的代价是强迫症式的频繁整理:因为要维持层内无重叠,每次合并可能牵扯下一层多个文件,写放大通常会比较高。但如果你更关心查询稳定和磁盘省空间,这个“楼层分明”的仓库方案非常合适。
- 第 i 层容量
C_i表示第 i 层货架能容纳的总数据量上限。 - 基础容量
C_0是第 0 层临时堆放区的基准容量,通常是 MemTable 刷盘几轮后的大小。 - 增长倍数
r是每层容量的放大系数,比如 10,表示下一层是上一层的 10 倍。 - 层级指数
i代表货架层号,层数越深,容量越大,文件也越稳定。
Tiered:同级积压
另一类仓库管理员很务实:先让包裹在同一个楼层里随意堆着,堆够一定数量后,再一次性打包成一个大箱。这就是 Tiered(又称 Size-Tiered)Compaction。它不追求每层都整整齐齐,而是允许同一层同时存在多个 key 范围重叠的小文件,等小文件攒够 N 个,才把它们全部合并成一个更大的文件,放入下一层。
Cassandra 等系统常用这种策略,因为它对写入极其友好。小文件先堆着不整理,写入的时候不必立刻和下一层缠绵,写放大很低,吞吐很高。代价是读取变慢:因为同层有多个重叠文件,一次查询可能要逐一检查每一个小箱,读放大明显上升。空间放大也比较高,因为删除和覆盖的数据会一直存在于多个小箱里,直到积攒够数才被清理。
Tiered 和 Leveled 的关系,就像“先攒一堆再打包”和“随手归位”两种生活方式。前者写起来轻松、找起来累;后者写起来累、找起来轻松。没有绝对好坏,只有合不合适。
- 写放大近似值
A_w^(T)是 Tiered 策略下的写放大程度。 - 合并文件数
n_merge是一次参与合并的小文件数量,比如 5 个就近似放大 5 倍。 - 为什么近似因为合并时读取所有输入文件并写出一个较大的输出文件,每个字节都被重新写了一遍。
FIFO:按时间淘汰
还有一种更极端的仓库管理方式,叫 FIFO(先进先出)。它根本不拆箱、不精细合并,而是按时间画一条生死线:新箱子排在传送带上,当它“活够了”规定的保留窗口,整个箱子连同里面所有数据一起被扔进回收站。你只需要知道每个箱子到达的时间,不需要关心箱子里有没有有效数据。
这种策略的写放大极低,因为它几乎不做数据搬运,只是简单按时间丢弃旧文件。RocksDB 的 FIFO Compaction 就常被用于缓存类或时序类数据:比如保存最近 7 天的监控指标,7 天前的整箱数据直接删除。读取时你可能需要翻多个历史文件,但这类场景通常只关心最新数据,所以读放大高一点也不致命。
FIFO 的思路是:既然旧的本来就没用,何必费劲去从垃圾堆里挑宝贝?它非常适合数据价值随时间快速衰减的场景。但请记住,它不保证“保留窗口内所有数据一定存在”,因为整箱删除可能误伤仍在窗口内的部分数据——这正是不拆箱的代价。
- 回收率
E_reclaim是 FIFO 整理时一次可以回收的磁盘空间比例。 - 过期数据量
S_expired是已经超过保留时间、可以被整箱丢弃的数据量。 - 总数据量
S_total是所有 SSTable 占用的磁盘空间总和。
触发机制:何时整理
仓库不能一边收包裹一边无休止地整理,也不能永远不整理。那什么时候动手最合适?聪明的仓库管理员定了几条规矩:第一,某个区域的小纸箱数量超过一定数量时,必须合并;第二,某一层货架的总容量超过上限时,必须整理;第三,管理员可以随时手动下令大扫除;第四,没有包裹高峰的深夜,后台线程会顺手把没整理完的尾巴收掉。
存储引擎的触发机制也类似。Level 0 的 SSTable 数量超过 level0_file_num_compaction_trigger,就会触发合并;某一层整体大小超过 max_bytes_for_level_base 的乘数限制,也会触发合并。手动触发对应 CompactRange 之类的 API,适合管理员知道业务大变更后主动整理。后台调度则依赖独立线程池,在每次刷盘或后台任务循环中检查条件。
触发机制直接决定了 Compaction 是“润物细无声”还是“深夜大扫除”。如果触发太频繁,前台写入会被反复打扰;如果触发太迟钝,磁盘和查询延迟又会爆炸。理解触发条件,是调优存储引擎的第一步。
- 当前层级数据量
C_level是某一层当前的 SSTable 总大小。 - 容量上限
C_max是该层允许的最大容量,由max_bytes_for_level_base等参数决定。 - 触发结果条件成立时执行压缩命令
compact,开始合并该层文件。
现代演进:分区与云化
早期 Compaction 常常一不做二不休:一次整理把整个 key 范围的数据都搅动一遍,像在仓库里推倒所有货架重新摆。现代仓库管理员学会了“分区整理”:把仓库隔成一个个小隔间,哪个隔间乱了就只整理哪个隔间,其他隔间的业务照常运行。对应到存储引擎,就是 Partitioned Compaction / 局部 Compaction:只挑选一部分 SSTable 或一个 key 区间参与合并,减少全局阻塞。
同时,Universal Compaction 这类混合策略开始流行,它把 Tiered 的低写放大和 Leveled 的低空间放大结合起来,在不同阶段自动切换整理方式。云计算场景下,传统机械硬盘的随机 IO 费用变成了对象存储的访问费用,策略也更倾向于把大量小文件合并成少量大文件,以降低 API 调用成本。RocksDB 的 use_direct_io_for_flush_and_compaction、target_file_size_base 等参数,都是为了让整理动作更贴合实际硬件和业务的节奏。
现代 Compaction 已经不再是“拼命整理”或“懒得整理”的二选一,而是变成了根据业务特征动态调整的精细运营。仓库越来越大,整理员也必须越来越聪明。
- 单分区大小
S_partition表示一次局部 Compaction 只需要处理的数据量。 - 总数据量
S_total是整层或整个 key 范围内的数据总量。 - 分区数
K是把 key 空间划分成的独立区间数量,K 越大,单次整理影响越小。
AI 应用场景
时序数据库中的 FIFO/Tiered Compaction
阶段:数据存储与预处理
解决的问题:监控指标、日志数据量巨大且价值随时间衰减,传统 B+Tree 存储难以承受高频写入,而无限保留历史数据会让运维成本失控。AI 系统需要让数据采集端以最低写放大持续写入,并自动淘汰过期样本。
具体案例:某 AI 平台每天从千万级 IoT 设备收集传感器数据,写入 InfluxDB/TDengine。存储层使用 Tiered 或 FIFO Compaction,让热数据先落盘,保留 30 天后整批删除。模型训练时只读取最近一周数据,读放大略高但不影响训练管线。
大模型训练检查点中的 Leveled Compaction
阶段:模型训练与持久化
解决的问题:大规模分布式训练要把模型参数和优化器状态持久化到 KV 存储,频繁读写 checkpoint。Leveled Compaction 把多层文件整理成无重叠结构,让随机读取某个参数块时只需查一两个文件,显著降低读放大。
具体案例:某大模型训练框架用 RocksDB 保存 shard 级 checkpoint,启用 Leveled Compaction 并调大每层容量倍数。训练中断后恢复时,参数读取延迟下降 40%,磁盘空间占用减少约 30%。
云原生数据库中的局部 Compaction
阶段:线上推理服务的数据访问
解决的问题:云数据库服务数万个租户,如果一次 Compaction 扫描全局 key 范围,会造成明显的写入毛刺和跨租户影响。局部 Compaction 把整理隔离到单个分区,让 AI 应用的后台查询流量保持稳定。
具体案例:某云数据库使用分区级 Compaction,租户 A 的大量写入触发合并时,只影响租户 A 所在区间,租户 B 的推理服务毫无感知。运维还可以在低峰期手动触发整理,进一步削峰填谷。
Python 演示
class SSTable: def __init__(self, level, size, keys): self.level = level self.size = size self.keys = keys class LSMStore: def __init__(self, ratio=3, levels=3): self.levels = [[] for _ in range(levels)] self.ratio = ratio self.gc_count = 0 self.amp_bytes = 0 self.levels[0].append(SSTable(0, 10, set(range(0, 30)))) self.levels[0].append(SSTable(0, 10, set(range(30, 60)))) self.levels[0].append(SSTable(0, 10, set(range(60, 90)))) def compact(self): for lv in range(len(self.levels) - 1): files = self.levels[lv] if len(files) < 2: continue merged_keys = set() total_size = 0 for f in files: merged_keys |= f.keys total_size += f.size total_size *= self.ratio self.amp_bytes += total_size self.gc_count += 1 new_file = SSTable(lv + 1, total_size, merged_keys) self.levels[lv] = [] self.levels[lv + 1].append(new_file) def run(self, rounds=3): for _ in range(rounds): self.compact() print("compaction count:", self.gc_count) print("write amplification bytes:", self.amp_bytes) if __name__ == "__main__": store = LSMStore() store.run()
merged_keys |= f.keys把所有参与合并的 key 取并集,相当于把多本小册子合成一本大全。total_size += f.size累加所有小文件的大小,准备估算整理成本。total_size *= self.ratio模拟分层合并后文件容量按比例增长,用于估算写放大。self.amp_bytes += total_size每合并一次就把新写出的字节数计入写放大。self.levels[lv] = []清空当前层的旧文件,这是 Compaction 回收空间的关键动作。self.levels[lv + 1].append(new_file)把合并后的大文件放到下一层,让数据向更深的层级流动。self.gc_count += 1记录归并次数,方便观察整理频率。
常见误区
误区“Compaction 不就是在删除数据后清理磁盘吗?把带删除标记的文件删掉不就行了?”
正解清理删除标记只是 Compaction 的收益之一。更核心的目标是把多个重叠的小 SSTable 合并成有序的大 SSTable,减少读放大和空间放大。即使没有任何删除操作,只要小文件数量不断增加,读取就要翻越来越多的箱子,同样需要 Compaction 来合并整理。
为何常见:因为删除是最直观的“旧数据没用了”,初学者容易把“整理”等同于“垃圾回收”,忽略了 Compaction 还承担了“文件归并排序”的职责。
误区“Leveled 让每层都有序且无重叠,读放大低、空间利用率高,所以它一定是最优秀的策略。”
正解策略优劣取决于 workload。Leveled 的频繁合并会造成高写放大,写入密集场景下会严重拖累吞吐;Tiered 虽然读放大高,但写放大低,非常适合日志、时序等写入为主且很少读旧数据的场景。没有银弹,只有匹配。
为何常见:看到 Leveled 的“整齐”就认为它是标准答案,忽略了数据库调优中反复强调的取舍关系。
误区“用户执行 DELETE 之后,存储引擎应该立刻把这条数据从磁盘上抹掉,空间马上就回收。”
正解LSM 的 SSTable 是不可变文件,删除只能先写入一条墓碑标记,让后续查询看不到这条数据。真正的物理删除要等 Compaction 处理到对应文件时,把墓碑连同失效数据一起合并掉。所以空间回收是延迟的,具体延迟取决于 Compaction 触发频率。
为何常见:传统 B+Tree 原地更新删除的模型给人们留下了“删除即释放”的惯性认知。
误区“Leveled 策略中层数越多,每层数据越稀疏,空间放大就越低,所以把层数设得越多越好。”
正解层数增加确实可以增大每一层的容量,改善空间利用率,但也会让数据从底部一路合并到高层,产生更高的写放大和更长的合并链路。如果层数过高,后台 Compaction 永远追不上数据生成速度,反而加剧空间放大。合理的层数和每层大小需要根据实际写入速率调参。
为何常见:人们容易把“空间放大低”当作唯一目标,忽略了分层带来的写放大和调度压力。
面试题
Q1. LSM-Tree 中,新写入的数据最先进入哪里?
- A.磁盘上的 SSTable 文件
- B.内存中的 MemTable 缓冲← 正确答案
- C.合并完成的新的大文件
- D.已回收的磁盘空闲区域
答案:B。LSM 的设计核心是先用内存缓冲吸收随机写,再批量刷盘。MemTable 就是前台暂存台,SSTable 是后续落盘和合并的结果。其他选项描述的都是 Compaction 或回收阶段,而不是写入入口。
Q2. Compaction 主要解决什么问题?
- A.加速 CPU 运行频率,提升算力
- B.合并 SSTable 并回收无效数据空间← 正确答案
- C.把内存数据定期备份到远程
- D.将客户端请求分发到多个副本
答案:B。Compaction 相当于仓库归并整理:把多个小 SSTable 合并成有序大文件,清理墓碑和过期数据,从而同时改善读放大和空间放大。CPU、备份、请求分发都跟 Compaction 的核心职责无关。
Q3. 写放大是指什么?
- A.同一份数据被重复搬运写盘的倍数← 正确答案
- B.读取时需要扫描的文件数量
- C.删除标记占用的额外空间大小
- D.磁盘碎片整理所消耗的时间
答案:A。写放大用“实际写盘字节数 / 新数据字节数”来衡量。每次 Compaction 把数据从小文件搬到大文件,都会让同一份数据多次写盘,就像仓库里反复搬同一批包裹。读取文件数对应读放大,删除标记对应空间放大。
Q4. 空间放大最直接的表现是什么?
- A.写入请求响应时间持续上涨
- B.磁盘占用明显大于有效数据量← 正确答案
- C.内存缓存命中率不断下降
- D.CPU 消耗随着查询直线上升
答案:B。空间放大是“磁盘实际占用 / 有效数据量”的比值。存储层堆积大量含过期数据的 SSTable 时,磁盘占用会明显超过逻辑数据量。它主要影响容量成本,不直接表现为延迟或 CPU 消耗。
Q5. Tiered 策略相比 Leveled 的主要优点?
- A.查询延迟稳定且读放大更低
- B.写放大更低,写入吞吐更高← 正确答案
- C.空间放大为零且无需整理
- D.删除数据后空间立即释放
答案:B。Tiered 允许同层堆叠多个小文件,攒够 N 个再合并,减少了频繁搬运,因此写放大更低、写入吞吐更高。但它会导致读放大偏高,也不会让空间放大为零,更不会立即释放删除空间。
Q6. 删除一条记录在 LSM 中如何实现?
- A.立即定位并覆写所在磁盘页
- B.写入一条带墓碑标记的新数据← 正确答案
- C.直接删除对应 SSTable 文件
- D.等待系统重启后自动清理
答案:B。SSTable 不可修改,删除不能原地改写,只能追加一条墓碑记录,让旧值在查询时被屏蔽。后续 Compaction 合并到该文件时,才会真正丢弃墓碑和失效数据。立即覆写是 B+Tree 的做法,直接删文件也不安全。
Q7. Leveled Compaction 中每层容量通常是?
- A.每层容量固定不变,与层数无关
- B.每层按固定倍数增长,容量逐层扩大← 正确答案
- C.高层容量小于低层,便于快速淘汰
- D.容量由随机漂移算法动态决定
答案:B。Leveled 策略的典型设计是第 i 层容量 = 基础容量 × 倍数^i,层越深容量越大。这样既能承载更多数据,又保持层内文件有序无重叠。固定不变或高层缩小都会导致频繁向上层合并,失去 Leveled 的意义。
Q8. 以下哪些情况会触发 Compaction?
- A.某层 SSTable 文件数量超过阈值← 正确答案
- B.某层总数据量超过容量上限← 正确答案
- C.数据库收到手动合并指令← 正确答案
- D.内存 MemTable 尚未写满
答案:ABC。文件数量阈值、层级容量上限和手动指令都是典型触发条件。MemTable 未写满时通常不需要刷盘,也不会触发 Compaction;只有 MemTable 写满并刷出新的 SSTable 后,才可能因为文件变多而触发整理。
Q9. 关于 FIFO Compaction 的说法正确的是?
- A.适合按时间保留窗口的时序数据← 正确答案
- B.过期的整个 SSTable 会被直接丢弃← 正确答案
- C.写放大通常比 Leveled 更低← 正确答案
- D.不需要考虑任何时间维度
答案:ABC。FIFO 按时间保留窗口整箱淘汰,几乎不搬运数据,所以写放大很低,适合时序数据。但它必须依赖时间维度判断过期,完全“不考虑时间”就失去了 FIFO 的前提。
Q10. 现代分区/局部 Compaction 的目标包括哪些?
- A.只整理一部分 SSTable 减少全局阻塞← 正确答案
- B.允许只参与大区间的合并任务← 正确答案
- C.避免每次整理都要扫描全部数据← 正确答案
- D.让所有写放大完全消失为零
答案:ABC。分区 Compaction 把 key 空间切成隔间,只整理当前隔间,能减少阻塞和扫描范围;Universal 等策略也允许跨层大区间合并。但任何 Compaction 都必须搬运数据,写放大只能降低,不可能归零。