4. Apache Hudi 深度解析(二):写入流程、增量查询、Clustering 与 Compaction
上一章我们聊了 Hudi 的表类型和索引,算是打了个底。这一章咱们直接上手,看看数据到底是怎么写进去的,又怎么读出来。特别是增量查询、Clustering 和 Compaction 这几个核心机制,我当年在生产环境里可没少跟它们打交道。
4.1 写入流程:从 Record 到 File Group
Hudi 的写入,说白了就是把你的数据记录,组织成一个个文件组。我习惯把整个过程拆成三步:
- 输入转换:把上游的 Avro、Parquet 或 JSON 数据,转成 Hudi 内部的 HoodieRecord。
- 索引定位:根据记录的主键,去索引里查一下——这条数据之前有没有写过?
- 文件写入:根据索引结果,决定是插入新文件,还是更新已有文件。
举个例子,你有一条用户数据,主键是 user_id=1001。Hudi 会先查索引,如果发现这个 ID 已经存在,就把这条记录标记为「更新」,写入对应的文件组。如果是新 ID,就标记为「插入」,分配到一个新的文件组。
关键点:写入时,数据先写到内存中的缓冲区(类似 Spark 的 Dataframe),等攒够一批(比如 120MB),再一次性刷到磁盘。这样做能大幅减少小文件数量。
嗯,这里要注意写入模式的选择。Hudi 支持三种:
| 写入模式 | 适用场景 | 我的经验 |
|---|---|---|
| UPSERT | 既有插入又有更新 | 最常用,但索引开销大 |
| INSERT | 纯新增数据 | 适合日志导入,速度快 |
| BULK_INSERT | 大规模初始加载 | 我用来做历史数据回填,比 INSERT 快 3-5 倍 |
小技巧:如果你确定数据里没有重复主键,直接用 BULK_INSERT。它跳过了索引查找和排序,写入性能会好很多。我在一次 10TB 历史数据迁移中,就靠这个把时间从 6 小时压缩到了 2 小时。
4.2 增量查询:只读变化的数据
增量查询是 Hudi 最吸引我的特性之一。你想想看,传统数仓里你要查「今天新增了哪些数据」,要么全表扫描,要么依赖业务时间戳。Hudi 的做法更优雅——它记录了每个 commit 变更的文件列表。
查询时,你只需要指定一个起始时间戳:
-- 查询从 '20240101000000' 之后的所有变更
SELECT * FROM hudi_table
WHERE _hoodie_commit_time > '20240101000000'
为什么能做到?因为 Hudi 的元数据里维护了一张 Timeline,记录了每次写入的 commit 信息。增量查询本质上就是去 Timeline 里找「从上次查询到现在,哪些文件发生了变化」,然后只读这些文件。
避坑指南:我曾经遇到过一个坑——增量查询的起始时间戳,必须是 Hudi 的 commit 时间,不是你业务数据里的时间。如果你用业务时间戳去过滤,结果会不准确。因为 Hudi 的 commit 时间和业务时间可能差好几秒。
增量查询的典型场景:
- 实时 ETL 管道:每 5 分钟拉取一次增量数据
- CDC 同步:从 Hudi 表同步变更到下游系统
- 数据回填修正:只修复某段时间内写错的数据
4.3 Clustering:把小文件合并成大文件
写入多了,小文件问题就来了。你想想看,HDFS 上每个小文件都要占用一个 block 的元数据,NameNode 压力大,查询性能也差。Clustering 就是专门解决这个问题的。
它的核心逻辑很简单:把多个小文件组(File Group)合并成一个大文件组。但合并的时候有个讲究——不能破坏数据的一致性。
Hudi 的做法是:
- 先选出需要合并的小文件(比如小于 128MB 的)
- 把这些文件的数据读出来,重新排序、合并
- 写入新的大文件,同时更新元数据
- 旧的小文件标记为「已废弃」,等待清理
注意:Clustering 是异步执行的,不会阻塞正常的写入和查询。我习惯在业务低峰期(比如凌晨 2 点)触发 Clustering,避免影响在线服务。
配置 Clustering 的几个关键参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
| hoodie.clustering.inline | false | 是否在写入时同步执行 |
| hoodie.clustering.plan.strategy.target.file.max.bytes | 1073741824 (1GB) | 合并后的目标文件大小 |
| hoodie.clustering.plan.strategy.small.file.limit | 314572800 (300MB) | 小于此值的文件才参与合并 |
我的建议:目标文件大小设成 HDFS block size 的整数倍(比如 256MB 或 512MB),这样查询时能充分利用数据本地性。我踩过坑,设成 1GB 后,Map 任务数变少,但单个任务处理时间变长,反而得不偿失。
4.4 Compaction:MOR 表的救星
如果你用的是 MOR(读时合并)表,那 Compaction 就是你绕不开的话题。MOR 表把数据分成两部分:Base 文件(Parquet,列存,压缩率高)和 Log 文件(Avro,行存,写入快)。
Compaction 的作用,就是把 Log 文件里的增量数据,合并到 Base 文件里。说白了,就是把行存转成列存,提升查询性能。
我画了一张图,帮你理解 Compaction 的流程:
Compaction 有两种触发方式:
- 内联 Compaction:写入时同步执行,简单但影响写入性能
- 异步 Compaction:单独起一个 Spark 任务执行,我推荐这种方式
我曾经踩过的坑:有一次我把 Compaction 的触发间隔设得太长(24 小时),结果 Log 文件累积了上百个。查询时,Hudi 需要合并所有 Log 文件,一个简单的 count(*) 查询跑了 20 分钟。后来我把间隔改成 1 小时,或者 Log 文件总大小超过 500MB 就触发,问题就解决了。
Compaction 策略的选择也很重要:
| 策略 | 特点 | 适用场景 |
|---|---|---|
| NUM_COMMITS | 按 commit 次数触发 | 写入频率稳定时 |
| TIME_ELAPSED | 按时间间隔触发 | 写入频率波动大时 |
| NUM_IO_ERROR | 按 IO 错误次数触发 | 不太常用,我基本不用 |
我的配置建议:对于大多数场景,我习惯用 TIME_ELAPSED + NUM_COMMITS 组合。比如每 30 分钟或每 5 个 commit 触发一次 Compaction。这样既能保证 Log 文件不会太多,又不会频繁触发浪费资源。
4.5 小结一下
这一章我们聊了 Hudi 的写入流程、增量查询、Clustering 和 Compaction。说白了,写入流程决定了数据怎么进去,增量查询决定了怎么高效读变化,Clustering 和 Compaction 则是保证系统长期稳定运行的关键。
我个人觉得,理解这几个机制的核心,不在于记住每个参数,而在于明白它们为什么存在。比如 Compaction,它存在的根本原因是 MOR 表的设计取舍——用写入性能换查询性能。你理解了这一点,配置参数时就不会盲目。