☰
RocksDB CompactionPri调优:四种优先级策略与场景选型
2026/10/20 9:49:01 网站建设 项目流程

RocksDB调优调到后面,百分之八九十的人都会盯上compaction_pri这个参数。全称Compaction Priority,说白了就是:候选的压缩任务不止一个时,先压谁、后压谁的排序规则。别小看这个排序,它直接决定了一轮Compaction里文件被选中、被合并的路径,进而改写整个DB的写放大、空间放大和读性能曲线。很多线上事故,表面看是磁盘暴涨或者写入卡顿,根子其实是这个不起眼的优先级选错了策略。

这个参数适合谁来调?凡是底层用了RocksDB、并且已经经历过“默认参数能跑但一上量就露馅”的团队,都需要把CompactionPri纳入调优清单。它不像write_buffer_size那样直观,也不像max_background_jobs那样立竿见影,但它决定了你压下去的每一轮后台IO到底是不是花在了刀刃上。我今天就把这个参数从原理到实操完整聊一遍,重点讲清楚四种枚举值各自适用什么场景、怎么配置、怎么观察效果,以及我在真实项目里踩过的坑。

1. 先搞明白Compaction Priority在解决什么问题

1.1 LSM-Tree里永远存在的“候选者冲突”

RocksDB底层是LSM-Tree结构,数据先写MemTable,刷盘成SST文件,逐层向下合并。每个层级到了一定阈值就会触发Compaction,但触发之后随之而来的问题是:这一层里有好几个文件都满足合并条件,合并不是流水线式的排队,而是需要一个明确的“插队规则”——谁先被处理,谁继续等着。

这个规则就是Compaction Priority。简单说,它作用的位置是CompactionPicker内部,也就是负责“挑选本次合并哪些文件”的那个模块。你可以把它理解为排队叫号系统里的优先级排序:如果只是“先到先得”,结果可能很糟糕,因为最先到达的文件未必对整体结构最有利。

没有合理优先级时会出现什么现象?最常见的是写放大飙升。假设L1层有十个文件,其中五个和L2层几乎没有重叠,另外五个和L2层大范围重叠。如果系统优先合并的是后面五个,那么每一轮合并都要携带大量冗余数据下沉,磁盘IO消耗成倍增加,但又没有换来读性能的明显收益。选文件的顺序不对,等于同样一份数据反复被搬来搬去,这就是我在生产环境里见过最典型的隐性浪费。

1.2 CompactionPri不是触发条件,而是排序规则

很多刚接触RocksDB的人会把compaction_pri和“什么时候触发Compaction”混为一谈。其实触发条件和它是两码事:什么时候触发由level0_file_num_compaction_trigger、max_bytes_for_level_base这些参数决定;一旦触发条件满足、进入候选队列,接下来才是compaction_pri发挥作用。

这个区别很重要,因为它决定了你调参的心态。改compaction_pri不会让Compaction变得更频繁或更稀疏,它只是改变“在已经决定要做合并的情况下,优先处理谁”。所以如果线上Compaction积压严重,第一反应应该是去看触发阈值和后台线程数,而不是试图靠改优先级来“急救”。

不过优先级选对了,确实能明显缓解积压。为什么?因为Compaction积压的本质是写放大过大、有效产出比太低,大量IO被浪费在合并重叠度很高的文件上。选一个合适的优先级,让每一轮合并都在“以最小的付出换取最大的结构收益”,积压自然就降下来了。

2. 四种CompactionPri策略的工作原理与选型逻辑

2.1 kByCompensatedSize:看着最“均衡”的默认策略

kByCompensatedSize是早期版本的默认策略,全称是按“补偿后大小”排序。这个名字很容易让人误解,它不是简单按文件大小从大到小排。它的核心逻辑是:每个文件有一个“补偿大小”,这个值会综合考虑文件本身的大小以及它在整个层级中的影响范围。排序时优先选择补偿后大小最小的文件进行合并。

为什么是“补偿后大小”?因为一个文件虽然本身只有10MB,但它可能和下一层有大片重叠,合并时实际要读写的IO远超10MB;另一个文件本身有50MB,但和下一层完全不重叠,合并时几乎就是文件搬迁,付出的小。所以只看原始文件大小判断优先级是不科学的,必须引入“补偿”概念,把重叠范围、层级影响折算进来。

不过这个策略有个典型问题:它倾向于优先压缩“看起来很小但实际影响不小”的文件,导致某些小文件可能会被反复选中。每次刚合并完,它的体量变小了,结果下一轮又变成“补偿后最小”的候选者,再次被优先处理。我在一个模拟项目X里就遇到过这种情况:写入量不大,但Compaction线程却忙个不停,打开rocksdb.cfstats一看,合并的总字节数远超实际写入字节数,写放大已经高到了令人头皮发麻的程度。

