一、背景
在 RocksDB 的 Leveled Compaction 策略下,ComputeCompactionScore 函数是压实调度决策的中枢。其核心任务是:
为每个层级(level)计算压实分数(score),并据此决定后台压实优先级。
- 触发阈值:score ≥ 1.0。
- 调度目标:优先处理高压层级,同时兼顾读放大、写放大、空间放大。
- 核心难点:L0 文件重叠且读路径敏感,L1+ 层有序但存在级联下压风险。
为便于把阅读路径和代码执行路径对齐,先给出本文主线问题:
- 为什么需要比较 L0 与 Base Level 大小,来提升 L0 的优先级?
- 什么是 “ 不必要层级 “(
unnecessary_level)? - 如何确定不必要层级?
- 怎么实现不必要层的清理?
- 怎么实现 “ 即将流入的数据量 “ (
total_downcompact_bytes)估计的? - 怎么通过 “ 即将流入的数据量 “ 降低当前层压实的优先级?
ComputeCompactionScore与PickCompaction如何衔接?高分是否一定可执行?- 当
score > 1但选不出任务时,系统如何避免长期抖动或饥饿? - score 与 write stall(L0 slowdown/stop)以及
pending_compaction_bytes如何协同?
二、L0 层(level == 0)精细化评分
(1)进入评分前:先建立可评分的上下文
在一个新 Version 的准备流程里,PrepareForVersionAppend() 先做三件关键事情,然后才适合讨论 score:
ComputeCompensatedSizes():计算每个文件的compensated_file_size。它不是简单文件大小,而是把点删密度(kDeletionWeightOnCompaction)和compensated_range_deletion_size一并计入,因此在 tombstone/range delete 场景下,能更接近真实压实收益。CalculateBaseBytes():计算动态层级字节模式下的base_level_、level_max_bytes_、lowest_unnecessary_level_。这一步决定后续 L0 与 Base Level 的比较基准,也决定哪些层被视作 unnecessary。UpdateFilesByCompactionPri():更新各层内部文件优先级,为后续PickCompaction()的文件挑选做准备。
因此,ComputeCompactionScore() 并不是孤立运行,它建立在这组状态之上。也正因为 base_level_ 是在新 Version 形成时重算,而不是每次写入即时重算,短周期内会有状态滞后窗口,但会在后续 Version 切换中被纠正。
(2)基础统计
int num_sorted_runs = 0;
uint64_t total_size = 0;
for (auto* f : files_[level]) {
total_downcompact_bytes += static_cast<double>(f->fd.GetFileSize());
if (!f->being_compacted) {
total_size += f->compensated_file_size;
num_sorted_runs++;
}
}
num_sorted_runs:L0 每个未压实文件都视作一个独立 run(文件间重叠)。total_size:只统计未处于 compaction 中的文件,避免把“正在处理的压力”重复计入。total_downcompact_bytes:累计上层向下游传播的潜在压力,后续会参与 L1+ 评分降权。
排除 being_compacted 的直接效果,是让分数反映“当前仍待调度的负载”;其代价是短窗口内会低估总压力,但每次 pick/完成后都会重算分数,且 level_total_bytes 与 total_downcompact_bytes 机制会在后续层级补回部分前瞻信息。
(3)基础分数:文件数量驱动
score = num_sorted_runs / level0_file_num_compaction_trigger;
默认 level0_file_num_compaction_trigger = 4,该规则先把 L0 读放大风险显式纳入优先级。。
(4)动态层级字节模式(level_compaction_dynamic_level_bytes == true)
分支 A:确保 L0 到达 base size 必有资格
if (total_size >= mutable_cf_options.max_bytes_for_level_base) {
score = std::max(score, 1.01);
}
大写缓冲区会产生较大 SST,仅靠文件个数可能低估压力。这里把分数托到 1.0 以上,避免“写入已积压但分数未达阈值”的尴尬状态。
分支 B:比较 L0 与 Base Level,控制下刷优先级
if (total_size > level_max_bytes_[base_level_]) {
uint64_t base_level_size = 0;
for (auto f : files_[base_level_]) {
base_level_size += f->compensated_file_size;
}
score = std::max(score, static_cast<double>(total_size) /
static_cast<double>(std::max(
base_level_size,
level_max_bytes_[base_level_])));
}
这里比较的是 L0_size 与 max(BaseLevel_size, BaseLevel_target),而不是固定比较某一层实际大小。这样做可以在 L0 快速增长时,把 L0->Base 的调度优先级拉高,防止 Base 层下游整理尚未完成时 L0 继续失控堆积。
例如(假设 base_level=L1,且 L1_target=100MB):L0=200MB, L1=100MB,则 L0 分数为 200/max(100,100)=2.0,缩放后为 2.0 × 10 (kScoreScale) = 20.0。此时 total_downcompact_bytes ≈ L0_size = 200MB,L1 分数约为 100MB/(100MB + 200MB) ×10 = 3.3。**最终,L0 (20.0) >> L1 (3.3)**,L0 优先级会明显高于同周期内 BaseLevel 的整理任务。
分支 C:超阈值后分数缩放
if (score > 1.0) score *= kScoreScale; // kScoreScale = 10.0
kScoreScale 的作用不是改变触发阈值,而是扩展超阈值区间的可排序空间。这样即使引入了 total_downcompact_bytes 这类降权项,系统仍能拉开高压层之间的优先级差异。
(5)静态模式补充判断
score = std::max(score, total_size / max_bytes_for_level_base);
静态模式下,L0 同时受“文件数”和“总大小”双约束,避免大文件场景只靠 run 数无法及时触发压实。
三、L1+ 层(level > 0)的动态评分机制
(1)基础统计
uint64_t level_bytes_no_compacting = 0;
uint64_t level_total_bytes = 0;
for (auto f : files_[level]) {
level_total_bytes += f->fd.GetFileSize();
if (!f->being_compacted) {
level_bytes_no_compacting += f->compensated_file_size;
}
}
其中 level_bytes_no_compacting 统计当前仍待调度的有效压力;level_total_bytes 统计该层总物理大小,后续用于估算向下游传播的压力。
(2)静态模式
score = static_cast<double>(level_bytes_no_compacting) / MaxBytesForLevel(level);
仍是“当前大小 / 目标大小”的经典定义。
(3)动态模式(核心)
分支 A:未超限,常规比值
if (level_bytes_no_compacting < MaxBytesForLevel(level)) {
score = static_cast<double>(level_bytes_no_compacting) / MaxBytesForLevel(level);
}
分支 B:超限时引入 total_downcompact_bytes 降权
score = static_cast<double>(level_bytes_no_compacting) /
(MaxBytesForLevel(level) + total_downcompact_bytes) *
kScoreScale;
这一步对应“先看本层压力,再看上游即将流入压力”。当上游即将下压的数据很多时,先把本层 score 压低,避免做出“刚整理完马上又被灌满”的调度决策。
例如:L2 超限 50MB,但 L1 正在压 200MB 到 L2,则分母 = 100 + 200 = 300,计算分数为 0.33 × 10 = 3.3。即使超限,该层优先级也会被下调,待后续再评估。
该估计天然有误差:
- 高估常见于整层 unnecessary 清理或后续会大量消解 tombstone 的场景;
- 低估常见于突发写入尚未 flush 进当前 Version 视图的时段。
但它的设计目标不是精确预测,而是给调度加入前瞻约束,降低 compaction 风暴概率。
分支 C:清理“不必要层级”
if (level_bytes_no_compacting > 0 && level <= lowest_unnecessary_level_) {
score = std::max(score, kScoreScale * (1.001 + 0.001 * (lowest_unnecessary_level_ - level)));
}
什么是“不必要层级”? 只在启用动态层级字节数模式 (level_compaction_dynamic_level_bytes = true) 时才会产生,指可被完全合并到下一层的层级(如 L3 只有 10MB,L4 目标 10GB)。当 CalculateBaseBytes() 把某些层判为 unnecessary 后,这里给它们一个稳定且有限的优先级(10.01 ~ 10.05,取决于 lowest_unnecessary_level_ - level)的分数。一方面,分数大于 1.0,意味着有资格压实;另外一方面,远小于 L0 超限时的分数(如 20.0),使其在 L0 不紧急时被逐步清理,但在 L0 高压时仍会让位给更紧急的下刷任务。
在 CalculateBaseBytes 函数中,lowest_unnecessary_level_ 的判定来自 base bytes 反推过程:从高层目标往前按 multiplier 递推,当某层理论目标跌破 base_bytes_min 且满足约束(含 preclude_last_level_data_seconds 相关条件)时,开始标记为 unnecessary 区间。
if (lowest_unnecessary_level_ == -1 &&
cur_level_size <= base_bytes_min &&
(ioptions.preclude_last_level_data_seconds == 0 ||
i < num_levels_ - 2)) {
lowest_unnecessary_level_ = i;
}
具体条件:
- 尚未找到 unnecessary level (lowest_unnecessary_level_ == -1)
- 当前层级的理论目标大小小于等于 base_bytes_min = max_bytes_for_level_base / max_bytes_for_level_multiplier
- 不是倒数第二层(当启用 per_key_placement 时,倒数第二层是必需的)
假设:
- max_bytes_for_level_base = 256MB
- max_bytes_for_level_multiplier = 10
- base_bytes_min = 256MB / 10 = 25.6MB
- 最大层级(L6)有 10GB 数据
计算过程:
- L6: 10GB (实际最大)
- L5: 10GB / 10 = 1GB (理论)
- L4: 1GB / 10 = 100MB (理论)
- L3: 100MB / 10 = 10MB (理论) ,此层级 10MB 小于 25.6MB,会被标记为 unnecessary
因此 lowest_unnecessary_level_ = 3。在该推导前提下(相关层级位于计算窗口且为非空层),可理解为 L1、L2、L3 都属于不必要层级候选。
(4)total_downcompact_bytes 的更新回路
if (level <= lowest_unnecessary_level_) {
total_downcompact_bytes += level_total_bytes; // 不必要层级:全部计入
} else if (level_total_bytes > MaxBytesForLevel(level)) {
total_downcompact_bytes += static_cast<double>(level_total_bytes - MaxBytesForLevel(level)); // 超限部分计入
}
把“上层会向下传播多少压力”显式反馈到后续层级评分。ComputeCompactionScore() 在层级遍历过程中不断更新该变量,形成一个由上到下的前瞻抑制回路。对于不必要层级,因为整个层级都会被压实掉,在估计中全部字节都计入下一层压力。对于 超限层级:在估计中仅计入超出目标的部分。
(5)从 score 到可执行 compaction,再到 write stall
score 只是“候选优先级”,是否能执行要继续走调度链路:
ColumnFamilyData::NeedsCompaction()->LevelCompactionPicker::NeedsCompaction():只要存在可触发条件(包括CompactionScore(i) >= 1、TTL、periodic、marked、forced blob GC、bottommost)就会进入候选。PickCompaction()/SetupInitialFiles():先按 score 从高到低尝试,再进入实际文件选择。- 文件选择阶段还要满足执行约束:
ExpandInputsToCleanCut()必须成功;- 不能与正在运行 compaction 的输入/输出范围冲突(
FilesRangeOverlapWithCompaction()); - L0 有并发限制(有 L0 compaction 在跑时,新的 L0->base 可能被阻塞)。
这解释了“score > 1 但仍选不出任务”的常见原因。系统不是停在原地,会继续尝试后续层级/文件,L0 受阻时可回退尝试 PickIntraL0Compaction(),先降低 L0 文件数压力,若按分数选不出,会继续进入标记类任务路径。
在 Level compaction 下,SetupInitialFiles() 的自动仲裁顺序可概括如下(仅在对应条件满足时生效,例如 compaction_style 为 level,且 TTL/periodic/blob GC 相关选项开启):score 驱动任务、FilesMarkedForCompaction、BottommostFilesMarkedForCompaction、ExpiredTtlFiles、FilesMarkedForPeriodicCompaction、FilesMarkedForForcedBlobGC
调度最后还会与写入限流协同,GetWriteStallConditionAndCause() 使用 l0_delay_trigger_count 与 estimated_compaction_needed_bytes(soft/hard pending bytes limit)判定 normal/delayed/stopped;RecalculateWriteStallConditions() 通过 WriteController 发放 delay/stop/compaction-pressure token;同一个系统里,score 决定“先做谁”,write stall 决定“写入端需要多强背压”,两者共同防止失稳。
四、总结:调度仲裁顺序与多 CF 行为
从执行路径看,CompactionScore 相关机制可以压缩成一条主链:
PrepareForVersionAppend
-> ComputeCompensatedSizes / CalculateBaseBytes
-> VersionSet::AppendVersion(ComputeCompactionScore)
-> NeedsCompaction
-> PickCompaction(SetupInitialFiles)
-> GetWriteStallConditionAndCause
在这条链上:ComputeCompactionScore 负责表达“压力与优先级”;PickCompaction 负责在并发与重叠约束下寻找“可执行任务”;write stall 负责在压力持续时给写入路径施加背压。
多 Column Family 场景下,后台线程以 DB 级 compaction_queue_ 进行 CF 任务分发,体现为队列轮转而非全局最优 score 调度。
本文作者 : cyningsun
本文地址 : https://www.cyningsun.com/03-14-2026/rocksdb-compaction-score.html
版权声明 :本博客所有文章除特别声明外,均采用 CC BY-NC-ND 3.0 CN 许可协议。转载请注明出处!