流式数据湖Paimon探秘之旅 (九) Compaction压缩机制

第9章:Compaction压缩机制

导言:Compaction的目标

在第8章,我们学到了LSM Tree的分层结构和读取原理。但如何维护这个结构?如何定期清理冗余数据?答案就是Compaction(压缩)

Compaction的目标

ini 复制代码
问题:Level 0有太多文件,读取时需要打开所有文件
Level 0: [F1][F2][F3][F4][F5]...(50个文件,1GB)
    ↓
Compaction:合并这些文件,减少文件数
    ↓
Level 1: [F51][F52](2个大文件,1GB)
    ↓
结果:读性能提升10倍!

简单来说:Compaction是LSM Tree的"后勤部队",默默维护树的性能。


第一部分:Compaction的触发时机

1.1 四种触发条件

scss 复制代码
┌─────────────────────────────────────┐
│       Compaction触发条件             │
├─────────────────────────────────────┤
│ 1. 文件数触发                       │
│    Level N文件数 > num-sorted-run-  │
│    compaction-trigger(如4)          │
│                                     │
│ 2. 大小比例触发                     │
│    Level N / Level N-1 <            │
│    sorted-run-size-ratio(如2)       │
│                                     │
│ 3. 定期检查触发                     │
│    每5分钟检查一次                  │
│    是否需要Compaction               │
│                                     │
│ 4. 预提交触发                       │
│    prepareCommit(true)时            │
│    立即等待进行中的Compaction       │
└─────────────────────────────────────┘

1.2 触发流程

java 复制代码
public class MergeTreeCompactManager implements CompactManager {
    
    private ExecutorService compactExecutor;  // 后台线程
    private CompactStrategy strategy;          // 压缩策略
    
    public void handleCompaction() {
        // 定期检查(每次flush后)
        if (needsCompaction()) {
            // 提交压缩任务到线程池
            compactExecutor.submit(() -> doCompaction());
        }
    }
    
    private boolean needsCompaction() {
        // 条件1:Level 0文件数
        if (levels.level(0).size() > compactionTrigger) {
            return true;
        }
        
        // 条件2:大小比例
        long level0Size = levels.level(0).totalSize();
        long level1Size = levels.level(1).totalSize();
        if (level0Size / level1Size < sizeRatio) {
            return true;
        }
        
        return false;
    }
    
    private void doCompaction() {
        // 后台执行Compaction
        List<DataFileMeta> inputFiles = selectFilesToCompact();
        List<DataFileMeta> outputFiles = merge(inputFiles);
        updateLevels(inputFiles, outputFiles);
    }
}

第二部分:UniversalCompaction策略

2.1 策略概述

什么是UniversalCompaction?

UniversalCompaction是Paimon使用的分层合并策略(借鉴自RocksDB):

复制代码
核心思想:
├─ 定期检查各层大小
├─ 如果某层过大,向下合并
├─ 逐级向下推,最终数据沉淀到最底层
└─ 结果:维持LSM树的性能

对比:
├─ LeveledCompaction(传统):严格按level合并,开销大
├─ TieredCompaction(宽松):积累很多文件才合并,写快但读慢
└─ UniversalCompaction(平衡):灵活选择合并时机

2.2 合并策略的选择逻辑

java 复制代码
public class UniversalCompaction implements CompactStrategy {
    
    public CompactInput selectCompactInput(Levels levels) {
        // 核心逻辑:从哪个level开始合并?
        
        // Step 1: 扫描所有level,找第一个"过大"的level
        for (int i = 0; i < levels.size(); i++) {
            long currentSize = levels.level(i).totalSize();
            long expectedSize = expectedSizeAtLevel(i);
            
            if (currentSize > expectedSize * sizeRatio) {
                // 找到了!从这个level开始合并
                return selectFilesFrom(i);
            }
        }
        
        return null;  // 无需合并
    }
    
    private long expectedSizeAtLevel(int level) {
        // 预期大小 = Level 0大小 × ratio^level
        return level0Size * Math.pow(sizeRatio, level);
    }
}

2.3 合并选择示例

场景

yaml 复制代码
Level 0: 100MB (4文件)
Level 1: 150MB (2文件)   ← 这里有问题!
Level 2: 400MB (1文件)
Level 3: 1600MB (1文件)

expected size计算:
Level 0: 100MB (基准)
Level 1: 100 × 2 = 200MB   ← 实际150MB < 200MB,OK
Level 2: 100 × 4 = 400MB   ← 实际400MB = 400MB,OK
Level 3: 100 × 8 = 800MB   ← 实际1600MB > 800MB,有问题!