2.2 kOldestLargestSeqFirst:让最老的数据先沉到底

kOldestLargestSeqFirst的策略偏向于时间维度。它会优先选择包含最老Sequence Number、并且文件尺寸也更大的那些文件。你可以把这个行为理解成“老数据优先下葬”:越老的数据越应该早一点被合并到更深层去,这样查询老数据时路径更短,同时也能更早地释放上层的空间。

这个策略和Tiered Compaction搭配得非常好。Tiered模式下各个层级更像是“暂存区”,数据先快速写入多层,由后台慢慢整理;此时如果优先处理最老且最大的文件,可以加速整个SST的整体下沉,避免上层文件堆太久导致空间放大失控。我在日志类存储场景里用过这个组合,效果立竿见影——磁盘占用增速从每天一个多GB降到几百MB,后台IO也更平稳了。

但它也有代价:这种策略对写放大的控制不如后两种那么精细化。它是“时间优先”而非“成本优先”,所以适合那些对磁盘空间更敏感、对写放大相对宽容的业务。典型场景是流水型数据的长期存储,数据一旦落盘基本不会再更新,重点是尽快把老数据组织好、省空间。

2.3 kOldestSmallestSeqFirst:Tiered模式下的默认强者

kOldestSmallestSeqFirst和上面那个只差一个词,但行为差异明显。它会优先选择最老Sequence Number的、文件尺寸偏小的文件进行合并。这个策略的出发点很朴素:先把最老的数据处理掉,而且优先处理小文件,合并的代价相对小,整体IO更平滑。

为什么Tiered Compaction常常默认或者推荐用这个?因为Tiered模式下,一个文件可能被多个层级“引用”,文件之间的重叠关系不像Leveled那么直接。选最老且最小的文件去做合并,IO成本可控,同时又能保证老数据不断向深层推进。我个人的观察是,这种策略在大量小文件持续写入的场景下,对稳定性的贡献最明显,Compaction峰值不会突然冲得特别高,而是维持在一个可以接受的范围内。

不过如果某个文件的Key Range恰好和很多文件重叠,哪怕它是最老的、最小的,合并它时依然可能拖出一大片IO。所以kOldestSmallestSeqFirst更像是一种“基准线策略”——在多数情况下表现良好,但遇到极端重叠时会有些吃力。

2.4 kMinOverlappingRatio:Leveled模式下的写放大杀手

kMinOverlappingRatio是我在Leveled Compaction下最常用的策略,也是目前RocksDB官方比较推荐的思路之一。它的排序依据是文件和下一层的重叠比率,重叠率低的文件优先被处理。

重叠率怎么理解?L1层的一个文件,如果要合并进L2,它可能需要覆盖L2层的好几个文件。如果只覆盖一个文件,那这次合并只需要读少量数据、写少量数据,代价就很小;如果覆盖了七八个文件,这次合并要带着大量无关数据一起重写,代价就很大。kMinOverlappingRatio要做的事情,就是每次都挑代价最小的那个文件先做,把昂贵的合并留到后面。

这个策略为什么对写放大如此友好?因为Compaction的写放大和文件之间的重叠程度强相关。人为贪心地按“成本最小”处理,一轮轮下来平均写放大会明显下降。我曾在测试环境做过对照组,同样写入500GB数据,用kByCompensatedSize时累计合并写入量接近1.2TB,切换到kMinOverlappingRatio之后,合并写入量降到700GB左右。这个差距让我后来在几乎所有Leveled场景下都选择了这个枚举值。

不过要注意,kMinOverlappingRatio对L0到L1的合并效果有限,因为L0文件之间的Key Range本身就可能重叠严重,上一层的“重叠比率”失真。但RocksDB的CompactionPicker对L0合并有自己的内部处理逻辑,不完全依赖这个优先级,所以实际使用时不需要太担心这一点。

3. 实操配置:从读取默认值到动态切换

3.1 查看当前CompactionPri状态

动手改参数之前,先要知道当前数据库到底用的什么策略。RocksDB不像MySQL的show variables那么直接,获取配置需要走GetOptions路径。在C++里可以这样:

#include <rocksdb/options.h> #include <rocksdb/db.h> rocksdb::ColumnFamilyOptions cf_opts; db->GetOptions(column_family_handle, &cf_opts); std::cout << "compaction_pri: " << static_cast<int>(cf_opts.compaction_pri) << std::endl;

枚举值对应的数字是:kByCompensatedSize为0、kOldestLargestSeqFirst为1、kOldestSmallestSeqFirst为2、kMinOverlappingRatio为3。拿到数字就知道线上跑的是哪个策略了。

Java端更简单,直接调用getColumnFamilyOptions(),然后读取compactionPriority()即可。没有直接改过配置的存量库,大概率是0,也就是kByCompensatedSize。如果数据库是从某个新版本初始化且明确指定了Compaction Style,默认值可能随版本变化,所以不要想当然,先查再说。

