ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

FlinkCDC 实时同步:MySQL Binlog、增量快照与一致性实践

FlinkCDC 实时同步:MySQL Binlog、增量快照与一致性实践

数据库里的数据每分每秒都在变,可下游的数仓宽表还停在昨天夜里跑批的那个快照上。业务方早上九点看报表,发现十点钟下的单查不到,跑来问是不是系统坏了——这种场景我见过太多次。问题的根子不在报表,而在于数据从业务库到分析库的那段路,原本是每天一次的重卡运输,现在需要改成一条不停歇的传送带。FlinkCDC 就是这条传送带最常被选中的那套零件:它蹲在 MySQL、PostgreSQL、Oracle 这些库的日志边上,把每一次 insert、update、delete 原封不动地捞出来,变成 Flink 里的一条流,再往下游任何地方送。

先把概念说清楚。CDC 是 Change Data Capture 的缩写,直译叫变更数据捕获,本质上就是"盯着数据库的账本抄流水"。FlinkCDC 不是一个独立软件,它是 Apache Flink 生态里的一组 Source/Sink 连接器,把 Debezium 这类日志解析引擎的能力包装成了 Flink 原生的数据源,让变更数据能直接进入 Flink 的算子链路,享受到分布式并行、状态管理、Checkpoint 容错这些能力。能做什么?实时同步整库、给缓存做失效、给搜索索引做增量更新、把多个业务库汇到一个 ODS 层、做异构数据库之间的不停机迁移。适合谁看?正在被 T+1 批处理折磨的数据开发、要做实时链路的后端工程师,以及刚接触 Flink 想找个真实场景落地的同学。下面这些内容,是我在几个项目里反复趟过之后的整理,原理部分尽量讲透,实操部分给到能直接抄的程度。

1. 先想清楚为什么要用日志,而不是定时去查表

在动手写第一行配置之前,有个问题必须先回答:为什么不用定时任务去扫表?这个问题的答案决定了你后面会不会在半夜被叫起来修数据。

1.1 批处理抽取的四个硬伤

最常见的做法是起一个定时任务,每隔十分钟或一小时,用WHERE update_time > 上次时间把增量数据捞出来写下游。这个方案上手快、门槛低,但它在四个地方会咬人。

第一是删除捕获不到。物理删除之后那行数据在源表里消失了,你用任何时间戳条件都查不出"它曾经存在过、现在没了"。下游只能永远保留一条幽灵记录,日积月累,宽表和明细对不上账。

第二是中间态丢失。一条订单在十分钟内从"待支付"改成"已支付"再改成"已发货",定时任务只看到最后一次的状态。如果下游需要计算"支付到发货的平均时长",这个信息就彻底没了。日志方案能拿到每一次变更的前后镜像,中间态一个不落。

第三是扫表压力。为了捞增量,你需要在update_time上建索引,但业务表往往已经有五六个索引了,再加一个会拖慢写入。更麻烦的是那些没有update_time字段、或者字段由业务代码手动维护(经常忘更新)的表,根本没法用这个方案。

第四是延迟和批量耦合。调度间隔决定了延迟下限,想做到秒级就得把间隔压到几秒,那扫表频率就上去了;压不下去,实时性就没了。这个矛盾在批处理框架里基本无解。

1.2 日志订阅好在哪儿

数据库自己写日志是为了崩溃恢复和主从复制,这份日志天然记录了每一行数据的完整变更前后值(前提是 ROW 格式),而且写入顺序就是事务提交顺序。订阅这份日志有几个先天优势:

  • 无侵入:不需要业务表加字段,不需要改任何一行业务代码,数据库层面只多一个复制账号。
  • 完整:insert、update、delete 全都能拿到,update 还能拿到变更前和变更后两份镜像。
  • 低延迟:日志是实时产生的,解析出来就是毫秒到秒级的延迟。
  • 顺序性:同一个主键的变更严格按提交顺序到达,不会乱序。

代价是什么?一是运维复杂度上来了,你要保证日志不被过早清理、复制账号权限配好、网络链路稳定;二是历史数据得单独处理,日志里只有"从现在开始"的变更,存量数据还得靠一次全量快照。FlinkCDC 的价值,恰恰在于它把"全量快照"和"增量日志"这两件事缝在了一起,做成了一个对用户透明的 Source。

1.3 哪些场景值得上这套东西