触发Compaction

markdown 复制代码
Level 3过大 → 需要合并
    ↓
但从哪个level开始?
    ↓
使用贪心:从最低的过大level开始(Level 3)
    ↓
选择Level 3 + Level 2的部分文件
    ↓
合并 Level 3,输出到新的Level 3文件

第三部分:Compaction的执行流程

3.1 文件选择

markdown 复制代码
输入:Levels结构,包含所有文件

Step 1: 确定要合并的Level
    └─ 例如:Level 0和Level 1

Step 2: 选择该Level中的文件
    ├─ 所有Level 0文件(必须全选)
    └─ Level 1中与Level 0有重叠的文件

Step 3: 收集待合并文件
    ├─ 文件1: 100MB
    ├─ 文件2: 150MB
    ├─ 文件3: 120MB
    └─ 总计:370MB

3.2 合并(Merge)

关键步骤

ini 复制代码
输入文件(都已排序):
F1: key [1-100], rows [1-50]
F2: key [50-150], rows [51-80]
F3: key [90-200], rows [81-100]

合并过程:

╔═══════════════════════════════════════╗
║ 多路归并(使用堆或败者树)           ║
║                                       ║
║ input_stream_1: 1, 2, 3, ... 100      ║
║ input_stream_2: 50, 51, ..., 150      ║
║ input_stream_3: 90, 91, ..., 200      ║
║                                       ║
║ 合并逻辑:每次取最小的key             ║
║ 1 → output                            ║
║ 2 → output                            ║
║ ...                                   ║
║ 50[F1] vs 50[F2] → 选F2(更新)        ║
║ ...                                   ║
╚═══════════════════════════════════════╝

输出(已排序,无重复):
key 1-200的所有不同值

去重逻辑

ini 复制代码
同一key在多个输入文件中:

key=50:
  ├─ F1: (value="Alice", seq=1, ts=1000)
  ├─ F2: (value="Alice_v2", seq=3, ts=3000)  ← 最新
  └─ F3: (value="Alice_v1", seq=2, ts=2000)

选择规则:
1. 按sequence number排序(新的seq号更大)
2. 选seq=3的版本(最新)
3. 对于DELETE记录(flag=DELETE),合并时删除

结果:
key=50: (value="Alice_v2", seq=3, ts=3000)

3.3 编码与压缩

vbnet 复制代码
去重后的数据(排序):
key1, key2, key3, ..., key100000

编码步骤:

Step 1: 按列编码(Parquet格式)
    ├─ key列: [1, 2, 3, ..., 100000]
    ├─ value列: ["Alice", "Bob", ...]
    └─ 其他字段...

Step 2: 压缩
    ├─ 算法: snappy(快速)或gzip(高压缩率)
    ├─ 压缩率: 8:1(数据 1GB → 125MB)
    └─ 耗时: 1-2秒

Step 3: 生成DataFile
    ├─ 文件名: 20240101-001-000.parquet
    ├─ 大小: 125MB(实际大小)
    └─ Stats: min_key, max_key, row_count等

输出:
└─ DataFile_merged.parquet (125MB)

3.4 更新Levels结构

css 复制代码
操作前:
Level 0: [F1(100MB)] [F2(150MB)] [F3(120MB)]
Level 1: [F4(200MB)] [F5(250MB)]

操作后:
Level 0: (清空)
Level 1: [F4(200MB)] [F5(250MB)] [F_new(125MB)]
         └─ 新文件是F1+F2+F3的合并结果

删除:F1, F2, F3
添加:F_new

原子性保证

vbnet 复制代码
为了保证一致性,Levels更新必须是原子的:

Step 1: 生成新文件到临时位置
Step 2: 原子更新Levels元数据
Step 3: 删除旧文件

如果Step 2失败:
├─ 临时文件可能丢留,但不影响功能
├─ 下次启动时清理
└─ 数据不会丢失或重复

第四部分:完整Compaction流程

4.1 流程图

scss 复制代码
┌──────────────────────────────┐
│ Flush WriteBuffer            │
│ → 生成Level 0新文件          │
└───────┬──────────────────────┘
        │
        ↓
┌──────────────────────────────┐
│ 检查Compaction条件           │
│ (文件数 > trigger? 或size不平衡?)
└───────┬──────────────────────┘
        │ No
        ├─→ 继续写入
        │
        │ Yes
        ↓