3.2 静态配置与动态SetOptions两种方式

配置CompactionPri有两种方式。第一种是初始化时在ColumnFamilyOptions里写好:

rocksdb::ColumnFamilyOptions cf_opts; cf_opts.compaction_pri = rocksdb::CompactionPri::kMinOverlappingRatio; rocksdb::DB* db; rocksdb::ColumnFamilyHandle* cf_handle; rocksdb::DB::Open(rocksdb::DBOptions(), "/data/rocksdb", {rocksdb::ColumnFamilyDescriptor("default", cf_opts)}, &cf_handle, &db);

Java端的写法类似:

ColumnFamilyOptions cfOpts = new ColumnFamilyOptions() .setCompactionPriority(CompactionPriority.KMinOverlappingRatio);

更实用的是第二种:线上数据库已经在运行,不可能重启,这时用动态SetOptions调整:

# 通过RocksDB的SetOptions接口动态更新 # 注意参数名是字符串形式,枚举名的写法不同语言略有差异

Java端动态调整我常用这种方式:

import org.rocksdb.RocksDB; import org.rocksdb.ColumnFamilyHandle; import java.util.Collections; RocksDB db = RocksDB.open("/data/rocksdb"); ColumnFamilyHandle cf = db.getDefaultColumnFamily(); db.setOptions(cf, Collections.singletonMap("compaction_pri", "kMinOverlappingRatio"));

这里特别注意:动态设置是作用于单个ColumnFamily的,不是全局的。如果一个DB开了多个CF,只设置了default,其他CF还是老策略。我曾经因为漏看了这个细节,以为已经把业务热点CF调过去了,结果排查半天发现调的是另一个无关CF。多CF场景尽量遍历所有handle,统一设置。

3.3 设置后必须观察的阈值与配套参数

compaction_pri不是孤立生效的,它和几个兄弟参数配合才能发挥出理想效果。我每次调它的时候,都会顺手检查下面这几个值:

首先是compaction_style。kMinOverlappingRatio在kCompactionStyleLevel下是主力推荐,但在kCompactionStyleUniversal下就未必合适;反过来kOldestSmallestSeqFirst在Tiered模式下更常用。如果你用的是Universal Compaction但还挂着Leveled思路的优先级,等于穿错鞋跑步,越跑越别扭。

其次是level0_file_num_compaction_trigger。这个参数决定了L0同时有多少文件时会触发Compaction。优先级再合理,如果L0堆积的触发阈值设置过高,前台写入仍然会因为L0文件过多而卡顿。我一般会把L0触发阈值和Compaction后台线程数放在一起调,确保Compaction队列不会长期处于饱和状态。

最后是max_background_jobs。再好的优先级排序,如果后台线程不够,积压还是积压。在调整compaction_pri之前,先确认max_background_jobs和max_background_compactions没有明显偏小,否则你很难说清效果到底是优先级带来的还是线程数变化带来的。控制变量,调参的黄金法则。

4. 真实场景调优:三个案例复盘

4.1 案例一:写入吞吐上不去,CPU全耗在Compaction上

这个案例来自某跨平台系统的存储模块,底层用RocksDB做元数据存储,写入量不算极端,但吞吐始终上不去。查看监控时发现,CPU使用率高的时间段和Compaction线程活跃时间段高度重合,rocksdb.cfstats里显示合并写入量是实际写入量的好几倍。

诊断过程很简单:先看Compaction Style,Leveled,再看compaction_pri,默认的kByCompensatedSize。于是我做了一次切换,把优先级换成kMinOverlappingRatio,同时保持其他参数不变。观察了半天后,写放大数据开始下降,CPU的Compaction占用曲线也从满负荷变成了有规律的脉冲。

事后复盘原因:这个业务的Key写入有明显热点区间,新写入的文件和下一层重叠率普遍较高。kByCompensatedSize按补偿大小排序,优先处理的往往是一些新生成的、和下一层大面积重叠的文件,导致每一轮Compaction都像在搬一座山,IO付出极大。kMinOverlappingRatio把重叠率低的文件优先处理,一轮轮下来成本被摊薄了,整体写放大自然就下去了。

4.2 案例二:磁盘空间持续暴涨,查了一圈发现是老数据在反复横跳

另一个案例是某日志型存储服务,数据大部分是只追加写入,很少有更新删除。服务跑了两三周后磁盘占用比预估高出一大截,DBA第一反应是数据量涨了,但查了实际写入量后对不上,怀疑是RocksDB空间放大异常。