不是所有同步都值得引入 FlinkCDC。我的判断标准是三条:延迟要求是否在分钟级以内、是否需要捕获删除和中间态、源表是否频繁变更且量大。三条里占两条以上,就值得考虑。

典型场景包括:实时数仓的 ODS 层同步(这是最大的一类)、缓存与数据库的一致性维护、搜索引擎索引的准实时更新、多业务库汇聚做全局用户画像、以及数据库迁移时的双写过渡期数据校验。反过来,如果只是每天凌晨同步一次全量维度表,或者数据量小到几万行,老老实实用批任务更省心,别为了技术而技术。

2. 一条变更数据在 FlinkCDC 内部走过的完整链路

这一节讲原理。很多人用 FlinkCDC 用了很久,只知道配几个参数就能跑,一旦出问题就抓瞎,根子在于不清楚数据在内部到底经过了哪几道手。把这层搞清楚,后面排查问题会轻松一大截。

2.1 第一道关:数据库必须开启行级日志

MySQL 的 binlog 有三种格式:STATEMENT、ROW、MIXED。STATEMENT 记录的是 SQL 语句本身,比如UPDATE t SET status=2 WHERE id=100;ROW 记录的是每一行数据变更前后的具体值;MIXED 是两者的混合,由数据库自己判断。

CDC 必须用 ROW。原因很直接:STATEMENT 格式下你只能看到一条 SQL,拿不到"改之前是什么样",也拿不到这条 SQL 实际影响了哪些行,下游无法做幂等的 upsert。而且 STATEMENT 里的函数调用(比如NOW())在备库重放时结果可能不同,本身就不适合做数据同步。

配套的还有一个参数binlog_row_image,取值 FULL、MINIMAL、NOBLOB。必须设成 FULL,意思是变更前后两行数据的全部列都要记录。如果设成 MINIMAL,update 的 before 镜像里只有主键,FlinkCDC 拿不到完整的旧值,宽表更新时会出现字段被空值覆盖的问题。这个坑我踩过一次,表现是下游某一列莫名其妙变成 NULL,查了半天才发现是源库的这个参数被人改过。

2.2 第二道关:伪装成一个从库

Debezium 拿到变更的方式,是把自己注册成 MySQL 的一个从库(slave/replica)。它向主库发送COM_BINLOG_DUMP指令,主库就把它当成一个普通的复制客户端,持续推送 binlog 事件。

这里的关键参数是server-id。MySQL 的主从复制拓扑里,每个实例都必须有唯一 ID,如果两个客户端用了同一个 ID,后连上的会把先连上的踢掉,表现为同步任务随机中断、报错提示 server id 冲突。所以 FlinkCDC 的server-id参数支持配置一个区间,比如5400-5404,表示从 5400 到 5404 这五个 ID 可用。区间的容量必须大于等于任务并行度,否则启动时直接报错。在一个 Flink 集群上跑多个 CDC 任务时,每个任务的区间要错开,这个必须提前规划,不然任务一多就互相打架。

2.3 第三道关:增量快照算法如何做到不锁表

这是 FlinkCDC 2.x 之后最核心的改进,值得单独讲。老版本的 Debezium 做全量快照时会在表上加全局读锁(FLUSH TABLES WITH READ LOCK),锁住期间业务写入全部阻塞。一张几千万行的表,全量跑二十分钟,业务就卡二十分钟,这在生产环境是不可接受的。

FlinkCDC 2.x 引入了**增量快照(Incremental Snapshot)**算法,思路是把大表切成小块,逐块读取,读的时候不加锁。具体分几步走:

第一步,记录起点。连接器先读取当前 binlog 的最新位点,记为整个任务的起始 offset,同时短暂加表级元数据锁读取表结构(这个锁的持有时间在毫秒级,可以忽略)。

第二步,切分数据块。根据表的主键(没有主键则用唯一键),把全表数据按主键区间切成若干个 chunk。切分的粒度由scan.incremental.snapshot.chunk.size控制,默认 8096 行一个块。比如主键范围是 1 到 100000,chunk size 设为 8000,就会切出十三个块。

第三步,并行读取每个块。每个块记录两个水位:low watermark(块内主键最小值)和 high watermark(块内主键最大值)。读取的时候执行SELECT * FROM t WHERE pk >= low AND pk <= high,这个查询走主键索引,速度很快。关键点在于,每个块读取前会记录当下的 binlog 位点,这个位点跟块绑定在一起。

