Apache Hudi 学习笔记 12:Flink Hudi 流读、CDC 入湖与全量接增量


前面两篇已经把 Flink SQL 写 Hudi 和核心参数调优讲了一遍。本文继续往实时入湖方向走,重点看三个问题:Flink 怎么流式读取 Hudi 表?CDC 数据怎么从 Kafka 写入 Hudi?离线全量数据和实时增量数据怎么衔接?

这三个问题其实是一条链路上的不同阶段。CDC 入湖负责把变化写进去,Hudi Streaming Query 负责把新的 commit 持续读出来,全量接增量负责解决“实时任务启动前,表里已经有历史数据”的识别问题。

1. 读取和 CDC 链路

1.1 背景问题

实时入湖不是简单地把数据写到 HDFS。以 MySQL 订单表为例,真实链路里至少有这些问题:

  1. MySQL binlog 里既有 insert,也有 update 和 delete。
  2. 同一个 Hudi record key / 业务主键可能短时间内多次变化。
  3. Kafka 中的 CDC 消息需要能回放。
  4. Flink 任务失败后要从 checkpoint 恢复。
  5. Hudi 表需要根据 record key 做 upsert,而不是简单追加。
  6. 下游任务希望持续消费 Hudi 新 commit。
  7. 全量导入以后,实时任务不能把已有 record key 当成新记录重复插入。

我把这类链路拆成四层:

层次 作用
CDC Source 从 MySQL binlog 抽取变化
Kafka 保存可回放的 CDC 事件日志
Flink SQL 解析 CDC、维护状态、写入 Hudi
Hudi 提供 upsert、Timeline、增量读取

本文实操部分用 Debezium JSON 消息模拟 MySQL binlog 已经进入 Kafka 的状态。这样可以把重点放在 Flink Hudi 的读写语义上,不把篇幅消耗在 MySQL CDC connector 的安装细节上。

Flink 读 Hudi 常见有三类:

读取方式 说明 适合场景
Snapshot Query 读取当前最新快照 批量校验、临时查询、维表回填
Streaming Query 持续监听新 commit 增量消费、实时下游 pipeline
Time Travel 读取指定时间点附近快照 数据回溯、问题排查

Flink Streaming Read 的核心参数是:

'read.streaming.enabled' = 'true',
'read.start-commit' = 'earliest',
'read.streaming.check-interval' = '4'

它的工作方式可以理解成下面这样:

Flink Hudi 流式读取流程

这张图主要说明 Flink Hudi streaming source 会周期性轮询 Hudi Timeline,从指定 start commit 附近开始持续读取新的 completed instant。

read.start-commit 可以指定从哪个 instant 开始读。常见取值:

参数值 含义
earliest 从最早可用 commit 开始消费
具体 instant 从指定 instant 附近开始消费;是否包含该 instant 需要以 Hudi 1.0.2 Flink connector 实测语义为准
不设置 通常从最新位置开始监听后续 commit

常见读取参数可以先记住这几个:

参数 说明
read.streaming.enabled 是否开启流式读取
read.streaming.check-interval 流读轮询 Timeline 的间隔,单位秒,默认 60
read.start-commit 增量读取起始 instant,支持 earliest
read.end-commit 增量读取结束 instant
read.streaming.skip_compaction 流读时是否跳过 compaction instant,Hudi 1.0.2 默认开启
read.streaming.skip_clustering 流读时是否跳过 clustering instant,Hudi 1.0.2 默认开启

流读要特别注意清理策略。如果下游流读任务落后太多,而 Hudi clean 已经删除了它需要的历史 FileSlice,就可能读不到旧 commit 对应的数据。所以流读链路里,clean.retain_commits 不能只按写入端考虑,还要给下游消费延迟留余量。

read.streaming.skip_compaction=true 可以减少流读重复扫描 compaction 产物的开销,但它不是万能开关。MOR 表如果下游落后超过 clean.retain_commits 能保留的范围,旧的 FileSlice 已经被清理,流读仍然可能丢失需要补读的数据。我的经验是:只要下游依赖 Hudi 流读做准实时 pipeline,就要把 clean 保留策略和下游最大允许落后时间一起设计。

后面 CDC 入湖会生成一张表:hdfs:///hudi/demo_flink_cdc_orders。这一节是基于第 6、7 节已经写入完成后的验证结果,阅读时可以先看完 CDC 写入部分,再回来看流读 SQL:

cat > /tmp/hudi_flink_stream_read.sql <<'SQL'
SET 'execution.runtime-mode' = 'streaming';
SET 'sql-client.execution.result-mode' = 'TABLEAU';