┌──────────────────────────────┐
│ 后台线程:                   │
│ 1. 选择要合并的文件          │
│ 2. 多路归并                  │
│ 3. 去重                      │
│ 4. 压缩编码                  │
│ 5. 生成新文件                │
│ 6. 原子更新Levels            │
│ 7. 删除旧文件                │
└───────┬──────────────────────┘
        │
        ↓
┌──────────────────────────────┐
│ 应用准备提交                 │
│ prepareCommit(true)          │
└───────┬──────────────────────┘
        │
        ├─ 等待后台Compaction完成
        │
        ↓
┌──────────────────────────────┐
│ 生成CommitMessage            │
│ (包含所有新生成的文件)       │
└──────────────────────────────┘

4.2 性能分析

时间复杂度

ini 复制代码
假设:合并4个文件,每个100MB = 400MB总输入

Merge: O(n log k)
├─ n = 记录数 ≈ 400M条(假设1字节/行)
├─ k = 输入流数 = 4
└─ 实际:O(n) 线性扫描(4个已排序流的合并)
└─ 耗时:1-2秒

Compress:
├─ 算法:snappy
├─ 速度:500MB/s
└─ 耗时:400MB ÷ 500MB/s = 0.8秒

总耗时:~2-3秒(后台进行)

成本对比

erlang 复制代码
不压缩:
├─ 文件数:100个(每个4MB)
├─ 读取需要打开100个文件
├─ 点查延迟:>500ms(打开太多文件)

压缩后:
├─ 文件数:25个(每个16MB)
├─ 读取只需打开3-5个文件
├─ 点查延迟:<100ms
└─ 改善:5倍!

代价:
├─ CPU:压缩时30%(后台进行)
├─ 磁盘IO:写200MB文件
└─ 不影响应用写入(异步进行)

第五部分:CompactRewriter(压缩重写器)

5.1 什么是CompactRewriter

CompactRewriter负责将多个输入文件合并成输出文件的具体逻辑:

java 复制代码
public interface CompactRewriter {
    
    /**
     * 重写文件
     * @param inputFiles 待合并文件
     * @return 生成的新文件
     */
    List<DataFileMeta> rewrite(List<DataFileMeta> inputFiles);
}

两种实现

markdown 复制代码
1. MergeTreeCompactRewriter(标准去重合并)
   ├─ 输入:多个有序文件
   ├─ 处理:多路归并 + 去重
   └─ 输出:单个合并文件

2. FullChangelogMergeTreeCompactRewriter(全量日志)
   ├─ 输入:多个文件 + changelog标记
   ├─ 处理:合并同时保留changelog
   └─ 输出:合并文件 + 新的changelog文件

5.2 MergeTreeCompactRewriter的实现

java 复制代码
public class MergeTreeCompactRewriter implements CompactRewriter {
    
    private KeyValueFileReaderFactory readerFactory;
    private KeyValueFileWriterFactory writerFactory;
    private Comparator<InternalRow> keyComparator;
    private MergeFunctionFactory mfFactory;
    
    @Override
    public List<DataFileMeta> rewrite(
            List<DataFileMeta> inputFiles) throws Exception {
        
        // Step 1: 打开所有输入文件的读取器
        List<RecordReader<KeyValue>> readers = new ArrayList<>();
        for (DataFileMeta file : inputFiles) {
            readers.add(readerFactory.createReader(file));
        }
        
        // Step 2: 创建合并输入(多路归并)
        RecordIterator<KeyValue> merged = 
            new MergingIterator<>(readers, keyComparator);
        
        // Step 3: 创建输出writer
        RecordWriter<KeyValue> writer = 
            writerFactory.createWriter();
        
        // Step 4: 遍历合并数据,去重后写入
        KeyValue previous = null;
        for (KeyValue kv : merged) {
            if (previous == null || 
                !keyComparator.equal(kv.key(), previous.key())) {
                // 不同的key,直接写入
                writer.write(kv);
            } else {
                // 相同的key,合并value
                KeyValue merged_kv = 
                    mfFactory.merge(previous, kv);
                writer.write(merged_kv);
            }
            previous = kv;
        }
        
        // Step 5: 生成输出文件
        CommitIncrement result = writer.prepareCommit(false);
        return result.newFiles();
    }
}

第六部分:实战案例

6.1 案例1:监控Compaction过程

markdown 复制代码
场景:
Paimon表有50GB数据,需要了解Compaction进度

监控指标:

1. Compaction触发频率
   ├─ 每1小时触发一次 → 正常
   ├─ 每5分钟触发一次 → 压缩跟不上,需要优化
   └─ 从不触发 → 数据量太小