我用RocksDB自带的rocksdb.levelstats看了各层文件大小分布,发现L1层文件数量异常多,而且总大小明显超过该层的容量上限。继续翻LOG文件,发现L1到L2的Compaction频繁选中的文件,居然都是同一批近期文件。这个表现很符合kByCompensatedSize的特征:刚合并完的文件变“小”了,又被当成补偿后最小的候选者,反复被拖去合并。

处理方式:把这个CF的存储模式调整为更适合日志追加场景的Tiered Compaction,同时把compaction_pri改为kOldestSmallestSeqFirst。调整后,老数据开始稳步下沉到深层,顶层文件不再被反复折腾,磁盘占用的增长曲线迅速回归到和实际写入量相匹配的水平。那次之后,我对“数据是只增不改”的业务默认都会考虑Tiered加上时间维度的优先级。

4.3 案例三:读延迟偶发飙升,Compaction和查询相互踩踏

第三个案例是读多写少的服务,写入量不大,但时不时会有一次明显的读延迟尖刺。排查时定位到Compaction任务在尖刺时间点前后被触发,而且Compaction选中处理的是一个很大、和下层重叠很多的老文件,长时间占用IO。

这类场景的痛点是:一次重Compaction成本太高,长时间占用后台线程和磁盘带宽,导致前台读请求的延迟被拉高。用kMinOverlappingRatio之后,重成本的Compaction被拆散成多次低成本任务,虽然总耗时可能更长,但每次占用的资源有限,前台读的尖刺明显消失。

这也是我在实际项目里最推崇kMinOverlappingRatio的原因:它不是最快的,但它是“平滑的”。对在线服务来说,一次100毫秒的重合并可能比十次10毫秒的轻合并更危险,前者会让尾延迟爆炸,后者却可以完全被缓冲掉。选择优先级,本质上是选择系统的抖动特性。

5. 踩坑记录与排查清单

5.1 设置的优先级一直没生效?

这是最常见的困惑。排查看三点:第一,动态SetOptions是否作用到了正确的ColumnFamily?第二,是否设置了但没有等当前正在进行的Compaction批次结束?第三,你确认数值已经写入但行为没变化,可能是对这个优先级的作用范围理解有偏差——它只影响新挑选的Compaction任务,已经在队列里的老任务不会因为参数改了而中止重排。

有一个陷阱很隐蔽:如果设置了kMinOverlappingRatio,但观察时间太短,可能刚好赶上L0到L1的批量合并阶段,这个阶段有些行为不完全受该优先级控制。建议观察至少一个完整的Compaction周期,也就是看到L1及以下层级发生多次合并之后再做结论。

5.2 优先级和Compaction Style不匹配

我在前面的案例里提到过,kMinOverlappingRatio和Leveled是天作之合,但如果你在Universal/Tiered模式下强行使用重叠率逻辑,效果会非常差。反过来,倒也不是说时间维度的优先级不能在Leveled下用,只是它容易让“重叠率很高但很老”的文件拖拽大量IO,写放大控制明显不如重叠率策略。

建议对照表如下:

策略推荐Compaction Style核心优势典型代价
kByCompensatedSize通用(默认)均衡、适应面广产物文件易被反复选中,写放大偏高
kOldestLargestSeqFirstUniversal / Tiered老数据快速下沉,空间控制好对大文件合并成本预估不细
kOldestSmallestSeqFirstUniversal / Tiered平滑稳定,IO脉冲低极端重叠场景会有一次重代价
kMinOverlappingRatioLeveled写放大最低,抖动小对L0场景帮助有限

5.3 不要只调这一个参数

最后一条血泪经验:compaction_pri是锦上添花的参数,不是包治百病的偏方。你在线上看到的Compaction问题,往往是多因素叠加的结果:MemTable刷盘太快、L0触发阈值太低、后台线程数不足、目标文件大小不合理,任何一个都可能让Compaction表现变差。

我的习惯是先把write_buffer_size、level0_file_num_compaction_trigger、max_background_jobs这些基础参数调到一个相对合理的范围,再通过compaction_pri做细粒度的“选择性优化”。否则基础都歪了,优先级配得再完美也是白搭。而且每次改动只动一个变量,记录切换前后一周内的写放大、空间放大、P99读延迟数据,用数据说话,远比凭感觉调要靠谱得多。

CompactionPri这个参数就像一把精细的手术刀,用对了地方能精准解决写放大和抖动问题,用错了方向反而会让系统行为变得难以解释。在真实项目的调优过程中,我一直坚持“先看懂LSM结构,再决定优先级”的顺序:先确认业务是写多读少、读多写少还是只增不改,再针对LSM当前的结构瓶颈去匹配策略。实际操作里,kMinOverlappingRatio在大多数Leveled场景下都有着亮眼表现,而Tiered场景我则更倾心于基于序列号的两个时间维度策略。如果你还在用默认值跑业务,不妨花一个Compaction周期做个对照测试,多半会有意外收获。

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

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

立即咨询