CREATE TABLE hudi_orders_stream_read (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/demo_flink_cdc_orders',
  'table.type' = 'COPY_ON_WRITE',
  'hoodie.metadata.enable' = 'false',
  'read.tasks' = '1',
  'read.streaming.enabled' = 'true',
  'read.start-commit' = 'earliest',
  'read.streaming.check-interval' = '4'
);

SELECT order_id, user_id, status, dt
FROM hudi_orders_stream_read
WHERE order_id IN (12001, 12002, 12003)
LIMIT 3;
SQL

执行:

unset HADOOP_CLASSPATH
export HADOOP_CONF_DIR=/opt/module/hadoop-3.4.1/etc/hadoop

/opt/module/flink-1.20.1/bin/sql-client.sh \
  -f /tmp/hudi_flink_stream_read.sql

输出如下:

+----+----------------------+--------------------------------+--------------------------------+--------------------------------+
| op |             order_id |                        user_id |                         status |                             dt |
+----+----------------------+--------------------------------+--------------------------------+--------------------------------+
| +I |                12002 |                           u202 |                           PAID |                     2025-09-28 |
| +I |                12001 |                           u201 |                        CREATED |                     2025-09-28 |
| +I |                12003 |                           u203 |                        CREATED |                     2025-09-29 |
+----+----------------------+--------------------------------+--------------------------------+--------------------------------+
3 rows in set

这个结果说明,在 read.start-commit='earliest' 的配置下,Flink Hudi streaming source 可以从已有 commit 开始读取历史数据;如果不设置 start commit,通常更像从当前最新位置监听后续 commit。因为这里加了 LIMIT 3,SQL Client 输出 3 条后结束;如果不加限制,它会持续等待后续 Hudi commit。

如果要观察持续监听,可以去掉 LIMIT

SELECT order_id, user_id, status, dt
FROM hudi_orders_stream_read;

如果只想做批式增量读取,可以同时指定 read.start-commitread.end-commit。在本文 Hudi 1.0.2 Flink connector 实测中,指定 start 和 end commit 的 batch incremental read 会读取该范围内的变更数据;起止边界是否包含对应 instant,建议以当前版本文档和实测为准。如果要避免重复消费上一个 checkpoint,建议把 start commit 设置为上次已处理 completed instant 之后的下一个起点,或按当前版本文档确认边界语义。

CREATE TABLE hudi_orders_incremental_read (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/demo_flink_cdc_orders',
  'table.type' = 'COPY_ON_WRITE',
  'hoodie.metadata.enable' = 'false',
  'read.start-commit' = '20250929203945218',
  'read.end-commit' = '20250929203949912'
);

SELECT order_id, status, dt
FROM hudi_orders_incremental_read
ORDER BY order_id;

在 Flink Hudi 中,可以通过限定 read.end-commit 来读取某个 instant 范围内的数据;如果要严格表达 Time Travel 快照语义,需要按 Hudi 1.0.2 Flink 查询文档和实测确认:

CREATE TABLE hudi_orders_time_travel (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/demo_flink_cdc_orders',
  'table.type' = 'COPY_ON_WRITE',
  'hoodie.metadata.enable' = 'false',
  'read.end-commit' = '20250929203949912'
);

1.4 CDC 入湖整体链路

CDC 入湖可以有两种常见做法:

  1. Flink CDC connector 直接读取 MySQL binlog,然后写 Hudi。
  2. Debezium / Canal / Flink CDC 先把 binlog 写入 Kafka,再由 Flink SQL 消费 Kafka 写 Hudi。

我更喜欢第二种,尤其是生产环境。原因是 Kafka 能保存 CDC 原始事件,方便回放、补偿、排查,也能把数据库采集和湖表写入解耦。

本文实测链路如下:

CDC 入湖链路

这张图主要说明 MySQL binlog 先进入 Kafka,再由 Flink SQL 解析 Debezium JSON changelog,最终通过 Hudi upsert 写入湖表。

如果要把 MySQL binlog 先写入 Kafka,Flink SQL 的形态一般是 mysql-cdc 源表加 upsert-kafka 目标表。下面是结构示例,<mysql-host> 替换为真实 MySQL 地址,账号和口令在博客里统一使用测试值:

CREATE TABLE mysql_orders_cdc (
  order_id BIGINT,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING,
  PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
  'connector' = 'mysql-cdc',
  'hostname' = '<mysql-host>',
  'port' = '3306',
  'username' = 'test_user',
  'password' = 'test_password',
  'database-name' = 'test',
  'table-name' = 'orders'
);