2. 单次Compaction耗时
   ├─ <10秒 → 正常
   ├─ 10-30秒 → 中等(可能有影响)
   └─ >30秒 → 后台堆积(需要提升Compaction线程)

3. Compaction CPU占用
   ├─ <30% → 正常(后台处理)
   ├─ 30-60% → 中等(读性能可能轻微下降)
   └─ >60% → 严重(读性能明显下降)

6.2 案例2:调优高吞吐写入场景

场景:电商订单表,日均50M新增,10%的更新

初始配置

yaml 复制代码
num-sorted-run-compaction-trigger: 4
sorted-run-size-ratio: 2

问题:Compaction频繁(每5分钟一次),CPU占用60%

优化

yaml 复制代码
num-sorted-run-compaction-trigger: 8   # 增加触发阈值
sorted-run-size-ratio: 4               # 放宽大小比例
max-size-amplification-percent: 200    # 允许膨胀

# 也可以增加压缩线程
num-compact-threads: 4                 # 原来可能只有1个

结果

erlang 复制代码
Compaction频率降低到每30分钟一次
CPU占用降低到30%
写入吞吐提升50%
读延迟增加10%(仍在可接受范围)

6.3 案例3:调优低延迟查询场景

场景:用户维表,频繁的点查

初始配置

yaml 复制代码
num-sorted-run-compaction-trigger: 4
sorted-run-size-ratio: 2
lookup-enabled: false

问题:点查延迟>200ms

优化

yaml 复制代码
num-sorted-run-compaction-trigger: 2   # 更激进的压缩
sorted-run-size-ratio: 2               # 保持不变
lookup-enabled: true                   # 启用lookup表
lookup-cache-file-retention: 30min     # 缓存lookup文件

结果

erlang 复制代码
点查延迟降低到<50ms(主要来自lookup优化)
Compaction频率增加(但CPU占用仍<40%)
写入吞吐略微下降(5-10%,可接受)

第七部分:高级特性

7.1 OffPeakHours(非高峰压缩)

问题:白天Compaction占用CPU,影响应用性能

解决方案:在非高峰时段(如晚上)进行强制Compaction

yaml 复制代码
# 配置非高峰时段压缩
full-compaction-start-time: "22:00"  # 晚上10点
full-compaction-end-time: "06:00"    # 早上6点

# 高峰时段
peak-hours-start: "09:00"
peak-hours-end: "18:00"

效果:
├─ 高峰期:压缩宽松,优先写入吞吐
├─ 非高峰期:压缩激进,清理LSM树
└─ 结果:全天性能均衡

7.2 FullCompaction(全量压缩)

目标:定期执行一次完整的LSM树重建

sql 复制代码
触发条件:
├─ 按计划(每天一次)
├─ 手动触发(CALL full_compact())
└─ 空间膨胀(LSM树大小超过目标的150%)

执行过程:
Level 0 → merge → Level 1
          ↓
Level 1 + Level 2 → merge → Level 2
          ↓
Level 2 + Level 3 → merge → Level 3
          ↓
...最终整个树紧凑

成本:
├─ 一次性耗时:30分钟-2小时
├─ CPU占用:80%+(后台完全利用)
└─ 读性能:无影响

优点:
└─ 压缩完成后,LSM树最优化
   → 读性能最好
   → 磁盘占用最少

7.3 异步Compaction队列

复制代码
场景:多个表的Compaction任务堆积

解决方案:
Compaction Queue(优先级队列)

高优先级:
├─ 主键表(影响写入性能)
└─ Level 0堆积的表(可能阻塞应用)

低优先级:
├─ 追加表(Level 0堆积影响小)
└─ 不经常查询的表(读性能影响小)

调度:
└─ 先处理高优先级任务
   再处理低优先级任务

第八部分:常见问题与故障排查

Q1: Compaction突然停止,写入阻塞

现象

arduino 复制代码
应用写入:正常 → 突然延迟增加 → 阻塞
Compaction线程:空闲
错误日志:Exception in compaction thread

原因

复制代码
Compaction线程崩溃(通常是内存或磁盘满)

解决

markdown 复制代码
1. 检查日志找出崩溃原因
2. 增加磁盘空间或内存
3. 重启应用(自动触发Compaction)

Q2: Compaction频率过高,CPU占用高

现象

bash 复制代码
CPU占用:80%+(几乎全部用于Compaction)
写入吞吐:50MB/s(本应500MB/s)