第四步,全量转增量。所有块读完之后,连接器从最早的块位点开始消费 binlog。对每一条 binlog 记录,它做一次判断:这条记录的主键落在哪个块里?那个块读完了没有?如果块已经读完,且 binlog 位点在该块的 high watermark 之后,直接向下游发送;如果位点落在块读取的区间内,说明这条变更可能已经包含在快照里了,需要按主键和位点做去重。

这个设计的精妙之处在于:全量阶段和增量阶段是无缝衔接的,中间没有断点,也不需要锁表。而且全量阶段可以多并行度跑(不同 chunk 分给不同 subtask),大幅缩短大表的初始化时间。代价是状态会变大——每个 chunk 的水位信息都要存到 Flink 的 state 里,checkpoint 体积会上升,这个后面讲调优时再展开。

2.4 第四道关:并行度的跷跷板

这里有个很多人第一次配置时会困惑的点:为什么全量阶段能并行,增量阶段却不能?

因为 binlog 是一条全局有序的单流。MySQL 的 binlog 里,不同表的变更、同一张表不同行的变更,全都混在一条序列里。FlinkCDC 的增量阶段只有一个 subtask 负责读 binlog(或者说,读到的 binlog 需要按主键分发),如果无脑提高并行度,会出现同一主键的变更被分到不同 subtask 上、顺序错乱的问题。

所以 FlinkCDC 2.x 的实际行为是:全量阶段按 chunk 分配到多个 subtask 并行读,增量阶段会在 source 内部做一次分发,按主键 hash 把变更路由到下游对应的 subtask。你在 Flink UI 上看到的 source 并行度是 N,但其中只有一部分真正在消费 binlog。这个特性决定了增量阶段的吞吐上限受单点 binlog 解析能力限制,如果单库的变更量特别大(比如每秒几万条),靠加并行度是解决不了的,得考虑拆分任务或者升级到新版本的 Pipeline 模式。

2.5 第五道关:Checkpoint 才是真正的"进度条"

FlinkCDC 把 binlog 位点存在 Flink 的 state 里,随 Checkpoint 一起持久化。这意味着Checkpoint 必须开启,而且间隔不能太长。

如果没开 Checkpoint,位点只存在内存里,任务一重启,连接器找不到上次读到哪儿,只能从头再来一遍全量。我见过一个任务因为 Checkpoint 配置写错(忘了配状态后端)导致每次重启都全量重跑八千万行,运维同学差点崩溃。

开了 Checkpoint 之后,每次成功做一次快照,位点就被持久化。任务失败重启时,从最近一次成功的 Checkpoint 恢复,接着往下消费,不会重复也不会丢(下游需要配合幂等写入,这点后面细说)。所以 Checkpoint 间隔的设置是一个权衡:太短会增加集群压力,太长则故障时的重放数据量变大。一般生产环境设 30 秒到 3 分钟之间,具体看集群承受能力和可接受的重放量。

3. 从零把一个同步任务跑起来

原理讲完,进入动手环节。这一节给的是能直接复制粘贴的配置,以及每一步为什么这么配。

3.1 版本对齐:最容易翻车的第一步

FlinkCDC 的版本和 Flink 版本有严格的对应关系,装错了启动就报NoSuchMethodError。下面是我实际验证过的组合:

Flink 版本FlinkCDC 版本连接器包名
1.13.x2.2.xflink-sql-connector-mysql-cdc-2.2.x.jar
1.14.x2.3.x / 2.4.xflink-sql-connector-mysql-cdc-2.4.x.jar
1.15.x / 1.16.x2.4.x同上
1.17.x / 1.18.x3.0.xflink-cdc-3.0.x-bin.tar.gz

表里最后一行的 3.0 是个大变化:它不再走 Flink SQL 的 connector 体系,而是提供了一套独立的 Pipeline 框架,用 YAML 描述 source 到 sink 的整条链路,运行时自己起一个轻量集群。这个模式在整库同步场景下体验好很多,后面单独说。

选包的时候还有个细节:优先用带sql前缀的 shade 包(flink-sql-connector-mysql-cdc),它把 Debezium、MySQL 驱动这些依赖都打进去了,不用手动解决依赖冲突。不带前缀的那个包需要你自己保证 classpath 里有debezium-connector-mysql,版本对不上就是一堆ClassNotFoundException。

3.2 源库侧的四项准备

动手之前,源库必须满足四个条件,缺一个都跑不起来。