CREATE TABLE kafka_orders_upsert (
  order_id BIGINT,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING,
  PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
  'connector' = 'upsert-kafka',
  'topic' = 'hudi_flink_cdc_orders_upsert',
  'properties.bootstrap.servers' = 'localhost:9092',
  'key.format' = 'json',
  'value.format' = 'json'
);

INSERT INTO kafka_orders_upsert
SELECT order_id, user_id, amount, status, ts, dt
FROM mysql_orders_cdc;

本文账号和口令都只用于本地单机示例;生产环境应使用最小权限账号、强密码,并通过安全配置管理,不要把真实密码写入博客或仓库。

这条链路适合把数据库采集和入湖写入拆开。需要注意,upsert-kafka 表输出的是按 key 更新后的 changelog,不是 Debezium 原始 JSON 的 before / after / op 结构。本文后面的实测从 Kafka Debezium JSON 开始,假设 Kafka 中已经是 Debezium JSON 格式,是为了把重点放在 Hudi 写入和读取语义上。

需要补一个 Flink Kafka SQL connector:

cd /opt/module/flink-1.20.1/lib

curl -k -L \
  -o flink-sql-connector-kafka-3.4.0-1.20.jar \
  https://repo.maven.apache.org/maven2/org/apache/flink/flink-sql-connector-kafka/3.4.0-1.20/flink-sql-connector-kafka-3.4.0-1.20.jar

验证 jar 中有 Kafka SQL connector:

jar tf flink-sql-connector-kafka-3.4.0-1.20.jar \
  | grep KafkaDynamicTableFactory

输出:

org/apache/flink/streaming/connectors/kafka/table/KafkaDynamicTableFactory.class

修改 Flink lib 后重启 Flink:

/opt/module/flink-1.20.1/bin/stop-cluster.sh
/opt/module/flink-1.20.1/bin/start-cluster.sh

如果测试机系统时间早于证书生效时间,远端 Maven 证书校验可能失败。正常环境应恢复正确系统时间、开启 NTP,并使用 curl -Lwget 保留证书校验;curl -k 只适合隔离测试环境临时排错。本文使用的 flink-sql-connector-kafka-3.4.0-1.20.jar 与 Flink 1.20 配套,实际版本仍以对应版本的官方 connector 兼容说明为准。

2. CDC 写入验证

2.1 准备 Kafka CDC Topic

创建 Topic:

/opt/module/kafka/bin/kafka-topics.sh \
  --bootstrap-server localhost:9092 \
  --create \
  --if-not-exists \
  --topic hudi_flink_cdc_orders \
  --partitions 1 \
  --replication-factor 1

如果 Topic 不存在,输出类似:

Created topic hudi_flink_cdc_orders.

准备 Debezium JSON 消息。这里的 dt 是模拟业务分区日期,用于演示多分区 CDC 入湖,不代表文章实际写作日期;ts 是模拟业务事件时间,用作 Hudi precombine.field 排序字段:

cat > /tmp/hudi_flink_cdc_orders.jsonl <<'JSONL'
{"before":null,"after":{"order_id":12001,"user_id":"u201","amount":39.90,"status":"CREATED","ts":1759063600000,"dt":"2025-09-28"},"op":"c","ts_ms":1759063601000}
{"before":null,"after":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"CREATED","ts":1759063660000,"dt":"2025-09-28"},"op":"c","ts_ms":1759063661000}
{"before":null,"after":{"order_id":12003,"user_id":"u203","amount":168.00,"status":"CREATED","ts":1759063720000,"dt":"2025-09-29"},"op":"c","ts_ms":1759063721000}
{"before":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"CREATED","ts":1759063660000,"dt":"2025-09-28"},"after":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"PAID","ts":1759063900000,"dt":"2025-09-28"},"op":"u","ts_ms":1759063901000}
JSONL

写入 Kafka:

/opt/module/kafka/bin/kafka-console-producer.sh \
  --bootstrap-server localhost:9092 \
  --topic hudi_flink_cdc_orders \
  < /tmp/hudi_flink_cdc_orders.jsonl

消费验证:

/opt/module/kafka/bin/kafka-console-consumer.sh \
  --bootstrap-server localhost:9092 \
  --topic hudi_flink_cdc_orders \
  --from-beginning \
  --max-messages 4 \
  --timeout-ms 10000

输出:

