深入理解 RocksDB CompactionScore 机制


First Published: 2026-03-14 | Last Revised: 2026-03-14

  1. 一、背景
  2. 二、L0 层(level == 0)精细化评分
    1. (1)进入评分前:先建立可评分的上下文
    2. (2)基础统计
    3. (3)基础分数:文件数量驱动
    4. (4)动态层级字节模式(level_compaction_dynamic_level_bytes == true)
      1. 分支 A:确保 L0 到达 base size 必有资格
      2. 分支 B:比较 L0 与 Base Level,控制下刷优先级
      3. 分支 C:超阈值后分数缩放
    5. (5)静态模式补充判断
  3. 三、L1+ 层(level > 0)的动态评分机制
    1. (1)基础统计
    2. (2)静态模式
    3. (3)动态模式(核心)
      1. 分支 A:未超限,常规比值
      2. 分支 B:超限时引入 total_downcompact_bytes 降权
      3. 分支 C:清理“不必要层级”
    4. (4)total_downcompact_bytes 的更新回路
    5. (5)从 score 到可执行 compaction,再到 write stall
  4. 四、总结:调度仲裁顺序与多 CF 行为

一、背景

在 RocksDB 的 Leveled Compaction 策略下,ComputeCompactionScore 函数是压实调度决策的中枢。其核心任务是:

为每个层级(level)计算压实分数(score),并据此决定后台压实优先级。

  • 触发阈值:score ≥ 1.0。
  • 调度目标:优先处理高压层级,同时兼顾读放大、写放大、空间放大。
  • 核心难点:L0 文件重叠且读路径敏感,L1+ 层有序但存在级联下压风险。

为便于把阅读路径和代码执行路径对齐,先给出本文主线问题:

  1. 为什么需要比较 L0 与 Base Level 大小,来提升 L0 的优先级?
  2. 什么是 “ 不必要层级 “(unnecessary_level)?
  3. 如何确定不必要层级?
  4. 怎么实现不必要层的清理?
  5. 怎么实现 “ 即将流入的数据量 “ (total_downcompact_bytes)估计的?
  6. 怎么通过 “ 即将流入的数据量 “ 降低当前层压实的优先级?
  7. ComputeCompactionScorePickCompaction 如何衔接?高分是否一定可执行?
  8. score > 1 但选不出任务时,系统如何避免长期抖动或饥饿?
  9. score 与 write stall(L0 slowdown/stop)以及 pending_compaction_bytes 如何协同?

二、L0 层(level == 0)精细化评分

(1)进入评分前:先建立可评分的上下文

在一个新 Version 的准备流程里,PrepareForVersionAppend() 先做三件关键事情,然后才适合讨论 score:

  1. ComputeCompensatedSizes():计算每个文件的 compensated_file_size。它不是简单文件大小,而是把点删密度(kDeletionWeightOnCompaction)和 compensated_range_deletion_size 一并计入,因此在 tombstone/range delete 场景下,能更接近真实压实收益。
  2. CalculateBaseBytes():计算动态层级字节模式下的 base_level_level_max_bytes_lowest_unnecessary_level_。这一步决定后续 L0 与 Base Level 的比较基准,也决定哪些层被视作 unnecessary。
  3. 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_bytestotal_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_sizemax(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;
}

具体条件:

  1. 尚未找到 unnecessary level (lowest_unnecessary_level_ == -1)
  2. 当前层级的理论目标大小小于等于 base_bytes_min = max_bytes_for_level_base / max_bytes_for_level_multiplier
  3. 不是倒数第二层(当启用 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 只是“候选优先级”,是否能执行要继续走调度链路:

  1. ColumnFamilyData::NeedsCompaction() -> LevelCompactionPicker::NeedsCompaction():只要存在可触发条件(包括 CompactionScore(i) >= 1、TTL、periodic、marked、forced blob GC、bottommost)就会进入候选。
  2. PickCompaction() / SetupInitialFiles():先按 score 从高到低尝试,再进入实际文件选择。
  3. 文件选择阶段还要满足执行约束:
    • ExpandInputsToCleanCut() 必须成功;
    • 不能与正在运行 compaction 的输入/输出范围冲突(FilesRangeOverlapWithCompaction());
    • L0 有并发限制(有 L0 compaction 在跑时,新的 L0->base 可能被阻塞)。

这解释了“score > 1 但仍选不出任务”的常见原因。系统不是停在原地,会继续尝试后续层级/文件,L0 受阻时可回退尝试 PickIntraL0Compaction(),先降低 L0 文件数压力,若按分数选不出,会继续进入标记类任务路径。

在 Level compaction 下,SetupInitialFiles() 的自动仲裁顺序可概括如下(仅在对应条件满足时生效,例如 compaction_style 为 level,且 TTL/periodic/blob GC 相关选项开启):score 驱动任务、FilesMarkedForCompactionBottommostFilesMarkedForCompactionExpiredTtlFilesFilesMarkedForPeriodicCompactionFilesMarkedForForcedBlobGC

调度最后还会与写入限流协同,GetWriteStallConditionAndCause() 使用 l0_delay_trigger_countestimated_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 许可协议。转载请注明出处!

# 数据库