日志格式。执行SHOW VARIABLES LIKE 'binlog_format',必须是 ROW;SHOW VARIABLES LIKE 'binlog_row_image',必须是 FULL。改这两个参数需要重启 MySQL(8.0 里部分可以动态改,但保险起见按重启规划)。

日志保留时间。执行SHOW VARIABLES LIKE 'expire_logs_days'(8.0 用binlog_expire_logs_seconds)。这个值决定了 binlog 保留多久。它必须大于"全量快照耗时 + 增量消费延迟"的总和。假设一张大表全量要跑两小时,增量追平还要半小时,那日志至少要保留三小时以上,建议直接设 7 天。这个参数配小了会导致一个特别隐蔽的故障:全量阶段跑完了,回头去消费 binlog,发现最早的位点已经被清理,直接报Could not find first log file name in binary log index file,一切重来。

复制账号权限。CDC 账号需要这几项:SELECT(读数据)、RELOAD(刷新元数据)、SHOW DATABASES(列库)、REPLICATION SLAVE和REPLICATION CLIENT(订阅日志)。少一项就会在启动时报权限错误。

CREATE USER 'cdc_user'@'%' IDENTIFIED BY 'your_password'; GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'cdc_user'@'%'; FLUSH PRIVILEGES;

GTID。如果源库开了 GTID(gtid_mode=ON),FlinkCDC 支持用 GTID 位点,容灾切换时更好处理。不开也能用,走 binlog file + position 的方式。

3.3 一段能跑通的最小 SQL

把 jar 包丢进 Flink 的lib目录,重启集群,然后进 SQL Client 执行下面的语句:

-- 源表定义 CREATE TABLE orders_source ( id BIGINT, order_no STRING, user_id BIGINT, amount DECIMAL(12,2), status INT, create_time TIMESTAMP(3), update_time TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = '10.0.0.11', 'port' = '3306', 'username' = 'cdc_user', 'password' = 'your_password', 'database-name' = 'app_db', 'table-name' = 'orders', 'server-id' = '5400-5404', 'server-time-zone' = 'Asia/Shanghai', 'scan.incremental.snapshot.chunk.size' = '8096', 'scan.startup.mode' = 'initial' ); -- 下游结果表,这里用 print 演示,实际换成 JDBC/ClickHouse/Doris CREATE TABLE orders_sink ( id BIGINT, order_no STRING, user_id BIGINT, amount DECIMAL(12,2), status INT, create_time TIMESTAMP(3), update_time TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'print' ); INSERT INTO orders_sink SELECT * FROM orders_source;

提交之后,你会看到先有一批历史数据打出来(全量阶段),然后新插入或修改一条数据,几秒内就会输出对应的变更(增量阶段)。这个"先全量后增量"的连贯体验,就是 FlinkCDC 最直观的价值。

几个参数的取值理由:server-id给了五个 ID 的区间,够并行度 4 使用(并行度不能超过区间容量);server-time-zone设了东八区,不设的话时间字段会按 UTC 解析,下游看到的时间差八小时,这个坑非常常见;scan.startup.mode设为initial表示先全量再增量,如果只想从当前位点开始,改成latest-offset。

3.4 启动报错时先看这几条

跑不起来的情况,八成是下面四种之一:

报错关键字根因处理办法
The MySQL server has a timezone offset没配server-time-zone加上该参数,值与源库一致
server id conflict多个任务 server-id 区间重叠规划互不重叠的 ID 段
Access denied; you need the REPLICATION SLAVE privilege账号权限不全按上文补授权
Table xxx has no primary key表无主键加主键,或临时用全表扫描模式

最后一条要单独说:FlinkCDC 2.4 之前对无主键表的支持很有限,如果表实在没法加主键(比如一些日志表),可以尝试用唯一索引替代,或者用scan.incremental.snapshot.enabled = false退回老的全量模式(会锁表,慎用)。生产环境里我的态度很明确:上 CDC 的表必须有主键,没主键的表先治理,别硬上。

4. 全量切增量那几分钟,才是真正的事故高发区

任务跑起来了不代表跑稳了。真正容易出问题的不是长期运行阶段,而是全量快照切到增量消费的那段时间,以及运行中遇到的各种边界情况。

4.1 大表全量阶段的耗时估算

