前言
本文重点介绍Paimon-Compaction的重要策略,包括全量压缩、比率压缩等
关键概念:
-
SortedRun:有序的文件集合
- Level 0: 每个文件是一个 run (文件间可能有键重叠)
- Level 1+: 每层所有文件是一个 run (层内文件间无键重叠)
-
CompactUnit:被选中要压缩的文件单元,以run为单元
一.CompactStrategy接口
这是个接口,其实现子类如下:

java
public interface CompactStrategy {
/**
* 从所有 runs 中挑选需要压缩的 CompactUnit,由子类实现
*
* 核心规则:
* 1. 压缩是基于 runs 的,而非单个文件
* 2. Level 0 特殊:一个文件一个 run;其他层级:一层一个 run
* 3. 压缩是顺序的:从小 level 到大 level
*/
Optional<CompactUnit> pick(int numLevels, List<LevelSortedRun> runs);
/**
* 挑选需要全量压缩的单元,该方法在MergeTreeCompactManager.triggerCompaction()中针对全量压缩调用
* @param numLevels
* @param runs
* @return
*/
static Optional<CompactUnit> pickFullCompaction(int numLevels, List<LevelSortedRun> runs) {
int maxLevel = numLevels - 1;
// 如果没有 run 或只有一个 run 在最高层,则无需压缩
if (runs.isEmpty() || (runs.size() == 1 && runs.get(0).level() == maxLevel)) {
// no sorted run or only 1 sorted run on the max level, no need to compact
return Optional.empty();
} else {
// 否则,选择全部level层的全部runs需要压缩
return Optional.of(CompactUnit.fromLevelRuns(maxLevel, runs));
}
}
}
二.UniversalCompaction -- 通用压缩策略
1.代码解析
(1) 核心变量和构造函数
| 参数 | 默认值 | 作用 | 示例 |
|---|---|---|---|
| compaction.max-size-amplification-percent | 200 | 最大空间放大百分比,超过则触发压缩 | 200% 表示允许最多 2 倍空间浪费 |
| compaction.size-ratio | 1 | 相邻层级的大小比率阈值 | 比率 < 1 时触发合并 |
| num-sorted-run.compaction-trigger | 5 | 触发压缩的最小 run 数量 | 当 Level 0 有 5 个文件时触发 |
| compaction.optimization-interval | null | 优化压缩间隔 (多少次普通压缩后触发一次优化压缩) | 设置为 5 表示每 5 次普通压缩执行 1 次优化压缩 |
java
private final int maxSizeAmp; // 由参数'compaction.max-size-amplification-percent'绑定 (默认 200)
private final int sizeRatio; // 由参数'compaction.size-ratio'绑定 (默认 1)
private final int numRunCompactionTrigger; // 由参数'num-sorted-run.compaction-trigger' (默认 5)
@Nullable private final Long opCompactionInterval; // 由参数'compaction.optimization-interval'绑定 (可选)
@Nullable private Long lastOptimizedCompaction; // 已执行的压缩次数计数器
public UniversalCompaction(int maxSizeAmp, int sizeRatio, int numRunCompactionTrigger) {
this(maxSizeAmp, sizeRatio, numRunCompactionTrigger, null);
}
public UniversalCompaction(
int maxSizeAmp,
int sizeRatio,
int numRunCompactionTrigger,
@Nullable Duration opCompactionInterval) {
this.maxSizeAmp = maxSizeAmp;
this.sizeRatio = sizeRatio;
this.numRunCompactionTrigger = numRunCompactionTrigger;
// 将compaction.optimization-interval配置的值转为毫秒
this.opCompactionInterval =
opCompactionInterval == null ? null : opCompactionInterval.toMillis();
}
(2) pick() -- 策略核心
流程总结如下:
flowchart TD
A[pick 方法开始] --> B{步骤1: 是否达到
压缩时间间隔opCompactionInterval?} B -->|是| C[触发全量压缩
压缩所有文件到最高层] B -->|否| D{步骤2: 是否空间放大
超过阈值?
pickForSizeAmp} D -->|是| E[触发全量压缩
压缩所有文件到最高层] D -->|否| F{步骤3: 是否大小比率
不合理?
pickForSizeRatio} F -->|是| G[触发部分压缩
压缩部分文件] F -->|否| H{步骤4: 文件数量是否
> numRunCompactionTrigger?} H -->|是| I[触发部分压缩
压缩多余文件] H -->|否| J[返回 empty
本次不压缩] style C fill:#fff9c4 style E fill:#fff9c4 style G fill:#c8e6c9 style I fill:#c8e6c9 style J fill:#ffcdd2
压缩时间间隔opCompactionInterval?} B -->|是| C[触发全量压缩
压缩所有文件到最高层] B -->|否| D{步骤2: 是否空间放大
超过阈值?
pickForSizeAmp} D -->|是| E[触发全量压缩
压缩所有文件到最高层] D -->|否| F{步骤3: 是否大小比率
不合理?
pickForSizeRatio} F -->|是| G[触发部分压缩
压缩部分文件] F -->|否| H{步骤4: 文件数量是否
> numRunCompactionTrigger?} H -->|是| I[触发部分压缩
压缩多余文件] H -->|否| J[返回 empty
本次不压缩] style C fill:#fff9c4 style E fill:#fff9c4 style G fill:#c8e6c9 style I fill:#c8e6c9 style J fill:#ffcdd2
| 步骤 | 判断条件 | 压缩类型 | 优先级 | 触发频率 |
|---|---|---|---|---|
| 步骤1 | 时间间隔达到 | 全量压缩 | 🔴 最高 | 定期触发 (如每 1 小时) |
| 步骤2 | 空间放大超标 | 全量压缩 | 🟠 高 | 空间浪费严重时 |
| 步骤3 | 大小比率不合理 | 部分压缩 | 🟡 中 | 层级失衡时 |
| 步骤4 | 文件数量过多 | 部分压缩 | 🟢 低 | 文件数量超标时 |
| 源码分析 |
java
@Override
public Optional<CompactUnit> pick(int numLevels, List<LevelSortedRun> runs) {
int maxLevel = numLevels - 1;
// 步1. 达到压缩时间间隔触发全量压缩 -- 高优
if (opCompactionInterval != null) {
if (lastOptimizedCompaction == null
|| currentTimeMillis() - lastOptimizedCompaction > opCompactionInterval) {
LOG.debug("Universal compaction due to optimized compaction interval");
updateLastOptimizedCompaction();
return Optional.of(CompactUnit.fromLevelRuns(maxLevel, runs));
}
}
// 步2. 空间放大检查,调pickForSizeAmp(),若达到空间放大边界,则触发全量压缩
CompactUnit unit = pickForSizeAmp(maxLevel, runs);
if (unit != null) {
if (LOG.isDebugEnabled()) {
LOG.debug("Universal compaction due to size amplification");
}
return Optional.of(unit);
}
// 步3. 相邻Sorted Run文件大小比率检查,调pickForSizeRatio(),文件小于一定比率,加入部分压缩单元,最终触发部分压缩
unit = pickForSizeRatio(maxLevel, runs);
if (unit != null) {
if (LOG.isDebugEnabled()) {
LOG.debug("Universal compaction due to size ratio");
}
return Optional.of(unit); // 部分压缩
}
// 步4. Sorted Run文件数量检查,超过numRunCompactionTrigger数量,则还需要结合文件比率去进行筛选部分压缩单元,最终触发部分压缩
if (runs.size() > numRunCompactionTrigger) {
// compacting for file num
// 计算要压缩的文件数=runs.size() - numRunCompactionTrigger + 1;
int candidateCount = runs.size() - numRunCompactionTrigger + 1;
if (LOG.isDebugEnabled()) {
LOG.debug("Universal compaction due to file num");
}
return Optional.ofNullable(pickForSizeRatio(maxLevel, runs, candidateCount));
}
return Optional.empty();
}
(3) pickForSizeAmp() -- 空间放大检查
默认情况,除了你最大的run外,其他的run的大小和已经超过了最大run文件大小的maxSizeAmp(默认是200) / 100倍了,说明已经空间放大了,需要全量压缩
java
@VisibleForTesting
CompactUnit pickForSizeAmp(int maxLevel, List<LevelSortedRun> runs) {
// 前置检查: run 数量必须 >= numRunCompactionTrigger
if (runs.size() < numRunCompactionTrigger) {
return null;
}
// 步骤 1: 计算除最后一个 run 外的所有 run 的总大小 -- candidateSize
long candidateSize =
runs.subList(0, runs.size() - 1).stream()
.map(LevelSortedRun::run)
.mapToLong(SortedRun::totalSize)
.sum();
// 步骤 2: 获取最后一个 run 的大小 (通常是最大、最老的文件) -- earliestRunSize
long earliestRunSize = runs.get(runs.size() - 1).run().totalSize();
// 步骤 3: 计算空间放大率并判断是否需要进行全量压缩
// 公式: candidateSize * 100 > maxSizeAmp * earliestRunSize
// 等价于: candidateSize / earliestRunSize > maxSizeAmp(默认是200) / 100
// 也就是说默认情况,除了你最大的run,其他的run的大小和已经超过了最大run文件大小的2倍了,说明已经空间放大了,需要全量压缩
if (candidateSize * 100 > maxSizeAmp * earliestRunSize) {
updateLastOptimizedCompaction();
return CompactUnit.fromLevelRuns(maxLevel, runs);
}
return null;
}
(4) pickForSizeRatio() -- 相邻文件比率检查
默认情况下,当下一个Sorted Run文件大小 <= 下一个Sorted Run前面所有文件的大小和 * (1 + sizeRatio(默认是1)/100),则将下一个Run文件加入到候选集合中
java
@VisibleForTesting
CompactUnit pickForSizeRatio(int maxLevel, List<LevelSortedRun> runs) {
// 前置检查: run 数量必须 >= numRunCompactionTrigger
if (runs.size() < numRunCompactionTrigger) {
return null;
}
// 从第一个文件开始检查,调pickForSizeRatio重载方法
return pickForSizeRatio(maxLevel, runs, 1);
}
private CompactUnit pickForSizeRatio(
int maxLevel, List<LevelSortedRun> runs, int candidateCount) {
// 指定候选文件数量,forcePick = false
return pickForSizeRatio(maxLevel, runs, candidateCount, false);
}
public CompactUnit pickForSizeRatio(
int maxLevel, List<LevelSortedRun> runs, int candidateCount, boolean forcePick) {
// 步骤1: 计算初始候选集合的总大小
long candidateSize = candidateSize(runs, candidateCount);
// 步骤 2: 尝试扩展候选集合 (向后合并更多文件), 从非候选文件开始检查是否可以将后面更多的 Sort runs 纳入压缩范围
for (int i = candidateCount; i < runs.size(); i++) {
LevelSortedRun next = runs.get(i);
// 步骤 2.1: 判断是否应该停止扩展
// 条件: 当前候选集合的文件总大小 * (1 + sizeRatio/100) < 下一个 run 大小,则退出循环
// 目的: 防止将过大的文件纳入压缩范围
if (candidateSize * (100.0 + sizeRatio) / 100.0 < next.run().totalSize()) {
break;
}
// 步骤 2.2: 符合条件,则将下一个 run 纳入候选集合
candidateSize += next.run().totalSize();
candidateCount++;
}
// 步骤 3: 对候选集合,进行创建压缩单元
// forcePick=true: 强制压缩 (即使只有 1 个文件) -- 由ForceUpLevel0Compaction策略传入
// forcePick=false: 至少需要 2 个文件才压缩
if (forcePick || candidateCount > 1) {
return createUnit(runs, maxLevel, candidateCount);
}
return null;
}
(4) candidateSize()
java
// 计算要压缩的候选Sort Run的文件总大小和
private long candidateSize(List<LevelSortedRun> runs, int candidateCount) {
long size = 0;
for (int i = 0; i < candidateCount; i++) {
size += runs.get(i).run().totalSize();
}
return size;
}
(5) createUnit() -- 创建压缩单元
流程图如下
flowchart TD
A[createUnit 开始] --> B{runCount == runs.size?
是否压缩所有文件?} B -->|是| C[outputLevel = maxLevel
输出到最高层] B -->|否| D[outputLevel = nextRun.level - 1
输出到下一层的前一层] C --> E{outputLevel == 0?} D --> E E -->|是| F[向后查找第一个非 Level 0 层级
并扩展压缩范围] E -->|否| G{runCount == runs.size?
扩展后是否包含所有文件?} F --> G G -->|是| H[outputLevel = maxLevel
标记为优化压缩] G -->|否| I[保持当前 outputLevel] H --> J[返回 CompactUnit范围是0到runCount] I --> J style C fill:#fff9c4 style H fill:#fff9c4
是否压缩所有文件?} B -->|是| C[outputLevel = maxLevel
输出到最高层] B -->|否| D[outputLevel = nextRun.level - 1
输出到下一层的前一层] C --> E{outputLevel == 0?} D --> E E -->|是| F[向后查找第一个非 Level 0 层级
并扩展压缩范围] E -->|否| G{runCount == runs.size?
扩展后是否包含所有文件?} F --> G G -->|是| H[outputLevel = maxLevel
标记为优化压缩] G -->|否| I[保持当前 outputLevel] H --> J[返回 CompactUnit范围是0到runCount] I --> J style C fill:#fff9c4 style H fill:#fff9c4
java
@VisibleForTesting
CompactUnit createUnit(List<LevelSortedRun> runs, int maxLevel, int runCount) {
int outputLevel;
// 步1. 初步确定压缩后的输出level层级
if (runCount == runs.size()) {
// 情况 1: 压缩所有文件 => 输出到最高层
outputLevel = maxLevel;
} else {
// 情况 2: 部分压缩 => 输出到下一个 run 的前一层
// 公式: outputLevel = max(0, nextRun.level - 1)
outputLevel = Math.max(0, runs.get(runCount).level() - 1);
}
// 步2. 处理L0层的情况
if (outputLevel == 0) {
// Level 0 不应该是输出层 (Level 0 是临时层)
// 需要遍历所有的runs, 向后查找第一个非 Level 0 的层级
for (int i = runCount; i < runs.size(); i++) {
LevelSortedRun next = runs.get(i);
runCount++; // 扩展压缩范围
if (next.level() != 0) { // 找到第一个非L0层的文件
outputLevel = next.level(); // 使用该层级
break;
}
}
}
// 步3. 如果扩展后,需要压缩的是所有文件,标记为优化压缩,更改输出层级为maxLevel
if (runCount == runs.size()) {
updateLastOptimizedCompaction();
outputLevel = maxLevel;
}
// 步4. 创建并返回压缩单元(范围是0到runCount)
return CompactUnit.fromLevelRuns(outputLevel, runs.subList(0, runCount));
}
(6) updateLastOptimizedCompaction()等辅助方法
java
private void updateLastOptimizedCompaction() {
// 更新上次优化压缩的时间戳,用于计算下次优化压缩时间。
lastOptimizedCompaction = currentTimeMillis();
}
long currentTimeMillis() {
return System.currentTimeMillis();
}
2.总结
sequenceDiagram
participant MCM as MergeTreeCompactManager
participant UC as UniversalCompaction
participant PFS as pickForSizeAmp
participant PFR as pickForSizeRatio
participant CU as createUnit
MCM->>UC: pick(numLevels, runs)
UC->>UC: 1. 检查优化压缩时间间隔
alt 达到时间间隔
UC-->>MCM: 返回全量压缩单元
else 未达到
UC->>PFS: pickForSizeAmp()
PFS->>PFS: 计算空间放大率
alt 超过阈值
PFS-->>UC: 返回全量压缩单元
UC-->>MCM: 返回全量压缩单元
else 未超过
UC->>PFR: pickForSizeRatio()
PFR->>PFR: 计算大小比率并扩展候选
alt 找到候选
PFR->>CU: createUnit()
CU->>CU: 确定输出层级
CU-->>PFR: 返回压缩单元
PFR-->>UC: 返回部分压缩单元
UC-->>MCM: 返回部分压缩单元
else 未找到
UC->>UC: 检查文件数量
alt 数量超标
UC->>PFR: pickForSizeRatio(candidateCount)
PFR->>CU: createUnit()
CU-->>PFR: 返回压缩单元
PFR-->>UC: 返回部分压缩单元
UC-->>MCM: 返回部分压缩单元
else 数量正常
UC-->>MCM: 返回 empty (不压缩)
end
end
end
end
| 判断 | 方法 | 触发条件 | 压缩范围 | 优先级 |
|---|---|---|---|---|
| 时间间隔 | pick() | currentTime - lastOptimized > interval | 全部文件 | P0 (最高) |
| 空间放大 | pickForSizeAmp() | candidateSize / earliestSize > maxSizeAmp% | 全部文件 | P1 |
| 大小比率 | pickForSizeRatio() | candidateSize * (1 + ratio%) < nextSize | 部分文件 | P2 |
| 文件数量 | pick() | runs.size > trigger | 多余文件 | P3 (最低) |
三.ForceUpLevel0Compaction -- 强制L0层压缩策略
该类针对的是需要lookup优化的策略,调用情况如下

