数据同步这件事,做过一次就再也回不去了。尤其是当业务方甩过来一句“把订单库的变更实时推到消息队列,下游好几个系统等着消费”,你脑子里第一反应大概率是写个定时任务轮询——然后立刻被自己否掉,因为轮询的延迟、对源库的压力、还有增量识别的复杂度,三座大山压下来根本不划算。这个标题讲的正是把 MySQL 的增量变更通过 CDC 手段实时同步到 Kafka 的完整链路,属于数据集成里非常典型的一环。它解决的核心问题是:如何在不侵入业务代码、不显著增加源库负担的前提下,把数据库的行级变更(增删改)捕获出来,转成结构化消息投递到 Kafka,供下游实时消费。适合已经对 Flink、Kafka 有基本概念,正在做实时数仓、异构数据同步、缓存刷新或事件驱动架构的中高级开发者参考。下面我按实际落地的顺序,把这条链路里真正会卡住人的地方一个个拆开讲。
1. 为什么这条链路值得单独拎出来做
1.1 从“定时拉”到“变更推”的思维切换
很多人第一次接触数据同步,习惯的是定时全量或定时增量拉取。定时任务的好处是简单、可控、好排查,但它的代价在于延迟不可控和源库压力集中。假设你每 5 分钟跑一次增量查询,用update_time > 上次时间这种条件去捞,会遇到两个绕不开的坑:一是同一秒内的多条更新可能因为时间精度被漏掉或重复;二是物理删除的数据你根本捞不到,因为行都没了。CDC 的思路完全不同,它盯的是数据库的变更日志(MySQL 就是 binlog),数据库写什么它就捕获什么,删除也能拿到删除前的主键。这种“跟着日志走”的模式,天然具备低延迟、不漏删、对业务零侵入的特点。
从拉取到推送的转变,本质上是把同步的触发权从“消费者主动问”交给了“生产者被动通知”。这个转变带来的直接好处是延迟从分钟级压到秒级甚至毫秒级,代价是你需要理解 binlog 的格式、位点管理、以及下游投递的语义保证。我在实际项目里见过太多人卡在“为什么同步过去的数据顺序乱了”或者“为什么重启后重复消费了一大批”,根子都在没吃透这套机制。
1.2 这条链路在真实架构里的位置
把 MySQL 变更同步到 Kafka,通常不是终点,而是中间枢纽。典型的下游有三类:第一类是实时计算,比如 Flink 作业消费 Kafka 做窗口聚合、实时大屏;第二类是缓存与检索同步,比如把变更推到 Kafka 后由消费者刷新 Redis 或更新 Elasticsearch;第三类是事件驱动,比如订单状态变更触发下游履约、通知、风控等业务动作。Kafka 在这里扮演的是“变更总线”的角色,把数据库这个点状事件源变成可被多方订阅的流。
理解这个位置很重要,因为它决定了你对消息格式、分区策略、投递语义的要求。如果下游是实时计算,你可能更在意消息的顺序性和 exactly-once;如果下游是缓存刷新,至少一次(at-least-once)配合幂等消费通常就够了。这些取舍会反过来影响你在同步链路上的配置,所以动手之前先把下游需求想清楚,比急着搭环境更省时间。
1.3 选 Flink CDC 而不是自己写 binlog 解析的理由
理论上你完全可以自己用 Canal 或者手写 binlog 解析客户端来做这件事。但 Flink CDC 的价值在于它把“变更捕获”和“流式处理”揉在了一起。它内置了 MySQL CDC Source,能直接读全量加增量,全量阶段结束后自动切换到增量位点,不需要你手动维护两套逻辑。更关键的是,同步到 Kafka 之后如果你还想做字段清洗、脱敏、路由分发,Flink 的算子链可以直接接上,不用再起一个中间服务。
自己写解析客户端的痛点在于:全量到增量的无缝切换、位点持久化、断点续传、DDL 变更处理,每一项都是细活。Flink CDC 把这些脏活封装掉了,你只需要关心 Source 配置和 Sink 配置。当然它也不是银弹,后面会讲到它在表结构变更和大表全量阶段的坑。
2. 动手前的环境与版本对齐
2.1 版本矩阵没对齐,后面全是玄学
这条链路上涉及 MySQL、Flink、Flink CDC 连接器、Kafka 四个组件,版本兼容性是第一个拦路虎。我踩过最典型的一次是 Flink 1.14 配了一个较新的 CDC 连接器,结果 Source 初始化就报类找不到,排查半天发现是连接器依赖的 Flink API 版本不匹配。下面这张表是我实测下来比较稳的组合,供参考:
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| Flink | 1.17.x / 1.18.x | 1.17 起对 CDC 支持较成熟,社区资料多 |
| Flink CDC 连接器 | 2.4.x / 3.0.x | 3.0 起 API 有调整,注意与 Flink 版本对应 |
| MySQL | 5.7 / 8.0 | 8.0 需注意认证插件与 binlog 格式 |
| Kafka | 2.8+ / 3.x | 3.x 的 KRaft 模式可省 Zookeeper,但生产慎用早期版本 |
需要强调的是,Flink CDC 3.0 之后连接器的打包方式和 API 有不小变化,网上很多 2.x 的教程直接套到 3.x 上会报错。我的建议是:新项目直接上 Flink 1.18 + CDC 3.0 的组合,老项目如果 Flink 版本较低,就老老实实用对应的 2.4.x 连接器,别硬凑。
2.2 MySQL 侧必须打开的开关
CDC 读的是 binlog,所以 MySQL 必须开启 binlog 且格式为 ROW。这一步如果没做,后面所有配置都是白搭。检查方式很简单,登录 MySQL 执行:
SHOW VARIABLES LIKE 'log_bin'; SHOW VARIABLES LIKE 'binlog_format'; SHOW VARIABLES LIKE 'binlog_row_image';期望结果是log_bin=ON、binlog_format=ROW、binlog_row_image=FULL。binlog_row_image设成 FULL 是为了让变更事件里带上完整的行数据,否则只有变更列,下游拼不出完整记录。这几个参数在 MySQL 8.0 默认配置里不一定都满足,尤其是云上托管的实例,有的默认关闭 binlog 或者格式是 MIXED,需要提前和运维确认。
还有一个容易被忽略的点:用于同步的账号需要具备SELECT、REPLICATION SLAVE、REPLICATION CLIENT权限。少了 REPLICATION 相关权限,连接器连 binlog 都读不了,报错信息还不一定直白。
2.3 Kafka 侧的前置准备
Kafka 这边主要是建好目标 topic,并想清楚分区数。分区数直接决定了消费端的并行度上限,如果下游 Flink 作业并行度是 4,而 topic 只有 2 个分区,那有两个 subtask 会空转。一般建议 topic 分区数不小于下游预期并行度。另外,如果对消息顺序有要求,要保证同一业务主键的变更落到同一分区,这就需要在写入时指定 key,通常用表名加主键拼一个 key,后面 Sink 部分会具体说。
3. Source 端:把 MySQL 变更稳稳读进来
3.1 全量加增量的启动模式怎么选
Flink CDC 的 MySQL Source 提供了几种启动模式,最常用的是initial(先全量快照再切增量)和latest-offset(只从最新位点开始读增量)。选哪个取决于你的场景:如果是首次搭建、下游需要历史全量数据,用initial;如果是链路重建、下游已经有历史数据只需要增量,用latest-offset能避免重复灌全量。
这里有个实操细节:initial模式在全量阶段会对源库加锁吗?在 Flink CDC 2.x 之后,全量阶段采用的是无锁快照(基于 binlog 位点 + 一致性读),不会长时间锁表,但大表全量期间仍会占用一定的数据库连接和 IO。我做过一张两千万行的表,全量阶段跑了将近二十分钟,期间源库 CPU 有明显上升但不影响业务。如果表特别大,建议错峰执行,或者用分库分表并行读取的方式拆开。
3.2 关键配置项逐条拆解
下面是一段典型的 Source 配置(DataStream API 风格,SQL 风格逻辑一致):
MySqlSource<String> source = MySqlSource.<String>builder() .hostname("127.0.0.1") .port(3306) .databaseList("order_db") .tableList("order_db.t_order") .username("cdc_user") .password("******") .deserializer(new JsonDebeziumDeserializationSchema()) .startupOptions(StartupOptions.initial()) .serverId("5401-5404") .build();逐条说几个容易出问题的:
serverId:这是伪装成 MySQL 从库的标识,必须全局唯一。如果多个 CDC 任务连同一个 MySQL 且 serverId 冲突,会互相踢下线,表现为任务反复重启。建议按任务规划一段区间,比如 5401-5404 表示这个任务最多用 4 个并行度。databaseList和tableList:支持正则,但要注意tableList必须写成库名.表名的形式,只写表名匹配不到。deserializer:决定输出格式。JsonDebeziumDeserializationSchema输出的是 Debezium 风格的 JSON,包含 before、after、op 等字段,信息全但体积大。如果下游只关心变更后的数据,可以自定义反序列化器精简结构,能显著降低 Kafka 的存储和网络开销。
3.3 并行度与 serverId 的对应关系
Source 的并行度不是随便设的。每个并行子任务会占用一个 serverId,所以 serverId 区间长度必须大于等于并行度。比如你设并行度为 4,serverId 写成5401(单值),启动时就会报错说 serverId 不够用。正确写法是5401-5404。这个细节文档里提过但很容易被忽略,我第一次配的时候就是并行度 4 配了单 serverId,任务起不来还以为是权限问题。
另外,并行度也不是越大越好。全量阶段并行度受表数量限制,单表无法拆分并行(除非用分片算法);增量阶段并行度受 binlog 分片影响。一般单表同步并行度设 1 到 2 就够,多表场景可以按表数量适当提高。
4. Sink 端:把变更消息干净地写进 Kafka
4.1 Kafka Sink 的连接与序列化配置
Sink 端相对简单,但序列化和分区策略是两个关键点。用 Flink 自带的KafkaSink大致是这样:
KafkaSink<String> sink = KafkaSink.<String>builder() .setBootstrapServers("kafka-broker:9092") .setRecordSerializer(KafkaRecordSerializationSchema.builder() .setTopic("order_cdc_topic") .setValueSerializationSchema(new SimpleStringSchema()) .setKeySerializationSchema(new SimpleStringSchema()) .build()) .setDeliveryGuarantee(DeliveryGuarantee.AT_LEAST_ONCE) .build();setDeliveryGuarantee决定投递语义。AT_LEAST_ONCE是默认且最稳的选择,配合下游幂等消费即可;EXACTLY_ONCE需要 Kafka 事务支持,会带来额外延迟和事务超时风险,除非下游对重复极度敏感,否则不建议一上来就开。
4.2 用 key 控制分区与顺序
前面提到顺序问题,落地方式就是在序列化时指定 key。默认的KafkaRecordSerializationSchema如果不设 key,消息会轮询落到各分区,同一张表同一主键的多次变更可能分散到不同分区,下游消费顺序就乱了。正确做法是从变更事件里解析出表名和主键,拼成 key:
.setKeySerializationSchema((element, context) -> { JSONObject obj = JSON.parseObject(element); String table = obj.getString("table"); JSONObject after = obj.getJSONObject("after"); String pk = after != null ? after.getString("id") : "unknown"; return (table + "_" + pk).getBytes(StandardCharsets.UTF_8); })这样同一主键的变更永远进同一分区,Kafka 单分区内有序,下游就能按序消费。代价是热点主键可能造成分区倾斜,如果业务主键分布本身均匀,这个问题不大;如果存在明显热点,需要评估是否接受。
4.3 消息体精简与字段裁剪
Debezium 原始 JSON 里 before、after、source、op、ts_ms 一大堆字段,实际下游往往只用到 after 和 op。全量保留会让 Kafka 消息体积翻倍甚至更多。我的做法是在 Sink 之前加一个 Map 算子做裁剪,只保留业务需要的字段,同时把 op 映射成可读的操作类型(insert/update/delete)。这一步能省下可观的存储和带宽,尤其在高频变更的表上效果明显。裁剪时要注意 delete 事件没有 after,主键要从 before 里取,别裁过头把主键弄丢了。
5. 那些文档里不会写的坑
5.1 表结构变更导致任务直接挂掉
这是 Flink CDC 最经典的坑。源表加了一列或者改了字段类型,CDC 任务在解析 binlog 时可能抛异常直接挂掉,或者新列的数据静默丢失。2.x 版本对 DDL 的支持有限,3.0 有所改善但仍需谨慎。我的应对策略是:对频繁变更的表,在 Source 配置里开启 schema 变更容忍相关参数(具体参数名随版本不同),同时建立监控,一旦任务重启告警就人工介入。更稳妥的做法是把 DDL 变更纳入发布流程,变更前先停同步任务,改完再重启并从指定位点恢复。
5.2 全量阶段大表把数据库拖慢
前面提过全量快照不锁表,但它依然要扫全表。一张几千万行的宽表,全量阶段产生的读 IO 不容小觑。我遇到过一次全量期间源库慢查询告警飙升,排查发现是 CDC 的全量读取和业务查询抢资源。缓解办法有三个:一是错峰,把首次启动放在低峰期;二是限速,部分版本支持配置读取速率;三是分片,把大表按主键范围拆成多个分片并行读,缩短总时长。第三种效果最好但配置最复杂,需要权衡。
5.3 位点丢失与重复消费
任务重启后从哪个位点继续,取决于 checkpoint 和 savepoint 的配置。如果没有开启 checkpoint,任务失败重启后可能从头开始或者从最新位点开始,前者导致大量重复,后者导致数据丢失。所以生产环境必须开启 checkpoint,并合理设置间隔(比如 30 秒到 1 分钟)。另外,Kafka 的AT_LEAST_ONCE语义下,checkpoint 之间失败重放会产生重复消息,这是设计使然,下游必须做幂等。我见过有团队因为下游没做幂等,重复消息导致库存被扣了两次,这种事故完全可以通过消费端按主键去重避免。
5.4 时区与时间字段的错位
MySQL 里的 datetime 字段经过 CDC 再到 Kafka,如果各环节时区配置不一致,下游看到的时间可能偏移几个小时。这个坑很隐蔽,因为数据本身“看起来”是对的,只是时间不对。根因通常是 MySQL 服务端时区、连接器 JVM 时区、Kafka 消息里时间戳的时区三者没对齐。我的做法是统一用 UTC 存储和传输,在展示层再做本地化转换,同时在 Source 配置里显式指定时区,避免依赖默认值。
6. 上线前该做的验证与监控
6.1 用三类数据做端到端验证
链路搭完别急着上线,至少做三轮验证。第一轮是增删改各来一条,确认 Kafka 里能收到对应 op 的消息且字段完整;第二轮是批量更新,比如一次 update 影响一千行,确认消息条数和顺序符合预期;第三轮是边界场景,包括主键为特殊字符、字段含中文和 emoji、时间为空值等。这三轮跑下来,大部分序列化和解析问题都能暴露。
验证时建议直接消费 Kafka 打印消息,而不是只看 Flink 任务不报错。任务不报错不代表数据对,我见过反序列化配置写错导致消息体是空字符串但任务一直正常运行的情况。
6.2 必须盯住的几个指标
上线后有几个指标要重点监控:Flink 任务的 checkpoint 成功率和耗时(失败或耗时飙升说明状态或反压有问题)、Source 端的 binlog 读取延迟(反映同步是否跟得上)、Kafka 的写入速率和 topic 积压(反映下游消费能力)、以及任务重启次数。其中 binlog 读取延迟最能反映链路健康度,一旦持续增长说明 Source 处理不过来或者下游反压传导上来了。
我在实际运维中的体会是,这条链路最脆弱的环节往往不是 Flink 本身,而是 MySQL 的 binlog 保留策略。如果 binlog 过期被清理而任务恰好停机超过保留期,重启后位点就找不到了,只能重新全量。所以务必确认 binlog 保留时间大于你可能的最长停机时间,一般建议至少保留 7 天。
6.3 一个容易被忽视的收尾动作
最后分享一个小技巧:在 Sink 之后加一个轻量的旁路统计,比如每处理一万条打一条日志记录当前表名和 op 分布。这个动作几乎零成本,但出问题时能快速判断是“没数据过来”还是“数据过来了但格式不对”,把排查范围直接砍一半。踩过几次坑之后我现在每个同步任务都会加这个旁路,比事后翻 checkpoint 状态高效得多。