先给个经验值:单块 8096 行,走主键范围的顺序扫描,在普通的 SSD 机器上大约是每秒读几万行。一张一千万行的表,串行跑大概五分钟,四并行能压到一分半。但这个数字波动很大,取决于行宽、索引情况、源库负载。

估算的意义在于反推 binlog 保留时间。假设你有二十张大表,总计两亿行,全量阶段跑下来要半小时,那 binlog 保留时间至少得设一小时以上,留足冗余。上线前一定要做一次耗时压测,用生产数据的副本跑一遍全量,把真实耗时摸清楚,再回去调整expire_logs_days。这一步省不得。

另外有个容易被忽略的点:全量阶段会占用源库的读资源。如果源库本身负载就高,全量扫描可能把 IO 打满,影响业务。缓解办法是控制并行度和 chunk size,把压力摊薄;也可以在低峰期做初始化。

4.2 更新主键会带来一条幽灵记录

这是 CDC 同步里最经典的数据陷阱,必须提前跟下游对齐。

假设源表有一条id=100的记录,业务执行了UPDATE orders SET id=200 WHERE id=100。FlinkCDC 收到的是两条事件:一条 delete(旧值 id=100),一条 insert(新值 id=200)。如果下游是 upsert 写入,会先删掉 id=100 再插入 id=200,一切正常。

但如果下游写入方式不支持删除(比如某些只追加的存储、或者你用INSERT INTO而没配主键),那条 id=100 的旧记录就会永远留在下游,成为幽灵。更麻烦的是有些业务代码用"更新主键"来实现某种分表逻辑,这种操作在 CDC 链路上往往是重灾区。

我的处理方式是:建表规范里明确禁止更新主键,这条规则在数据同步场景下是硬性的。如果业务确实有这种需求,就在下游做额外处理,比如定期跑一次对账任务清理孤儿记录。

4.3 数据类型映射的六个坑

Flink SQL 和 MySQL 的类型不是一一对应的,映射时会有信息损失或语义偏移。下面几个是我实际遇到过的:

TINYINT(1)会被映射成 BOOLEAN。MySQL 里TINYINT(1)常被用来存 0/1 布尔值,但也有很多系统用它存状态码(0、1、2、3)。FlinkCDC 默认把TINYINT(1)当布尔处理,状态码 2 和 3 会被截断成 true,数据直接错了。解决办法是在 Flink 表定义里显式声明为TINYINT,或者源库改用TINYINT(2)及以上。

无符号整型溢出。BIGINT UNSIGNED的最大值超过了 Javalong的范围,超过 9223372036854775807 的值会被当成负数。如果表里有雪花 ID 且用无符号存,务必检查。通常建议 Flink 侧用DECIMAL(20,0)承接。

DATETIME 精度丢失。MySQL 的DATETIME(3)带毫秒,如果 Flink 侧声明成TIMESTAMP(0),毫秒会被截断。声明时要写TIMESTAMP(3)或TIMESTAMP(6)。

时区。TIMESTAMP类型在 MySQL 里是按会话时区转换的,DATETIME不带时区。混用会导致下游时间偏移。统一做法是源库全用DATETIME,Flink 侧配好server-time-zone,下游按本地时区处理。

DECIMAL 精度。Flink 的 DECIMAL 默认是DECIMAL(10,0),金额字段必须显式声明精度,否则小数部分被抹掉。这个错误很隐蔽,因为不报错,只是数值不对。

BLOB / TEXT。大字段同步会显著拖慢速度,如果下游用不上,建议在下游表定义里排除,或者用 Flink SQL 的字段投影裁掉。全量阶段每个 chunk 都要把这些大字段读出来,白白消耗带宽。

4.4 源表 DDL 变更怎么办

这是 FlinkCDC 2.x 的一个明显短板:SQL 模式下 Schema 变更不会自动同步到 Flink 的表定义。源表加了一列,Flink 侧的表结构还是老的,新列的数据会被静默丢弃,不报错。等业务发现下游缺字段时,往往已经过了好几天。

处理方式分两种。如果是新增列且下游需要,只能改 Flink SQL 的表定义然后重启任务,重启时如果从 Checkpoint 恢复,位点还在,不会重跑全量。如果只是加了一个下游不要的列,可以暂时忽略。

3.0 版本的 Pipeline 模式引入了 Schema Evolution,支持部分 DDL 的自动同步(比如加列),但也不是全部 DDL 都支持,改列类型这类操作依然要人工介入。所以建立一套 DDL 变更的通知机制是必要的——源库的 DDL 上线流程里加一步"通知数据同步方",比任何自动化解法都靠谱。