详情请看:Paimon源码解读 -- Compaction-4.KeyValueFileStoreWrite前
为什么要有这个策略呢?
答案:在 NeedLookup 场景下(如 merge-engine=first-row、deletion-vectors 或 changelog=lookup),存在以下问题:
ini
问题分析:
Level 0: File1, File2, File3, File4, File5 (5 个小文件)
Level 1: File6 (1 个大文件,500 MB)
Lookup 查询流程:
1. 查询 key="user_001"
2. 依次在 File1 → File2 → File3 → File4 → File5 中查找
3. 如果都没找到,再去 File6 中查找
性能瓶颈:
- Level 0 文件越多,查找次数越多
- 每次查找都需要读取文件索引(BloomFilter、RocksDB 索引)
- 即使有索引,多个文件的索引查找开销仍然很大
因此,必须要对L0层文件进行强制压缩,从而达到读性能优化
1.代码解析
(1) 核心变量和构造函数
java
// 核心成员: 包装的 UniversalCompaction 实例
private final UniversalCompaction universal;
// 构造函数: 接收一个 UniversalCompaction 实例
public ForceUpLevel0Compaction(UniversalCompaction universal) {
this.universal = universal;
}
(2) pick() -- 策略核心
其调用流程如下
flowchart TD
A[ForceUpLevel0Compaction.pick 开始] --> B[步骤1: 调用 universal.pick]
B --> C{universal 是否
返回了压缩单元?} C -->|是| D[直接返回 universal 的结果
使用标准压缩策略] C -->|否| E[步骤2: 收集所有 Level 0 文件] E --> F[遍历 runs 列表
统计 Level 0 文件数量] F --> G{Level 0 文件数量 > 0?} G -->|否| H[返回 empty
不执行压缩] G -->|是| I[步骤3: 调用 universal.pickForSizeRatio
forcePick=true] I --> J[创建压缩单元
强制压缩所有 Level 0 文件] J --> K[返回压缩单元] style D fill:#c8e6c9 style H fill:#ffcdd2 style K fill:#fff9c4
返回了压缩单元?} C -->|是| D[直接返回 universal 的结果
使用标准压缩策略] C -->|否| E[步骤2: 收集所有 Level 0 文件] E --> F[遍历 runs 列表
统计 Level 0 文件数量] F --> G{Level 0 文件数量 > 0?} G -->|否| H[返回 empty
不执行压缩] G -->|是| I[步骤3: 调用 universal.pickForSizeRatio
forcePick=true] I --> J[创建压缩单元
强制压缩所有 Level 0 文件] J --> K[返回压缩单元] style D fill:#c8e6c9 style H fill:#ffcdd2 style K fill:#fff9c4
java
@Override
public Optional<CompactUnit> pick(int numLevels, List<LevelSortedRun> runs) {
// 步骤 1: 优先使用 UniversalCompaction 的判断
Optional<CompactUnit> pick = universal.pick(numLevels, runs);
// 如果 UniversalCompaction 已经选中了需要压缩的文件,直接返回
// 说明: 标准策略已经触发,无需额外处理
if (pick.isPresent()) {
return pick;
}
// collect all level 0 files
// 步骤 2: 针对UniversalCompaction 未触发,收集全部的 Level 0 文件
int candidateCount = 0;
for (int i = candidateCount; i < runs.size(); i++) {
if (runs.get(i).level() > 0) {
break;
}
candidateCount++;
}
// 步骤 3: 如果收集的文件中,有 Level 0 文件,强制压缩,调的还是UniversalCompaction.pickForSizeRatio(),只不过传入forcePice为true
return candidateCount == 0
? Optional.empty()
: Optional.of(
universal.pickForSizeRatio(numLevels - 1, runs, candidateCount, true));
}
三.适用场景
针对UniversalCompaction策略的几种均衡方案
| 负载类型 | 调优目标 | 参数配置建议 |
|---|---|---|
| 写密集 | 降低写放大 | maxSizeAmp=300, numTrigger=10, ratio=1 |
| 读密集 | 降低读放大 | maxSizeAmp=150, numTrigger=3, ratio=0.5 |
| 空间敏感 | 节省存储 | maxSizeAmp=100, interval=30min |
| 混合负载 | 平衡性能 | 使用默认值 (200, 5, 1) |
针对不同Merge Engie的推荐压缩策略方案
| Merge Engine | 是否需要 Lookup | 推荐策略 | 原因 |
|---|---|---|---|
| deduplicate | 否 | UniversalCompaction | 读取整个文件,Level 0 数量影响较小 |
| first-row | 是 | ForceUpLevel0Compaction | 需要快速查找第一条记录 |
| partial-update | 是 | ForceUpLevel0Compaction | 需要查找所有版本合并 |
| aggregation | 否 | UniversalCompaction | 读取整个文件聚合,Level 0 数量影响较小 |