第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树深度 | 增加→分层细致,减少→处理快 |