☰
Flink CDC 实战:DataStream API 与 Flink SQL 选型指南
2026/10/22 4:16:15 网站建设 项目流程

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 APIFlink 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 变更这些基础问题摸清楚,再逐步扩展。我见过太多团队一上来就设计十几层的数仓架构,结果卡在全量同步阶段就推不动了。小步快跑,比一步到位靠谱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询