{"before":null,"after":{"order_id":12001,"user_id":"u201","amount":39.90,"status":"CREATED","ts":1759063600000,"dt":"2025-09-28"},"op":"c","ts_ms":1759063601000}
{"before":null,"after":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"CREATED","ts":1759063660000,"dt":"2025-09-28"},"op":"c","ts_ms":1759063661000}
{"before":null,"after":{"order_id":12003,"user_id":"u203","amount":168.00,"status":"CREATED","ts":1759063720000,"dt":"2025-09-29"},"op":"c","ts_ms":1759063721000}
{"before":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"CREATED","ts":1759063660000,"dt":"2025-09-28"},"after":{"order_id":12002,"user_id":"u202","amount":88.00,"status":"PAID","ts":1759063900000,"dt":"2025-09-28"},"op":"u","ts_ms":1759063901000}
Processed a total of 4 messages

本文实测只演示 Debezium insert / update 进入 Hudi;delete 事件需要单独验证,尤其要确认 Flink debezium-json 如何把 delete changelog 转给 Hudi sink。

准备 SQL:

cat > /tmp/hudi_flink_cdc_kafka_to_hudi.sql <<'SQL'
SET 'execution.runtime-mode' = 'streaming';
SET 'sql-client.execution.result-mode' = 'TABLEAU';
SET 'execution.checkpointing.interval' = '5s';
SET 'execution.checkpointing.dir' = 'hdfs:///flink-checkpoints/hudi_flink_cdc_orders';
SET 'state.backend.type' = 'rocksdb';
SET 'parallelism.default' = '1';

DROP TABLE IF EXISTS kafka_orders_cdc;
DROP TABLE IF EXISTS hudi_orders_cdc;

CREATE TABLE kafka_orders_cdc (
  order_id BIGINT,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING,
  PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
  'connector' = 'kafka',
  'topic' = 'hudi_flink_cdc_orders',
  'properties.bootstrap.servers' = 'localhost:9092',
  'properties.group.id' = 'hudi-flink-cdc-orders-g20250929',
  'scan.startup.mode' = 'earliest-offset',
  'format' = 'debezium-json'
);

CREATE TABLE hudi_orders_cdc (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/demo_flink_cdc_orders',
  'table.type' = 'COPY_ON_WRITE',
  'write.operation' = 'upsert',
  'precombine.field' = 'ts',
  'hoodie.metadata.enable' = 'false',
  'write.tasks' = '1',
  'write.bucket_assign.tasks' = '1',
  'read.tasks' = '1',
  'write.rate.limit' = '1000'
);

INSERT INTO hudi_orders_cdc
SELECT order_id, user_id, amount, status, ts, dt
FROM kafka_orders_cdc;
SQL

这里有几个关键点:

  1. Kafka 表使用 format='debezium-json'
  2. Hudi 表使用 write.operation='upsert'
  3. precombine.field='ts' 用于同一 record key 多版本合并。
  4. write.rate.limit='1000' 用于限制写入速率。
  5. 单机环境只有 1 个 slot,所以并行度都设为 1。

kafka_orders_cdc 的 primary key 主要用于表达 changelog key 语义;不同 Flink/Kafka connector 配置下,可能还需要 key.formatkey.fields 或 upsert-kafka 方案。如果复用旧 group.id,可能不会重新消费已有 Kafka 消息;测试时建议使用新的 Topic 或新的 group.id。本文 Flink 1.20.1 环境中 state.backend.type=rocksdb 实测可用,如果当前发行包要求 embedded-rocksdb 或其他配置项,应按 Flink 1.20 文档调整。

write.rate.limit 在 Hudi 1.0.2 Flink connector 中用于限制每秒写入记录数,本文用 1000 只是单机测试值。当前 CDC 实测是从空 Hudi 表开始写入,所以没有开启 index.bootstrap.enabled;如果目标表已经有历史全量数据,应参考第 11 节开启 index bootstrap 或使用已有 checkpoint / savepoint state。

执行:

unset HADOOP_CLASSPATH
export HADOOP_CONF_DIR=/opt/module/hadoop-3.4.1/etc/hadoop

hdfs dfs -rm -r -f \
  /hudi/demo_flink_cdc_orders \
  /flink-checkpoints/hudi_flink_cdc_orders

/opt/module/flink-1.20.1/bin/sql-client.sh \
  -f /tmp/hudi_flink_cdc_kafka_to_hudi.sql

这里 unset HADOOP_CLASSPATH 是为了避免把本机 Hadoop 依赖再次通过环境变量注入 Flink,造成和 Flink lib 中已有依赖重复。本文依赖主要放在 Flink lib 中,再通过 HADOOP_CONF_DIR 提供 Hadoop 配置。

