1. 为什么 CDC 场景下 Flink 的两套 API 值得单独拎出来讲
CDC 这个词这几年在数据圈的热度一直没降过。所谓 CDC,全称 Change Data Capture,翻译过来就是变更数据捕获。它的核心目标很朴素:数据库里发生了增删改,下游系统能近实时地感知到,而不是靠每天凌晨跑一遍全量同步。业务方要实时报表、要搜索索引同步、要缓存刷新、要数仓分层,几乎都绕不开这条链路。
Flink 在这个领域的位置很特殊。它不是最早的 CDC 工具,但它把“采集 + 计算 + 写出”揉进了一个引擎里。你可以在同一个作业里既接 MySQL 的 binlog,又做流式聚合,再写进下游存储,中间不用落地到消息队列再起另一个消费程序。这个特性让 Flink CDC 在近几年的实时数仓方案里出现频率极高。
但真正上手时,很多人会卡在一个岔路口:到底用 DataStream API 还是 Flink SQL。这两个入口都能接 CDC 源、都能做转换、都能写下游,可它们的适用边界、代码组织方式、调试手段、运维成本完全不一样。我见过不少团队一开始图省事全用 SQL,结果遇到复杂的状态管理或者自定义序列化就抓瞎;也见过有人什么场景都手写 DataStream,明明一个 SQL 就能搞定的宽表关联硬是写了几百行 ProcessFunction。
这篇内容就是想把这两条路彻底拆开讲清楚。从底层原理到代码骨架,从参数配置到踩坑记录,尽量让看完的人能自己判断:我手上这个需求,该走哪条路。适合已经了解 Flink 基本概念、准备在项目里落地 CDC 的同学,也适合正在做技术选型、想搞清楚两者差异的架构同学。全文会围绕 DataStream API 和 Flink SQL 两条主线展开,穿插实际配置和排查经验。
2. Flink CDC 的底层逻辑与两套 API 的定位差异
2.1 CDC 到底在捕获什么:从 binlog 到 Flink 内部数据结构
要理解两套 API 的差异,得先知道 CDC 源在 Flink 内部长什么样。以最常见的 MySQL 为例,CDC 连接器本质上是伪装成一个从库,向主库发送 dump 协议请求,主库把 binlog 推过来。连接器解析 binlog 事件,把 INSERT、UPDATE、DELETE 转换成 Flink 能识别的变更记录。
这里有个关键点:Flink CDC 连接器在内部把每条变更表达成一种带操作类型标记的记录。在 DataStream 层面,你拿到的是SourceRecord或者更上层的RowData,里面带着+I(insert)、-U(update before)、+U(update after)、-D(delete)这样的标记。在 SQL 层面,这些标记被 planner 消化掉,你看到的是一张会“变化”的表。
这个差异直接决定了两套 API 的心智模型。DataStream 是事件视角:你看到的是一个个变更事件,需要自己决定怎么处理 before/after。SQL 是表视角:你面对的是一张随时间演进的动态表,增删改被抽象成了表的更新,你写的是声明式的查询。
提示:很多人第一次用 DataStream 接 CDC 时会疑惑“为什么一条 UPDATE 会来两条记录”。这不是 bug,是 before 和 after 两条变更事件,处理时要注意去重或配对。
2.2 DataStream API 的定位:控制力优先,适合复杂状态逻辑
DataStream API 是 Flink 最底层的编程接口。用它的好处是你几乎能控制链路上的每一个环节:自定义反序列化、自定义水位线、自定义状态、自定义定时器、自定义旁路输出。CDC 场景里那些“不太规则”的需求,比如按主键做去重、按业务时间做延迟触发、把变更事件分流到不同下游,DataStream 都能比较自然地表达。
代价是代码量大、样板代码多。一个简单的 MySQL 到 MySQL 同步,用 DataStream 可能要写上百行,涉及 Source、Map、Sink 的拼装,还要自己处理 checkpoint 和并发度。而且 DataStream 的调试相对麻烦,出问题时你得从算子链一层层往下查。
2.3 Flink SQL 的定位:声明式优先,适合标准化流转
Flink SQL 走的是另一条路。你只需要CREATE TABLE定义源表和目标表,然后写一条INSERT INTO ... SELECT ...,剩下的交给 planner。连接器、水位线、状态、并发,大部分通过表选项配置。对于“读 A 表写 B 表”“多表 join 打宽”“简单聚合”这类需求,SQL 的开发效率是 DataStream 的好几倍。
但 SQL 的边界也很明显。一旦逻辑超出 SQL 表达范围,比如要调用外部服务做维表关联、要实现复杂的事件时间语义、要做非标准的去重,就会很别扭。虽然可以用 UDF、UDAF、UDTF 扩展,但复杂 UDF 的调试和性能调优又是另一门学问。
2.4 一张表看清两者的适用边界
| 维度 | DataStream API | Flink SQL |
|---|---|---|
| 开发效率 | 低,样板代码多 | 高,声明式 |
| 控制粒度 | 细,可控制每个算子 | 粗,依赖 planner |
| 状态管理 | 手动,灵活 | 自动,受限 |
| 调试难度 | 中,可断点可日志 | 高,依赖 explain |
| 复杂逻辑 | 强 | 弱,需 UDF 补 |
| 运维成本 | 高,需理解算子链 | 低,配置化 |
| 适合场景 | 复杂状态、自定义逻辑 | 标准流转、宽表聚合 |
这张表不是让你二选一,而是帮你判断起点。实际项目里两者经常混用:用 SQL 做主体流转,用 DataStream 处理特殊分支。
3. DataStream API 实现 CDC 的完整拆解
3.1 依赖引入与版本对齐的坑
DataStream 接 CDC,第一步是引依赖。这里最容易踩的坑是版本对齐。Flink 主版本、CDC 连接器版本、对应数据库驱动版本,三者必须匹配。比如 Flink 1.17 配 CDC 2.4,Flink 1.18 配 CDC 3.0,跨版本混用经常出现类找不到或者方法签名不匹配。
<dependency> <groupId>org.apache.flink</groupId> <artifactId>flink-connector-mysql-cdc</artifactId> <version>2.4.2</version> </dependency>注意:CDC 连接器从 3.0 开始做了较大的 API 重构,Source 的构建方式变了。如果你看的教程和你的版本对不上,先确认版本再照抄。
3.2 Source 构建:从 SourceFunction 到 Source 的演进
早期 CDC 用的是SourceFunction,后来 Flink 推出了新的Source接口,支持并行快照和增量切换。新版本推荐用MySqlSource构建:
MySqlSource<String> source = MySqlSource.<String>builder() .hostname("127.0.0.1") .port(3306) .databaseList("demo") .tableList("demo.orders") .username("reader") .password("******") .deserializer(new JsonDebeziumDeserializationSchema()) .startupOptions(StartupOptions.initial()) .build();这里几个参数值得说清楚。startupOptions决定启动位点:initial()是先全量再增量,latest()是只从当前位点开始,timestamp()是从指定时间开始。生产环境首次同步一般用initial(),后续重启如果还用initial()会重新全量,这点要特别注意,通常配合 savepoint 或者改成latest()。
deserializer决定输出格式。JsonDebeziumDeserializationSchema输出的是带 before/after 的 JSON,方便下游解析。如果追求性能,可以自定义反序列化直接输出 RowData,省掉 JSON 序列化开销。
3.3 变更事件的处理:去重、打宽与状态设计
拿到变更流之后,最常见的处理是按主键去重。因为一条 UPDATE 会来 before 和 after 两条,如果直接写下游,下游会看到中间态。去重的思路是用 KeyedProcessFunction,按主键 keyBy,用 ValueState 存最新记录,配合定时器延迟输出。
public class DedupFunction extends KeyedProcessFunction<String, RowData, RowData> { private ValueState<RowData> lastRecord; private ValueState<Long> timerState; @Override public void processElement(RowData value, Context ctx, Collector<RowData> out) { // 存最新记录,注册定时器,定时器触发时输出 } }状态设计这里有个经验:状态大小要可控。如果主键基数很大,ValueState 会撑爆内存。可以考虑用 RocksDB 状态后端,或者设置状态 TTL,让长时间不更新的 key 自动清理。
3.4 Sink 端的选择与幂等保证
DataStream 的 Sink 选择很多:JDBC Sink、Kafka Sink、Elasticsearch Sink、自定义 Sink。CDC 场景下最关心的是幂等。因为 Flink 是 at-least-once 或 exactly-once,重启时可能重复写。如果下游是数据库,用主键做 upsert 天然幂等;如果是 Kafka,需要下游消费端自己做去重。
JDBC Sink 要注意批量提交和事务。Flink 提供的JdbcSink支持批量,但默认不是事务性的。要保证 exactly-once,得用两阶段提交的 Sink,或者接受 at-least-once 配合下游幂等。
提示:我一般建议 CDC 同步链路默认按 at-least-once 设计,下游用主键 upsert 兜底。追求 exactly-once 的两阶段提交会显著增加延迟和复杂度,除非业务强要求,否则不划算。
4. Flink SQL 实现 CDC 的完整拆解
4.1 建表语句:连接器选项是核心
Flink SQL 接 CDC,核心就是CREATE TABLE里的WITH选项。以 MySQL 源表为例:
CREATE TABLE orders ( id BIGINT, user_id BIGINT, amount DECIMAL(10,2), status STRING, update_time TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = '127.0.0.1', 'port' = '3306', 'database-name' = 'demo', 'table-name' = 'orders', 'username' = 'reader', 'password' = '******', 'scan.startup.mode' = 'initial' );scan.startup.mode对应 DataStream 的startupOptions,取值有initial、latest-offset、timestamp、specific-offset。PRIMARY KEY ... NOT ENFORCED告诉 planner 主键信息,用于去重和 upsert,但 Flink 不会真的去校验,所以叫 NOT ENFORCED。
4.2 动态表到结果表的转换逻辑
SQL 里 CDC 源表是一张动态表。你写的SELECT是在这张不断变化的表上做查询。Flink 会根据查询类型决定是产生 retract 流还是 upsert 流。比如SELECT * FROM orders是简单的投影,产生的是 upsert 流;SELECT status, COUNT(*) FROM orders GROUP BY status是聚合,产生的是 retract 流,因为聚合结果会随新数据变化。
这个区别影响下游 Sink 的写法。如果下游是 Kafka,retract 流需要额外的标记字段;如果下游是 JDBC,upsert 流可以直接映射成 INSERT ... ON DUPLICATE KEY UPDATE。
4.3 多表 join 与维表关联的写法
CDC 场景里打宽很常见。比如订单表和用户表 join,补上用户信息。SQL 写法:
INSERT INTO wide_orders SELECT o.id, o.amount, u.name, u.city FROM orders o JOIN users u ON o.user_id = u.id;这里要注意状态大小。双流 join 会把两边数据都存进状态,如果两张表都很大,状态会爆炸。常见优化是给状态设 TTL,或者用 lookup join 代替 regular join。lookup join 是实时查维表,不缓存全量,适合维表大但变更不频繁的场景。
SELECT o.id, o.amount, u.name FROM orders o JOIN users FOR SYSTEM_TIME AS OF o.proc_time AS u ON o.user_id = u.id;4.4 结果写出:upsert 与 append 的选择
Sink 表的定义同样在WITH里配置。写 MySQL:
CREATE TABLE order_summary ( status STRING, cnt BIGINT, PRIMARY KEY (status) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://127.0.0.1:3306/demo', 'table-name' = 'order_summary', 'username' = 'writer', 'password' = '******' );如果查询产生的是 upsert 流,Sink 表必须有主键,连接器会生成 upsert 语句。如果产生的是 append 流,Sink 表不需要主键。选错会导致运行时报错,比如“Table sink doesn't support consuming update changes”。
5. 两套 API 的实操对比与选型决策
5.1 同一个需求的两套写法对照
拿“订单表同步到下游,过滤掉已取消订单”这个需求举例。
DataStream 写法:
DataStream<RowData> stream = env.fromSource(source, WatermarkStrategy.noWatermarks(), "mysql-cdc"); DataStream<RowData> filtered = stream.filter(row -> !"CANCELLED".equals(row.getField(3))); filtered.sinkTo(jdbcSink);SQL 写法:
INSERT INTO target_orders SELECT * FROM orders WHERE status <> 'CANCELLED';同样的逻辑,SQL 三行搞定,DataStream 要写算子链。但反过来,如果需求变成“按用户分组,5 秒内金额累计超过阈值就告警”,DataStream 用 KeyedProcessFunction 加定时器很自然,SQL 就得靠窗口加 UDF,反而绕。
5.2 性能与资源占用的实测差异
从资源角度看,SQL 作业经过 planner 优化,算子链合并得比较好,通常比手写 DataStream 更省资源。但 SQL 的优化是黑盒,遇到数据倾斜时不好干预。DataStream 可以手动加rebalance、rescale,或者自定义分区策略。
从延迟角度看,两者在简单链路上差异不大。复杂链路上 DataStream 因为可以精细控制,往往能压得更低。SQL 的 lookup join 每次都要查外部,延迟取决于外部存储的响应。
5.3 选型决策树:什么场景走哪条路
我一般用这几个问题来判断:
- 逻辑能不能用标准 SQL 表达?能,优先 SQL。
- 需不需要自定义状态、定时器、旁路输出?需要,走 DataStream。
- 团队里 SQL 能力强还是 Java 能力强?跟着团队走。
- 运维能不能接受黑盒优化?不能,走 DataStream。
- 需求会不会频繁变?会,SQL 改起来快。
实际项目里,我见过最舒服的方案是混合:主体链路用 SQL,特殊分支用 DataStream 单独起一个作业,两边通过消息队列衔接。这样既享受 SQL 的开发效率,又保留 DataStream 的灵活性。
6. 常见问题与排查技巧实录
6.1 全量阶段卡住或 OOM 的排查
全量同步阶段最容易出问题。常见现象是作业卡在某个百分比不动,或者 TaskManager OOM。原因通常是单表数据量太大,快照阶段一次性拉太多。
排查思路:先看 TaskManager 日志有没有 GC 频繁,再看 checkpoint 是否超时。解决办法是调小scan.snapshot.fetch.size,或者用chunk模式分片读取。另外,全量阶段的并发度受表数量限制,单表只能一个并发,大表要考虑分片。
提示:MySQL CDC 支持按主键范围分片,配置
scan.incremental.snapshot.chunk.size可以控制每个分片大小。分片太小会增加连接开销,太大又容易 OOM,一般设 8096 到 10240 行之间比较稳。
6.2 增量阶段延迟高的定位方法
增量阶段延迟高,先看 binlog 位点消费速度。如果位点落后很多,可能是下游写入慢导致反压。用 Flink UI 看反压指标,定位到具体算子。如果是 Sink 慢,考虑加批量、加并发;如果是 join 慢,考虑状态大小和 TTL。
还有一种情况是水位线推进慢。如果作业里有事件时间窗口,但某些分区没数据,水位线不推进,窗口不触发。这时候要配置withIdleness,让空闲分区不阻塞水位线。
6.3 主键变更与 schema 演进的应对
CDC 同步最怕源表结构变。加字段、改类型、改主键,都可能让作业挂掉。Flink CDC 对加字段支持较好,新字段会以 null 填充。但改类型和改主键支持有限,通常需要重启作业并调整表定义。
应对策略是提前约定 schema 变更流程。源表变更前先通知同步链路,必要时停作业、改定义、从 savepoint 恢复。不要指望 CDC 连接器能自动处理所有 DDL。
6.4 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 解决手段 |
|---|---|---|---|
| 全量卡住 | 大表快照慢 | 看日志和 GC | 调小 fetch size,分片 |
| OOM | 状态过大 | 看状态后端 | 换 RocksDB,设 TTL |
| 延迟高 | 反压 | 看 UI 反压图 | 加并发,优化 Sink |
| 窗口不触发 | 水位线不推进 | 看 watermark | 配 withIdleness |
| 作业重启后重复 | at-least-once | 看 checkpoint | 下游幂等 |
| schema 变更失败 | DDL 不兼容 | 看异常栈 | 停作业改定义 |
6.5 我踩过的几个真实坑
第一个坑是时区。MySQL 的 timestamp 和 Flink 的 TIMESTAMP 类型时区处理不一致,导致同步过去的时间差 8 小时。解决办法是在连接器里配server-time-zone,或者在 SQL 里显式转换。
第二个坑是大小写敏感。MySQL 在 Linux 下默认表名大小写敏感,Windows 下不敏感。同步到下游时如果表名大小写不一致,会找不到表。建议统一用小写。
第三个坑是连接数。CDC 连接器会为每个表建连接,表多了会打满数据库连接数。解决办法是复用连接,或者限制并发表数量。
7. 从单表同步到实时数仓的扩展思路
单表同步只是起点。真正体现 Flink CDC 价值的是把它放进实时数仓的链路里。典型架构是 ODS 层用 CDC 接各业务库,DWD 层做清洗和打宽,DWS 层做轻度聚合,ADS 层直接服务报表。
在这个架构里,DataStream 和 SQL 的分工可以这样安排:ODS 到 DWD 的清洗用 SQL,因为规则相对标准;DWD 到 DWS 的复杂聚合如果涉及自定义逻辑,用 DataStream;ADS 层如果只是简单查询,继续用 SQL。
还有一个扩展方向是多源合并。比如多个分库的订单表要合并成一张逻辑表,可以用 SQL 的 UNION ALL,也可以用 DataStream 的 union。分库场景下要注意主键冲突,通常加库标识做联合主键。
最后分享一个我在实际项目里的体会:不要一开始就追求大而全的实时数仓。先用 Flink CDC 把一两条核心链路跑通,把全量、增量、重启、schema 变更这些基础问题摸清楚,再逐步扩展。我见过太多团队一上来就设计十几层的数仓架构,结果卡在全量同步阶段就推不动了。小步快跑,比一步到位靠谱得多。