4.5 大事务会把延迟拉爆

一个事务里更新了五十万行,这在批量订正、数据修复的场景下很常见。Debezium 解析这种大事务时,需要把整个事务的所有变更事件在内存里缓冲,等事务提交后才一并下发。缓冲期间,下游看到的延迟会突然飙到几分钟甚至更久。

缓解办法有两个:一是源库侧控制事务大小,大批量更新拆成小批次提交(这个建议本身对数据库也更友好);二是调大 Debezium 的缓冲参数,debezium.max.queue.size和debezium.max.batch.size,代价是内存占用上升。还有一些场景可以开启debezium.event.processing.failure.handling.mode来做容错,但这属于兜底手段,不解决根本问题。

5. 并发、状态与反压:让任务长期跑稳的参数逻辑

配置能跑通只是及格线,能在生产环境连续跑几个月不出事,靠的是对几个关键参数的理解决策。

5.1 并行度到底设多少

前面说过,全量阶段可并行、增量阶段受单流限制。所以并行度的设置要分情况看。

如果这张表的存量数据很大(千万级以上),增量变更量不大,那把并行度设高一点(4 到 8)能显著缩短初始化时间。如果存量不大但增量特别猛(比如订单表每秒几千条变更),并行度设再高也帮不上忙,瓶颈在 binlog 解析,这时候要考虑的是拆分任务或者换用更高吞吐的方案。

有个细节:并行度不能超过 server-id 区间的容量。配了server-id = '5400-5404',并行度最多 5。这个约束在改并行度时特别容易忘,改完重启就报错。

另外一个经验:多个 CDC 任务跑在同一个 Flink 集群上时,尽量把它们的执行资源隔开(用单独的 Slot Group 或者干脆分集群)。一个任务做全量时会把 IO 和网络打满,殃及其他任务。

5.2 Chunk size 的取舍

scan.incremental.snapshot.chunk.size默认 8096,这个值决定了全量阶段的切分粒度。调小(比如 2000)会让块更多、并行更均匀,但每块的查询开销(连接建立、结果集初始化)占比上升;调大(比如 50000)能减少查询次数,但单块耗时长,并行度不够时整体反而更慢,而且块太大意味着状态里要记的水位信息更粗,去重判断的窗口也更大。

我的经验值:表在一千万行以内用默认 8096,超过一亿行用 20000 到 50000,配合 8 以上并行度。同时在初始化完成后,可以观察 Flink UI 里各个 subtask 的处理速率是否均衡,如果明显有几块拖后腿,说明切分不够均匀,可以调小 chunk size 重来。

5.3 Checkpoint 配置的三个关键值

execution.checkpointing.interval: 60s execution.checkpointing.timeout: 10min execution.checkpointing.unaligned: true

间隔 60 秒是个折中。CDC 任务的状态主要是位点和 chunk 水位,体积不大,60 秒一次的额外开销可以接受,故障时最多重放 60 秒的数据。

超时 10 分钟。CDC 任务在写下游慢的时候,checkpoint 可能被反压拖住。超时设太短会导致 checkpoint 频繁失败,任务陷入"一直失败一直重试"的循环。设长一点,给反压留出恢复空间。

非对齐 Checkpoint(unaligned)。开了之后,checkpoint 不再等待所有在途数据被处理完,而是把在途数据也快照下来。在反压场景下,这个开关能大幅提升 checkpoint 成功率。代价是状态体积会变大一些。在有明显反压的 CDC 任务上,我一般都会打开它。

还有一点:状态后端建议用 RocksDB,把状态放到本地磁盘而不是堆内存。CDC 任务的 chunk 水位信息在表特别多的时候可能达到几百 MB,全放堆里容易 OOM。

5.4 反压的定位路径

任务跑着跑着延迟越来越高,Flink UI 上看到 source 的currentFetchEventTimeLag持续上涨,这就是典型的反压。排查顺序应该是从下游往上游倒推。

先看 Sink。写 JDBC 是不是单条 insert 逐条提交?改成批量写入(batch size 500 到 1000),吞吐能提升一个数量级。写 ClickHouse/Doris 是不是没走批量导入接口?这些库对单条写入的支持很差,必须走攒批。这部分优化往往能解决 80% 的反压问题。

再看中间算子。有没有做窗口聚合、双流 join 这种重操作?有的话检查状态是否在无限增长,考虑加 TTL。