手工删除 HDFS 表路径和 checkpoint 目录只是为了保证单机测试可重复执行;生产环境不要直接 hdfs dfs -rm Hudi 表目录或 checkpoint 目录。仅删除 Hudi 表和 checkpoint 不会删除 Kafka 中已有消息,如果重复执行第 5 节 producer,Topic 中会累积重复消息;为了保证结果可复现,测试时建议使用全新的 Kafka Topic,或删除并重建 Topic。

输出中可以看到作业提交成功:

Flink SQL> [INFO] Submitting SQL update statement to the cluster...
[INFO] SQL update statement has been successfully submitted to the cluster:
Job ID: 83faa7059126b57f0a3c6ab48781a5ef

查看 Flink job:

/opt/module/flink-1.20.1/bin/flink list

输出:

------------------ Running/Restarting Jobs -------------------
29.09.2025 20:39:40 : 83faa7059126b57f0a3c6ab48781a5ef : insert-into_default_catalog.default_database.hudi_orders_cdc (RUNNING)
--------------------------------------------------------------
No scheduled jobs.

因为这是持续流任务,验证完 Hudi commit 后可以取消。取消任务前应先确认 Flink checkpoint 成功,并且 Hudi Timeline 中已经出现 completed commit;否则可能还没有稳定提交到 Hudi 表:

/opt/module/flink-1.20.1/bin/flink cancel 83faa7059126b57f0a3c6ab48781a5ef

2.3 验证 Hudi commit 和文件

查看 Timeline:

hdfs dfs -ls /hudi/demo_flink_cdc_orders/.hoodie/timeline

完整目录里有 18 个条目,下面只截取两次 commit 相关文件:

Found 18 items
-rw-r--r--   1 root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203945218.commit.requested
-rw-r--r--   1 root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203945218.inflight
-rw-r--r--   1 root supergroup       4344 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203945218_20250929203949839.commit
-rw-r--r--   1 root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203949912.commit.requested
-rw-r--r--   1 root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203949912.inflight
-rw-r--r--   1 root supergroup       3933 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/.hoodie/timeline/20250929203949912_20250929203952260.commit

在 Hudi 1.0.x 中,完成态 Instant 文件名里可以看到 begin instant 和 completion instant,例如 begin_completion.commit;这里前半段更接近请求 / 开始时间,后半段是完成时间。requested / inflight 用于表达 Instant 状态流转;判断已经成功提交的动作时,主要看完成态 commit 文件。

查看 checkpoint 元信息和数据文件:

hdfs dfs -ls -R /hudi/demo_flink_cdc_orders \
  | grep -E 'ckp_meta|2025-09-2[90]|parquet'

部分输出:

drwxr-xr-x   - root supergroup          0 2025-09-29 20:40 /hudi/demo_flink_cdc_orders/.hoodie/.aux/ckp_meta
-rw-r--r--   1 root supergroup          0 2025-09-29 20:40 /hudi/demo_flink_cdc_orders/.hoodie/.aux/ckp_meta/20250929204001894.COMPLETED
-rw-r--r--   1 root supergroup          0 2025-09-29 20:40 /hudi/demo_flink_cdc_orders/.hoodie/.aux/ckp_meta/20250929204001894.INFLIGHT
drwxr-xr-x   - root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/2025-09-28
-rw-r--r--   1 root supergroup     435862 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/2025-09-28/e7d73c08-2a7f-4be9-aa3f-ac4352592d4d_0-1-0_20250929203945218.parquet
drwxr-xr-x   - root supergroup          0 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/2025-09-29
-rw-r--r--   1 root supergroup     435725 2025-09-29 20:39 /hudi/demo_flink_cdc_orders/2025-09-29/9afc043a-6dc2-4197-8fdd-5170ad042120_0-1-0_20250929203945218.parquet

如果要确认第二次 commit 对应的数据文件,可以单独过滤第二个 instant:

hdfs dfs -ls -R /hudi/demo_flink_cdc_orders \
  | grep 20250929203949912

上面是 grep 截取的部分输出,只展示了第一批 base files;完整排查时应结合第二个 commit 对应的新文件、Timeline 和查询结果一起看。

这里能看到:

  1. 流式任务按 checkpoint 产生多个 commit。
  2. .hoodie/.aux/ckp_meta 中是 Flink Hudi sink 写入过程中使用的 checkpoint / instant 辅助元信息。
  3. 数据按 dt 分区写入。
  4. Hudi 表已经形成可查询的 COW 快照。