原因

arduino 复制代码
Compaction参数设置过激进
- num-sorted-run-compaction-trigger太小
- sorted-run-size-ratio太小

解决

makefile 复制代码
# 放宽条件,降低Compaction频率
num-sorted-run-compaction-trigger: 8
sorted-run-size-ratio: 4

# 或增加Compaction线程处理能力
num-compact-threads: 4

Q3: 磁盘占用持续增长

现象

复制代码
初始:20GB
1周后:50GB
1个月后:200GB+

原因

复制代码
Compaction跟不上写入速度
LSM树过度膨胀

解决

csharp 复制代码
1. 立即触发FullCompaction
   CALL full_compact();
   
2. 检查写入速度
   如果>1GB/s,需要增加机器
   
3. 优化Compaction参数
   - 增加Compaction线程
   - 激进的压缩策略

Q4: 部分key重复出现

现象

sql 复制代码
select count(distinct id) = 1000
select count(*) = 1200

存在200条重复数据!

原因

sql 复制代码
Compaction去重逻辑有bug
或merge function实现错误

解决

ini 复制代码
1. 检查merge-engine配置
   verify merge-engine == 'deduplicate'

2. 检查sequence.field是否设置
   如果未设置,无法判断版本新旧

3. 运行修复工具
   CALL repair_deduplication();

第九部分:性能对标

Compaction参数的性能影响

matlab 复制代码
基准配置(推荐):
num-sorted-run-compaction-trigger: 4
sorted-run-size-ratio: 2
results:
├─ 写入吞吐:400MB/s
├─ 点查延迟:<100ms
└─ Compaction CPU:30%

激进压缩:
num-sorted-run-compaction-trigger: 2
sorted-run-size-ratio: 2
results:
├─ 写入吞吐:380MB/s(-5%)
├─ 点查延迟:<50ms(-50%)✅
└─ Compaction CPU:50%(+67%)

宽松压缩:
num-sorted-run-compaction-trigger: 8
sorted-run-size-ratio: 4
results:
├─ 写入吞吐:500MB/s(+25%)✅
├─ 点查延迟:<200ms(+100%)
└─ Compaction CPU:15%(-50%)

总结

Compaction的生命周期

markdown 复制代码
定期监控
    ↓
检测到条件满足
    ↓
后台线程执行
    ├─ 选择文件
    ├─ 多路归并
    ├─ 去重
    └─ 压缩编码
    ↓
原子更新Levels
    ↓
删除旧文件
    ↓
读性能改善

调优checklist

  • 根据业务选择压缩策略(吞吐优先 vs 延迟优先)
  • 设置合理的触发条件(避免频繁或稀少)
  • 启用Lookup(对于低延迟场景)
  • 配置非高峰压缩(对于生产系统)
  • 定期执行FullCompaction(每周一次)
  • 监控Compaction CPU和磁盘IO
  • 根据业务高峰期动态调整参数

关键参数说明

参数 含义 调优方向
num-sorted-run-compaction-trigger 触发文件数 ↑吞吐,↓延迟(反向)
sorted-run-size-ratio 层级大小比例 ↑吞吐,↓延迟(反向)
max-size-amplification-percent 允许膨胀百分比 ↑吞吐,↓磁盘占用(反向)
num-levels LSM树深度 增加→分层细致,减少→处理快

相关推荐
语落心生39 分钟前
流式数据湖Paimon探秘之旅 (十) Merge Engine合并引擎
大数据
en-route39 分钟前
深入理解数据仓库设计:事实表与事实宽表的区别与应用
大数据·数据仓库·spark
语落心生40 分钟前
流式数据湖Paimon探秘之旅 (八) LSM Tree核心原理
大数据
Light6042 分钟前
智慧办公新纪元:领码SPARK融合平台如何重塑企业OA核心价值
大数据·spark·oa系统·apaas·智能办公·领码spark·流程再造
智能化咨询1 小时前
(66页PPT)高校智慧校园解决方案(附下载方式)
大数据·数据库·人工智能
忆湫淮1 小时前
ENVI 5.6 利用现场标准校准板计算地表反射率具体步骤
大数据·人工智能·算法
lpfasd1231 小时前
现有版权在未来的价值:AI 泛滥时代的人类内容黄金
大数据·人工智能
庄小焱1 小时前
大数据存储域——图数据库系统
大数据·知识图谱·图数据库·大数据存储域·金融反欺诈系统
jiayong231 小时前
Elasticsearch Java 开发完全指南
java·大数据·elasticsearch