最后看 Source。如果下游都优化完了还是跟不上,那就是源库的变更量确实超过单任务处理能力了,考虑拆分或者升级架构。

有个工具值得推荐:Flink 的currentFetchEventTimeLag和currentEmitEventTimeLag两个指标,前者表示"数据产生到被 source 读取的延迟",后者表示"读取到下发到下游的延迟"。前者高说明源头堆积(消费不过来),后者高说明下游卡住。用这两个指标能快速定位瓶颈在哪一段。

6. 上线之后,怎么确认下游的数是对的

同步任务跑起来、指标看着正常,不代表数据就是对的。数据一致性这块必须有一套独立的验证手段,别指望"没报错就是对的"。

6.1 幂等写入是底线

先明确一个事实:FlinkCDC 配合 Checkpoint 能保证**至少一次(at-least-once)**语义,故障恢复时会有少量数据重放。要做到精确一次,需要下游配合。

下游是数据库的话,用 upsert 语义写入(JDBC connector 配INSERT INTO ... ON DUPLICATE KEY UPDATE,或者 Flink 表定义里声明主键让 connector 自动处理)。这样重放的数据会覆盖而不是追加,天然幂等。

下游是消息队列的话,消费端要做去重,通常用业务主键加版本号或者消费位点做判断。

下游是 ClickHouse 这类不支持更新的库,用 ReplacingMergeTree 引擎,靠主键去重(注意它的去重是最终一致的,查询时要用FINAL或者接受短暂重复)。

如果下游既不支持 upsert 又不去重,那这个链路的一致性就是没保障的,早晚出事。

6.2 三层对账机制

我一般会建三层校验,从粗到细:

第一层,行数对比。每天凌晨跑一次SELECT COUNT(*)对比源表和目标表。这个最粗糙,但能发现大规模的数据丢失。缺点是行数相同不代表内容相同。

第二层,关键字段聚合对比。选几个有代表性的数值字段做 SUM/AVG,加上按时间分组的趋势对比。金额类的字段尤其要做这个,能发现精度丢失、符号错误这类问题。

第三层,抽样明细对比。随机抽几千条主键,逐字段比对源和目标的完整记录。这个能发现字段错位、类型截断这类细节问题。

三层都跑一遍的成本不低,实际落地时我是这么安排的:行数和聚合对比每天跑,抽样对比每周跑一次,全量对比只在重大变更后做一次。另外,全量初始化刚结束的那一次,三层都必须跑,因为全量阶段是数据问题最集中的地方。

6.3 必须盯住的四个监控指标

除了 Flink 自带的任务指标,CDC 链路还有几个专用指标需要单独配告警:

指标含义告警阈值建议
currentFetchEventTimeLag数据产生到被读取的延迟持续 5 分钟 > 60 秒
numRecordsInPerSecondsource 每秒处理记录数骤降 50% 以上(可能是链路断了)
lastCheckpointDuration最近一次 checkpoint 耗时接近超时阈值
numRestarts任务重启次数10 分钟内 > 3 次

其中currentFetchEventTimeLag是最重要的一个。它不只看业务延迟,还能反映 binlog 消费是否停滞。如果这个值持续上涨且不回落,通常是下游卡住或者源库那边出了问题(比如有人做了大事务、主库切换导致位点失效)。

还有一类监控容易被忽略:源库的 binlog 生成速率。如果突然飙升到平时的十倍,说明有异常的大批量操作,这时候要提前预警,别等下游撑不住了才发现。

6.4 主备切换后的位点处理

MySQL 主从切换是运维常态,但对 CDC 任务是生死考验。切换后,新主库的 binlog file 名可能跟老库不一样(尤其是不开 GTID 的环境),连接器按老的位点去找会失败。

开 GTID 是首选方案。GTID 是全局唯一的事务 ID,不依赖文件名,主备切换后连接器能自动按 GTID 定位,恢复过程对用户透明。这也是我建议生产库一律开 GTID 的原因之一(另一个原因是主从切换本身更方便)。

没开 GTID 的环境,切换后大概率要人工介入,从最近的 checkpoint 恢复并手动指定位点。这种情况下最好提前把切换流程和 CDC 任务的恢复步骤写进运维手册,别等到半夜切换时再临时摸索。

7. 版本 3.0 的 Pipeline 模式值不值得迁