.hoodie/.aux/ckp_meta 和 Flink 的 checkpoint 存储目录、Kafka consumer group offset、HoodieStreamer checkpoint 不是同一个概念,不要混淆。数据文件名前半段是 fileId,中间的 0-1-0 类似写入任务相关 token,最后的 20250929203945218 是写入 instant,不要把整个文件名都理解成 fileId。

2.4 查询 CDC 入湖结果

准备查询 SQL。查询脚本使用 batch runtime,只读取已经完成的 Hudi commit,不需要配置 checkpoint。这里不是重新写表,而是在 Flink SQL 中用同样 schema 注册已有 Hudi 路径用于查询;字段、PRIMARY KEY NOT ENFORCEDPARTITIONED BYtable.type 应和已有表配置保持一致。

cat > /tmp/hudi_flink_cdc_query.sql <<'SQL'
SET 'execution.runtime-mode' = 'batch';
SET 'sql-client.execution.result-mode' = 'TABLEAU';

CREATE TABLE hudi_orders_cdc_query (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/demo_flink_cdc_orders',
  'table.type' = 'COPY_ON_WRITE',
  'hoodie.metadata.enable' = 'false',
  'read.tasks' = '1'
);

SELECT dt, COUNT(*) AS cnt, MIN(order_id) AS min_order_id, MAX(order_id) AS max_order_id
FROM hudi_orders_cdc_query
GROUP BY dt
ORDER BY dt;

SELECT order_id, user_id, amount, status, dt
FROM hudi_orders_cdc_query
ORDER BY order_id;
SQL

执行:

/opt/module/flink-1.20.1/bin/sql-client.sh \
  -f /tmp/hudi_flink_cdc_query.sql

分区统计:

+------------+-----+--------------+--------------+
|         dt | cnt | min_order_id | max_order_id |
+------------+-----+--------------+--------------+
| 2025-09-28 |   2 |        12001 |        12002 |
| 2025-09-29 |   1 |        12003 |        12003 |
+------------+-----+--------------+--------------+
2 rows in set (6.85 seconds)

明细结果:

+----------+---------+--------+---------+------------+
| order_id | user_id | amount |  status |         dt |
+----------+---------+--------+---------+------------+
|    12001 |    u201 |  39.90 | CREATED | 2025-09-28 |
|    12002 |    u202 |  88.00 |    PAID | 2025-09-28 |
|    12003 |    u203 | 168.00 | CREATED | 2025-09-29 |
+----------+---------+--------+---------+------------+
3 rows in set (1.17 seconds)

order_id=12002CREATED 更新成了 PAID,说明 Kafka Debezium JSON 的 update 事件被 Flink SQL 解析成 changelog 后,最终通过 Hudi record key 和 precombine.field=ts 合并成了最新状态。

3. 生产链路能力

3.1 限流:write.rate.limit

CDC 入湖最怕全量快照和实时增量混在一起时出现写入尖峰。比如 Debezium 先扫历史数据,再接 binlog;或者某个任务恢复后大量回放 Kafka offset。这个时候写入端可能瞬间把 HDFS、checkpoint 和 Hudi writer 都打满。

Flink Hudi 写入限流

这张图主要说明 write.rate.limit 用来削平 Kafka 堆积追赶和全量快照阶段的写入尖峰,避免 checkpoint、writer buffer 和 HDFS 文件写入同时被打满。

Hudi Flink 提供了 write.rate.limit。本文按 Hudi 1.0.2 Flink connector 语义理解为每秒写入记录数限制;具体单位、默认值和是否对所有 source / sink 场景生效,应以当前版本文档和实测为准:

WITH (
  'write.rate.limit' = '1000'
)

我对这个参数的理解是:它不是为了提升峰值吞吐,而是为了让写入更平滑。尤其在全量导入、补偿回放、Kafka 堆积追赶这几类场景里,限流可以让 checkpoint 更稳定。

如果没有限流,且 checkpoint 间隔较短、分区较多、write.tasks 较高或 small file 策略不合理,可能出现:

  1. checkpoint 一直超时。
  2. writer buffer 快速膨胀。
  3. HDFS 小文件突然增加。
  4. Kafka 消费吞吐忽高忽低。
  5. TaskManager GC 变多。

3.2 离线批量导入

CDC 入湖之前通常需要先做全量导入。全量导入不要直接用和实时任务一样的 upsert 链路硬灌,初始化阶段更适合 bulk_insert

示例:

SET 'execution.runtime-mode' = 'batch';
SET 'execution.checkpointing.interval' = '0';

