2. Compaction 核心原理:合并小文件、去重、排序、列式存储优化与事务性保证
好,咱们直接切入正题。上一章聊了聊为什么需要 Compaction,这一章咱们就把它拆开揉碎了,看看它到底在干什么。
很多人觉得 Compaction 不就是把一堆小文件拼成大文件吗?嗯,这么说也对,但只对了一半。真正的 Compaction,尤其是数据湖仓里的 Compaction,它其实是一个「多合一」的脏活累活。它要同时搞定四件事:合并小文件、去重、排序、列式存储优化。而且,这一切还得在保证事务性的前提下完成。
听起来是不是有点复杂?别急,我一个一个说。
2.1 合并小文件:最直观的收益
这个最好理解。你想想看,HDFS 或者对象存储,它们天生就不擅长处理大量小文件。一个小文件几 MB,元数据却要占一大块。NameNode 压力大,查询时打开文件的开销也大。
Compaction 的第一个任务,就是把成百上千个这样的小文件,合并成几个几百 MB 甚至 GB 级别的大文件。
核心目标:减少文件数量,降低元数据压力,提升后续读取的吞吐量。
我在项目中遇到过最夸张的情况,一个表一天产生了 2 万多个小文件,每次查询光列举文件列表就要花好几秒。跑一次 Compaction,直接合并成 200 个文件,查询性能提升了 10 倍不止。
2.2 去重:保证数据一致性
合并文件只是第一步。更关键的是去重。
为什么会有重复数据?因为数据湖仓通常支持「幂等写入」和「更新删除」。比如你用 Spark Streaming 写数据,任务失败重试,就可能产生重复的行。或者你执行了一个 UPDATE 语句,底层其实是先标记删除旧数据,再插入新数据。如果不做去重,查询结果就是错的。
Compaction 在合并文件时,会按照表的主键或唯一键进行去重。它只保留每个键的最新版本。
我的习惯:在定义表结构时,一定要明确主键或去重键。否则 Compaction 不知道按什么规则去重,结果就是数据越压越乱。
去重的实现方式,通常有两种:
- 基于排序的去重:先按主键排序,相邻行比较,保留最新的。
- 基于哈希的去重:用哈希表记录已见过的键,但内存消耗大,适合小规模数据。
实际生产中,绝大多数场景都采用第一种方式。
2.3 排序:为查询加速
去重之后,数据还是乱序的。这时候就需要排序了。
排序的好处是什么?说白了,就是让数据变得「有序」。有序的数据,查询引擎在做范围过滤(比如 WHERE date BETWEEN '2024-01-01' AND '2024-01-31')时,可以快速定位到目标数据块,跳过大量无关数据。这就是所谓的 Data Skipping。
而且,排序后的数据在列式存储中,相邻行的值往往相同或相近,压缩率会更高。
注意:排序键的选择很关键。不是随便选个字段排序就行的。我见过有人把 UUID 作为排序键,结果排序后数据分布完全随机,压缩率和查询性能都没有提升。排序键应该选择高基数且常用于过滤条件的字段,比如时间戳、用户 ID 等。
2.4 列式存储优化:压榨存储效率
数据湖仓底层存储格式,主流就是 Parquet 和 ORC。它们都是列式存储。
Compaction 在合并文件时,会重新组织数据的列式布局。具体来说,它会做两件事:
- 重新编码:根据数据的实际分布,选择最优的编码方式。比如对于枚举值少的列,用字典编码;对于连续数值,用差值编码。
- 调整行组大小:行组是列式存储的 I/O 单元。Compaction 会把行组大小调整到一个合理的范围(通常 64MB ~ 256MB),避免过小导致 I/O 次数多,或过大导致内存压力大。
这一步做完,你会发现同样的数据,存储空间能省 30% ~ 50%,查询速度还能再快一截。
2.5 事务性保证:不能丢数据,不能读脏数据
以上所有操作,合并、去重、排序、列式优化,都是在改写数据文件。如果在这个过程中系统崩溃了怎么办?如果用户正在查询,读到一半数据被删了怎么办?
这就是事务性保证要解决的问题。
数据湖仓(比如 Apache Iceberg、Delta Lake、Hudi)通过 MVCC(多版本并发控制) 来实现事务性。简单来说:
- Compaction 不会原地修改文件,而是生成一组新的文件。
- 在 Compaction 完成之前,元数据指针仍然指向旧文件。
- 只有 Compaction 成功提交后,元数据才会原子性地切换到新文件。
- 如果 Compaction 失败,旧文件依然可用,数据不会丢失。
一句话总结:Compaction 是「写时复制」的,它保证读操作永远看到一致的数据快照。
我曾经在生产环境遇到过一个问题:Compaction 执行到一半,集群资源不足被 kill 了。当时心里一紧,以为数据要出问题。后来检查发现,旧文件完好无损,元数据根本没变。重新跑一次 Compaction 就恢复了。这就是事务性保证的价值。
2.6 核心流程总览
说了这么多,咱们用一张图把整个流程串起来。
从这张图可以看得很清楚:Compaction 不是简单的文件合并,而是一个读取 → 去重 → 排序 → 列式优化 → 写入的完整流水线。每一步都有明确的优化目标,而且整个过程被事务性保证包裹着,确保数据安全。
2.7 不同引擎的实现差异
虽然核心原理相通,但不同的数据湖仓引擎在实现上还是有细微差别的。我整理了一个对比表,方便你参考:
| 特性 | Apache Iceberg | Delta Lake | Apache Hudi |
|---|---|---|---|
| 去重策略 | 基于文件级元数据 + 排序去重 | 基于事务日志 + 文件级去重 | 基于索引 (Bloom Filter / HBase) |
| 排序方式 | 全局排序 / 局部排序 (Z-order) | Z-order 排序 | 全局排序 / 桶排序 |
| 事务实现 | 乐观锁 + 原子提交 | 乐观锁 + 事务日志 | 基于时间线 + 标记文件 |
| 列式优化 | 自动调整行组大小 | 自动调整行组大小 | 支持自定义行组大小 |
我个人习惯用 Iceberg,因为它的元数据设计更干净,Compaction 的灵活性也更高。但 Delta Lake 和 Hudi 在特定场景下也有优势,这个后面章节会细讲。
2.8 小结
这一章我们把 Compaction 的核心原理拆解了一遍。记住四个关键词:合并、去重、排序、列式优化。再加上一个贯穿始终的事务性保证,这就是 Compaction 的全部秘密。