最后聊一下 3.0,因为这两年问的人很多。3.0 最大的变化是引入了 Pipeline 框架,整库同步变成一个 YAML 文件的事,不再需要为每张表写一段 Flink SQL。

7.1 一个 YAML 搞定整库同步

source: type: mysql hostname: 10.0.0.11 port: 3306 username: cdc_user password: your_password tables: app_db\.orders,app_db\.users server-id: 5400-5404 server-time-zone: Asia/Shanghai sink: type: jdbc url: jdbc:mysql://10.0.0.12:3306/dw username: dw_user password: your_password pipeline: name: mysql-to-dw-sync parallelism: 4

提交命令是bin/flink-cdc.sh job.yaml,它会自动起一个 Flink 作业。相比 SQL 模式,好处很明显:表结构自动映射、新增表可以自动捕获(不用改代码)、Schema Evolution 支持加列同步。

但我不会无脑推荐所有人升级。3.0 的生态成熟度、社区踩坑资料都比 2.4 少一截,遇到问题能查到的答案不多。如果你的场景是"整库同步到数仓"且团队对 Flink SQL 不熟,3.0 值得上;如果是"少量关键表的精细化同步",2.4 的稳定性和可控性更好。升级前把关键任务的 2.4 配置和 3.0 的 YAML 都跑一遍,用同样的数据量对比延迟和资源占用,再决定。

7.2 迁移时容易忽略的兼容性问题

从 2.4 迁到 3.0,有几个地方会咬人。

连接器命名变了。2.4 里connector = 'mysql-cdc',3.0 的 YAML 里写type: mysql,看着是简化了,但如果你同时维护两套任务,很容易混淆。

部分参数名改了。比如scan.incremental.snapshot.chunk.size在 3.0 里依然存在,但有些 Debezium 的透传参数前缀从debezium.变成了别的形式,具体要查对应版本的文档。升级前把现有配置逐项对照一遍,别指望完全兼容。

状态不兼容。3.0 的 state 格式跟 2.4 不同,不能从 2.4 的 savepoint 恢复。这意味着迁移时必须重新做一次全量,或者手工把位点搬过去。如果表特别大,这个切换窗口要提前规划,最好在业务低峰期做。

Sink 的写法变了。3.0 内置了一批 Sink(JDBC、Kafka、Doris 等),如果下游是比较少见的存储,可能还得自己写或者退回 2.4。

我个人在几个项目里的实际体会是:新项目、整库同步、下游是主流数仓的,直接上 3.0,省下的重复配置工作量很可观;存量大、任务多、下游五花八门的老系统,先别动,等 3.x 再迭代一两个小版本看看稳定性再说。技术选型最怕的就是为了尝鲜把稳定跑了半年的链路推倒重来,那个代价远比省下的几段配置代码高。

7.3 一个容易被低估的细节:下游表的建表语句

不管用 2.4 还是 3.0,下游表的建表语句都值得单独说。很多人直接把源表结构复制过去改个名字,结果踩一堆坑。

首先是主键必须带。下游表没有主键就无法做 upsert,重放数据会变成重复行。哪怕源表没主键(暂时没法加),下游也要想办法造一个(比如用几个字段组合),否则一致性没法保证。

其次是字符集和排序规则。源表是utf8mb4,下游建成utf8,遇到四字节字符(emoji、生僻字)就会截断或报错。这个错误在测试环境往往发现不了,因为测试数据都是纯英文数字。

然后是字段长度。源表VARCHAR(255),下游建成VARCHAR(100),看起来只是省点空间,实际上超过 100 的数据写入时会报错或者被截断,而且这类错误在批量写入时可能被吞掉。下游的字段长度一律不小于源表,这是我定的死规矩。

最后是时间字段的类型。源库DATETIME,下游建成TIMESTAMP,后者在 MySQL 里有 2038 年上限,而且受时区影响。统一用DATETIME能省掉一堆麻烦。

这些看起来都是细枝末节,但真正在生产环境出问题时,往往就是这些"看着不重要"的地方在捣鬼。我在一个项目里见过下游表把DECIMAL(12,2)写成了DECIMAL(12,0),跑了三个月才发现所有金额的小数部分都没了,补数据补了整整两天。从那以后,我的习惯是每次建完下游表,都写一条脚本自动比对源表和目标表的information_schema.columns,字段类型、长度、精度、可空性逐项对比,把差异打印出来人工过一遍,这个投入产出比极高。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进