Paimon源码解读 -- Compaction-6.CompactStrategy

前言

本文重点介绍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
步骤 判断条件 压缩类型 优先级 触发频率
步骤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
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-rowdeletion-vectorschangelog=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
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 数量影响较小
相关推荐
忘记9266 小时前
什么是spring boot
java·spring boot·后端
喵个咪6 小时前
开箱即用的 GoWind Admin|风行,企业级前后端一体中后台框架:极速搭建微服务应用
后端·微服务·go
十月南城7 小时前
多级缓存设计思路——本地 + 远程的一致性策略、失效风暴与旁路缓存的取舍
后端
float_六七7 小时前
Spring AOP连接点实战解析
java·后端·spring
武子康7 小时前
大数据-183 Elasticsearch - 并发冲突与乐观锁、分布式数据一致性剖析
大数据·后端·elasticsearch
Hello.Reader7 小时前
Flink SQL Top-N 深度从“实时榜单”到“少写点数据”
大数据·sql·flink
期待のcode8 小时前
MyBatis-Plus的Wrapper核心体系
java·数据库·spring boot·后端·mybatis
老华带你飞8 小时前
出行旅游安排|基于springboot出行旅游安排系统(源码+数据库+文档)
java·数据库·vue.js·spring boot·后端·spring·旅游
梦里不知身是客118 小时前
Combiner在mapreduce中的作用
大数据·mapreduce