CREATE TABLE hudi_orders_full (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/orders_full',
  'table.type' = 'COPY_ON_WRITE',
  'write.operation' = 'bulk_insert',
  'precombine.field' = 'ts',
  'write.tasks' = '4',
  'write.bulk_insert.shuffle_input' = 'true',
  'write.bulk_insert.sort_input' = 'true',
  'write.bulk_insert.sort_input.by_record_key' = 'false'
);

本文 Flink 1.20.1 环境中 execution.checkpointing.interval=0 用于关闭 checkpoint;如果你的版本不接受 0,可以直接删除 checkpoint interval 设置,或者按 Flink 文档关闭 checkpoint。这个示例只展示 bulk_insert 操作形态,文件大小调优沿用上一篇参数调优篇,后续应结合 hoodie.parquet.max.file.sizehoodie.parquet.small.file.limitwrite.taskswrite.bulk_insert.sort_inputwrite.bulk_insert.shuffle_input 控制初始化文件布局。

全量导入的核心目标是:

  1. 快速写入历史数据。
  2. 尽量生成合理大小的 Parquet 文件。
  3. 避免一开始就制造大量小文件。
  4. 为后续实时 upsert 留下清晰的 FileGroup 布局。

这里有两个细节值得单独记:

  1. bulk_insert 更适合在 batch runtime mode 下执行,checkpoint 可以关闭,避免初始化任务被流式 checkpoint 开销拖慢。
  2. write.tasks 会直接影响初始化阶段的输出文件和 FileGroup 数量。当单个文件达到目标大小后还会 roll over,所以最终文件数通常大于等于 write.tasks,但不要把这里的文件 / FileGroup 数量和 Bucket Index 的 bucket 概念混在一起。

如果一上来就用小批量 upsert 导历史数据,后面很容易出现写入慢和小文件问题。

3.3 全量接增量与 index bootstrap

全量接增量的核心问题是:实时任务启动时,Hudi 表里已经有历史数据,Flink state 里还没有这些 key 到 FileGroup 的映射。此时如果直接写入增量,可能无法正确识别历史记录位置。

在 Flink Hudi upsert 场景中,index.bootstrap.enabled 用于把已有 Hudi 表中的 record key 位置信息加载进 Flink state。它是 Flink state index 场景下用于已有 Hudi 表接续增量写入的机制,不要和 Spark 侧索引初始化能力混为一谈:

全量接增量流程

这张图主要说明全量 bulk_insert 完成后,实时 upsert 任务需要通过 index bootstrap 或已有 state 识别历史 record key 的文件位置。

示例配置:

CREATE TABLE hudi_orders_incremental (
  order_id BIGINT PRIMARY KEY NOT ENFORCED,
  user_id STRING,
  amount DECIMAL(10,2),
  status STRING,
  ts BIGINT,
  dt STRING
) PARTITIONED BY (dt)
WITH (
  'connector' = 'hudi',
  'path' = 'hdfs:///hudi/orders_full',
  'table.type' = 'COPY_ON_WRITE',
  'write.operation' = 'upsert',
  'precombine.field' = 'ts',
  'index.bootstrap.enabled' = 'true',
  'index.partition.regex' = '2025-09-.*',
  'write.index_bootstrap.tasks' = '4'
);

这里的 2025-09-.* 是示例分区范围,实际生产要按全量表历史分区范围设置,避免漏加载需要接续的历史分区。

使用 index bootstrap 时要注意:

  1. 它会把已有表的 key 映射加载到 Flink state。
  2. 索引加载是阻塞过程,加载期间 checkpoint 可能无法立即成功,需要给失败容忍留余量。
  3. 可以用 index.partition.regex 限定加载分区,避免一次扫全表。
  4. 首次 bootstrap 完成并确认 checkpoint / savepoint 已经成功保存后,后续从该状态恢复时通常可以关闭它;如果丢失 state 或重新从空状态启动,就需要重新评估是否再次 bootstrap。

官方文档也特别强调:index bootstrap 是阻塞过程,通常要等第一个 checkpoint 成功后,才能认为历史索引加载完成。

实际操作时我会把流程拆得更明确一些:

SET 'execution.checkpointing.interval' = '10s';
SET 'execution.checkpointing.tolerable-failed-checkpoints' = '20';

然后按下面的顺序做:

  1. 第一次启动任务时开启 index.bootstrap.enabled=true
  2. 等待日志里出现类似 finish loading the index under partitionLoad records from file 的加载进度。
  3. 等第一次 checkpoint 成功,说明历史索引已经进入 Flink state。
  4. 停止任务并保留 savepoint,或者依赖 externalized checkpoint。
  5. 第二次启动时关闭 index.bootstrap.enabled,把 write.taskswrite.bucket_assign.taskswrite.index_bootstrap.tasks 调回生产值。

还有一个容易忽略的点:某些场景下,索引加载进度可能和数据流、分区访问范围、index.partition.regex 以及 checkpoint 推进有关。全量接增量时最好设计一个明确的启动窗口,通过日志和 checkpoint 确认需要接续的分区都已经被覆盖。

4. 常见问题与链路判断

4.1 常见问题

4.1.1 Kafka CDC 表为什么要用 debezium-json

Debezium JSON 中有 beforeafterop 等字段,Flink 可以把它解析成 changelog。如果使用普通 kafka connector + json,确实会丢失 Debezium 的 changelog 语义,Hudi 只能看到普通 append 数据;如果使用 upsert-kafka,则是另一种按 key 表达更新的方式,但它和 Debezium before / after / op 原始 JSON 不是同一套格式。

4.1.2 为什么 Hudi 表产生了很多 commit

Flink Hudi sink 按 checkpoint 推进 commit。本文 checkpoint 间隔是 5 秒,且 checkpoint 能持续成功,所以任务运行几十秒后会出现多个 Hudi commit。这是正常现象。生产中要结合延迟、小文件数量和 checkpoint 成功率设置 checkpoint 间隔。

4.1.3 流读任务落后会有什么风险

如果流读任务从很早的 commit 开始消费,但 Hudi clean 已经清掉旧 FileSlice,就可能读不到历史增量。解决方向是:

'read.start-commit' = '20250929203945218'

并在写入表配置中合理保留 commit:

clean.retain_commits=30

clean.retain_commits 是 Hudi Flink connector 的短名配置,表示保留多少个 commit。清理策略还需要结合 archive、savepoint、下游最大延迟和增量消费 checkpoint 设计。

4.1.4 CDC 入湖能不能直接从 MySQL 读

可以。Flink CDC connector 能直接读取 MySQL binlog。但生产里我更倾向于 MySQL -> Kafka -> Flink -> Hudi,因为 Kafka 提供了回放、削峰、解耦和排查能力。

4.1.5 全量接增量为什么会重复

常见原因是实时写入任务不知道历史 key 已经存在,或者 partition path 发生变化但没有使用合适的索引策略。全量接增量时一定要考虑 index bootstrap、Hudi record key / 业务主键字段、分区字段和 checkpoint 起点。

4.2 CDC 入湖链路判断

CDC 入湖最重要的不是“写进去”,而是保证顺序、去重、回放和恢复。

Kafka 里的 CDC 消息只是原始变化日志;Flink 负责把它变成有状态的 changelog;Hudi 负责把 changelog 落成可查询、可增量消费的湖表。任何一层处理不好,最后都会表现成重复数据、旧值覆盖新值、checkpoint 卡住、下游读不到增量。

我更推荐的入湖顺序是:

  1. 离线 bulk_insert 完成历史数据初始化。
  2. 实时任务开启 index.bootstrap.enabled 识别已有数据。
  3. 任务第一个 checkpoint 成功后,最好通过 savepoint 或 externalized checkpoint 保留可恢复状态;生产切换配置时优先使用 savepoint 做受控恢复。
  4. 后续实时 upsert 从 Flink checkpoint / savepoint 中保存的 Kafka offset 和 Hudi writer / index state 继续推进。
  5. 下游用 Hudi Streaming Read 或 Incremental Query 消费新 commit。

不要把 Kafka consumer group offset、Flink checkpoint 和 Hudi Timeline commit 混为一谈。

这条链路比直接把 binlog 写 Parquet 复杂,但它换来的是可更新、可回放、可增量消费的数据湖表。

5. 总结

本文主要完成了 Flink Hudi 流读、CDC 入湖和全量接增量的梳理:

  1. Flink Hudi Streaming Read 通过 read.streaming.enabled=true 持续读取新 commit。
  2. read.start-commit='earliest' 可以从最早可用 commit 开始消费;如果 cleaner 已经清理了旧 FileSlice,落后太久的流读任务仍可能无法完整补读。
  3. Kafka Debezium JSON 可以通过 Flink SQL 写入 Hudi,并保留 update 语义。
  4. write.rate.limit 适合全量快照、补偿回放和 Kafka 堆积追赶场景。
  5. 全量导入优先考虑 bulk_insert,实时增量使用 upsert
  6. 全量接增量的关键是 index.bootstrap.enabled、Hudi record key / partition path 设计,以及 Flink checkpoint / savepoint 状态衔接。

本文以 Hudi 1.0.2 与 Flink 1.20.1 实测环境为准,latest 文档可能已经包含后续版本变化。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录