做实时数据同步,MySQL 到 Elasticsearch 这个组合我接手过不下十次。前阵子项目里要把订单系统近一年的数据搬进 ES,供前台搜索和后台报表聚合查询,业务上要求延迟按秒算,数据不能丢,MySQL 一有增删改,ES 索引里要立刻看得见。调研了一圈,最后选了 Flink CDC 来做这条链路。
这一篇把从版本选型、MySQL binlog 准备,到 YAML 无代码和 Flink SQL 两条实操路径,再到生产环境踩过的那些坑,完整整理一遍。适合刚接触 Flink CDC 想快速跑通 MySQL→ES 同步的开发者,也适合已经接手实时同步任务、正在被 binlog 消费、类型映射和全量增量衔接折磨的朋友。文章不绕弯子,只讲验证过的做法和能直接复用的配置。
1. 先搞清楚要解决什么问题
1.1 最典型的场景:数据库扛不住查询,ES 来分摊
订单系统这类业务,数据表的结构通常比较复杂。一个订单主表对应多个明细表,还关联用户、商户、商品等多张维表。前台要按关键词搜商品、按状态筛订单、按时间段聚合数据,这些查询在 MySQL 里写起来费劲,一旦并发上来或者数据量到千万级,一个模糊查询就能把主库拖慢,直接影响线上交易。
于是很自然想到把数据同步到 Elasticsearch。但同步这件事,难就难在数据一致性、时效性和关联关系上。业务方的要求往往是:订单状态从“待付款”变成“已支付”,ES 里最多几秒就要能查出来。如果写一个定时任务,每天凌晨全量刷一遍 ES,延迟是小时级,业务根本没法用。就算改成每分钟轮询增量,数据形态变化很快的字段(比如状态、物流单号)也很难及时反映。
这种需求下,基于 binlog 的实时同步就成了最优解。MySQL 每一条增删改都会记录到 binlog,实时捕获这些变更再写入目标端,数据的时效性可以做到秒级以内,同时不依赖业务代码里的埋点和轮询逻辑,对源库几乎无侵入。
1.2 方案对比:Canal、Debezium、Logstash、Flink CDC 怎么选
市面上做 MySQL 实时同步的方案不少,我列个对比表,都是实际用过的:
| 方案 | 实现方式 | 延迟 | 维护成本 | 适用场景 |
|---|---|---|---|---|
| 定时脚本 | 业务代码定时查增量字段 | 分钟到小时级 | 低 | 数据量小、实时性要求低 |
| Canal + 自研消费 | 伪装成 MySQL slave 拉 binlog | 秒级 | 高,要自研解析与写入 | 有团队愿意长期维护 |
| Debezium + Kafka + Connect | 通过 Kafka Connect 消费 binlog | 秒级 | 中高,引入 Kafka 组件 | 已有 Kafka 生态、多下游 |
| Logstash JDBC 轮询 | 定时查数据库 | 秒级到分钟级 | 低 | 简单全量+增量,无法删数据 |
| Flink CDC | 直接读取 binlog + 快照 | 秒级 | 中 | 全量+增量一体化、复杂转换 |
Canal 本身做的是 binlog 订阅,但拿到原始日志后,你还得写代码解析、处理表结构变更、管理消费位点,再对接 ES 写入,这一套自研下来工作量不小。Debezium 方案同样,典型的架构是 Debezium 把变更发到 Kafka,下游再用消费程序写 ES,链路中间多一个 Kafka,运维成本上来了。
Flink CDC 的核心价值在于把“全量快照 + 增量 binlog”无缝衔接这件事内建到了框架里。它先对源表做一次一致性快照,记录当时的 binlog 位点,快照完成后从位点继续读增量,期间的数据变更不会丢,也不会重复。配合 Checkpoint,可以做到端到端的有状态容错。再加上 Flink 本身有 SQL 层,过滤、关联、聚合这些转换直接在 SQL 里写,不需要单独开发解析程序。对“MySQL 一张或多张表 -> ES 索引”这种固定形态的同步,这是性价比最高的路线。
1.3 要搭建的是这样一条链路
整体架构其实很直白:
MySQL binlog -> Flink CDC Source(读取变更事件)-> Flink 作业(可选过滤、关联、格式转换)-> Elasticsearch Sink -> ES 索引
在 Flink CDC 2.x 时代,常见做法是写 Flink SQL 或者 DataStream 作业。到了 Flink CDC 3.x,官方推出了 Pipeline 无代码模式,直接在 YAML 里声明源和目标,不用写一行 Java。两种方式我都在生产里跑过,后面会分别讲配置要点。
链路里要注意一个容易被忽略的事实:Elasticsearch 不是数据库,它没有事务,没法像 MySQL 那样提供 ACID。我们说的“数据不丢”是指 binlog 事件被完整消费并写入 ES,而 ES 的写入是文档级别的覆盖更新,靠文档主键保证最终一致。所以同步设计的关键,是要保证每条变更事件都能对应到 ES 文档的主键,并且消费者能以“至少一次”的语义处理写入,配合幂等覆盖来达到最终一致性。
2. 版本选型与前置环境准备
2.1 版本矩阵怎么定,少走半年弯路
Flink CDC 是个对版本非常敏感的项目,组件之间匹配不对,经常会遇到莫名其妙的方法找不到、连接器不兼容的问题。我先给一套实测稳定的组合,这也是我目前生产环境在用的:
| 组件 | 版本 | 说明 |
|---|---|---|
| Flink | 1.17.2 | 稳定,生态全 |
| Flink CDC | 2.4.2 | MySQL connector 支持单表和多表模式 |
| MySQL | 8.0.x | 5.7 也能跑,8.0 对 binlog 配置更友好 |
| Elasticsearch | 7.17.x | 7.x 连接器稳定,8.x 配置项变化较大 |
如果你要用无代码 Pipeline 模式,推荐 Flink CDC 3.1 及以上配合 Flink 1.18 或 1.19,因为 3.0 早期的 pipeline 还有一些功能不完整。我自己的习惯是:生产环境跑 Flink SQL 用 2.4.2,实验性和新项目评估用 3.1。
还有一点,ES 的客户端连接器是基于 RestHighLevelClient 的,ES 7.17 是最稳妥的版本,ES 8 以后的连接器改用了新的 client 接口,配置上多了证书和 SSL 相关的项,如果你的 ES 恰好是 8.x,配置方式要单独看官方文档,别拿 7.x 的配置硬套。
2.2 MySQL 侧:binlog 开启与账号权限
Flink CDC 读取 MySQL 的变更,前提是 MySQL 必须开启 binlog 且格式为 ROW。检查当前状态:
SHOW VARIABLES LIKE 'log_bin'; SHOW VARIABLES LIKE 'binlog_format';如果没开启,修改 my.cnf(或 mysqld 配置):
[mysqld] server-id=1 log_bin=mysql-bin binlog_format=ROW binlog_row_image=FULL这里每个参数都有讲究。binlog_format 必须是 ROW,因为 Statement 模式下记录的是 SQL 语句而不是行变更,Flink CDC 拿不到更新前后的具体字段值。binlog_row_image 设为 FULL,是为了让 MySQL 在 binlog 里记录整行的前后镜像,否则更新操作可能只记录被修改的字段,当某条记录的旧值在解析时缺失,ES 端的文档对比就会出问题。
同步账号的权限也容易踩坑。除了常规查询权限,还需要 RELOAD、REPLICATION SLAVE、REPLICATION CLIENT。RELOAD 是为了执行 FLUSH TABLES WITH READ LOCK 来获取一致性快照,REPLICATION 权限用于拉取 binlog。给一个最小权限集:
CREATE USER 'flink_sync'@'%' IDENTIFIED BY '你的强密码'; GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'flink_sync'@'%'; FLUSH PRIVILEGES;注意别只给业务库的权限就完事,SHOW DATABASES 和 REPLICATION 这类权限是实例级别的,必须带 ON.授权。
2.3 Flink 运行环境与依赖准备
Flink 的部署形态有 Standalone、YARN 和 Kubernetes 等多种。同步作业本身的吞吐量不算高,几十 MB/s 已经很大了,所以部署方式可以根据公司基础设施选。我自己习惯用 Standalone 集群跑这类同步任务,一个 TaskManager 给 2 个 Slot,基本够用。但生产环境如果有资源调度需求,YARN 或者 K8s Operator 更合适。
依赖 Jar 的准备要特别注意版本一致性。Flink SQL 方式需要把 flink-sql-connector-mysql-cdc、flink-sql-connector-elasticsearch7 放到 FLINK_HOME/lib 下。直接从 Maven 仓库下载对应版本即可。在 Flink SQL Client 里执行时,SHOW JARS能看到已加载的依赖。
如果要用 YAML 无代码模式,需要下载 flink-cdc-pipeline-connector-mysql 和 flink-cdc-pipeline-connector-elasticsearch 两个连接器,放到 Flink CDC 发行版的 lib 目录。这些包名容易混淆,下载前要看清是 SQL connector 还是 pipeline connector,两者不能互换。
3. 两条路实操:无代码 Pipeline 与 Flink SQL
3.1 无代码方案:Flink CDC Pipeline YAML 配置
Flink CDC 3.x 的 YAML 模式最适合“一张表同步到一个索引”或者“多张表同步到各自的索引”这类需求。以订单场景为例,把 orders 和 order_items 两张表同步到各自的 ES 索引,配置如下:
source: type: mysql hostname: 127.0.0.1 port: 3306 username: flink_sync password: 你的密码 tables: ecom.orders, ecom.order_items server-id: 5400-5404 sink: type: elasticsearch host: http://127.0.0.1:9200 index: order_index username: elastic password: 你的密码 pipeline: name: mysql-to-es-order-sync parallelism: 4 schema-change.behavior: try_to_evolve route: - source-table: ecom.orders sink-table: order_index - source-table: ecom.order_items sink-table: order_item_index启动方式很简单:
$FLINK_HOME/bin/flink-cdc.sh /path/to/pipeline.yamlYAML 模式最爽的点是不用写代码,schema 变更时能尝试自动演进。比如源表新增一列,配置里的schema-change.behavior: try_to_evolve会尝试把新字段自动加到 ES 文档里。但要注意,这个自动演进不是万能的:如果源表删列,ES 端旧字段还会保留,数据不会自动删,只是后续文档不再写入该字段;如果字段类型变了,ES 的 mapping 可能直接冲突,这种场景需要人工介入。
server-id 配置成 5400-5404 表示给作业分配 5 个独立的 server-id。为什么是 5 个?因为 source 并行度如果大于 1,每个并行分片去拉取不同表的 binlog 时需要独立的 server-id,不然 MySQL 会认为多个客户端共享同一个复制通道,导致数据错乱。这个区间只要保证全局唯一就行,取值范围是 1 到 2^32-1,避开其他同步作业用过的值即可。
3.2 Flink SQL 方案:Source 与 Sink 建表及同步语句
如果你需要做 JOIN、聚合或字段裁剪,YAML 模式就有点不够用了,这时候回到 Flink SQL 更顺手。同样是订单表同步到 ES,完整语句如下:
CREATE TABLE orders_source ( id INT PRIMARY KEY, user_id INT, amount DECIMAL(10, 2), status STRING, create_time TIMESTAMP(3), WATERMARK FOR create_time AS create_time ) WITH ( 'connector' = 'mysql-cdc', 'hostname' = '127.0.0.1', 'port' = '3306', 'username' = 'flink_sync', 'password' = '你的密码', 'database-name' = 'ecom', 'table-name' = 'orders', 'server-id' = '5400', 'scan.startup.mode' = 'initial' ); CREATE TABLE order_sink ( id INT PRIMARY KEY, user_id INT, amount DOUBLE, status STRING, create_time TIMESTAMP(3) ) WITH ( 'connector' = 'elasticsearch', 'hosts' = 'http://127.0.0.1:9200', 'index' = 'order_index', 'username' = 'elastic', 'password' = '你的密码', 'sink.bulk-flush.max-actions' = '500', 'sink.bulk-flush.max-size.mb' = '10', 'sink.bulk-flush.interval' = '1000', 'format' = 'json' ); INSERT INTO order_sink SELECT id, user_id, CAST(amount AS DOUBLE), status, create_time FROM orders_source;这段 SQL 里最核心的是主键声明。ES sink 的 upsert 语义依赖主键:当一条 UPDATE 事件到达时,Flink 根据主键去覆盖对应的 ES 文档;如果源表没有主键,或者建表语句里没声明主键,更新操作在 ES 端就退化成普通写入,会产生重复文档。所以哪怕业务表没有主键,也要选一个业务唯一键当主键来声明。
scan.startup.mode = 'initial'表示作业启动时先做全量快照,再自动切换到增量。这是默认值,也是最省心的模式。如果只关心增量数据,可以改成latest-offset,但这样会丢掉历史数据,ES 索引里只有作业启动后才写入的文档,大多数同步场景不建议这么用。
3.3 关键参数背后的原理与取舍
server-id的分配是个高频出问题的点。在 Flink CDC 里,如果作业并行度是 N,MySQL 源连接器会尝试拉取 N 个分片的数据。多个分片同时作为 slave 连接 MySQL,就需要多个不同的 server-id。配置时可以给一个范围,比如5400-5404,Flink 会从区间里自动分配。但要注意,同一个 server-id 不能同时被两个作业使用,否则 MySQL 的 dump 线程会被后连接的客户端挤掉,前面的作业瞬间断流。建议规划一张“server-id 分配表”,把不同同步作业的区间固定下来,避免上线时互相撞车。
全量快照阶段的 chunk 大小也值得关注。Flink CDC 做全量时把表分成多个 chunk 并行扫描,默认 chunk 大小是 8096 行。表特别大(千万行以上)时,不建议把并行度调得过高,否则瞬间的读取压力会让源库的 CPU 和 IO 飙升,影响在线业务。我的经验是:大表同步并行度 2~4 就够了,小表并行度 1 也行,分批慢慢扫比一次性压满要稳。
ES sink 的 bulk 参数直接影响写入吞吐。max-actions控制一次批量写入多少条,max-size控制体积,interval控制攒批的等待时间。默认值有时候太保守,写入慢的情况下可以调大,但要注意 bulk 太大会导致 ES 端内存压力上升。实际调优时,先看 ES 节点有没有返回 429 限流,没有就逐步加大,压出那个临界点。
4. 实战中的典型坑与排查实录
4.1 binlog 消费错乱:server-id 冲突的现场
这个坑我印象太深了。当时上线了一个新同步作业,刚启动几分钟,另一个已经在稳定运行的作业突然报错,提示 binlog 连接被重置,日志里有 Connection reset by peer 的字样。查了一圈发现,新作业的 server-id 配置和旧作业完全一样。MySQL 的 binlog dump 是单通道的,同一个 server-id 新连接会直接踢掉旧连接,旧作业自然断流。
排查方法是先看 MySQL 侧当前的复制连接状态:
SHOW PROCESSLIST;如果看到同一个 server-id 出现在多条连接里,基本就是撞车了。解决办法只有一个:给每个作业分配互不重叠的 server-id 区间,并把分配记录维护好。我的习惯是把范围固定到 50000 以内自定义规划,比如订单同步占用 5400-5409,支付同步占用 5410-5419,后续上线前先查表,不和其他作业重叠。
4.2 全量与增量交接:initial 模式的隐藏条件
initial模式实现上是先记录当前 binlog 位点,然后扫描全表。扫描完成后再从记录位点开始消费增量。如果全量阶段耗时很长,而 MySQL 的 binlog 保留时间很短,位点可能在被读取前就已经被清理,作业就会报位点丢失的错误。
这个报错通常长这样:The requested binlog position is not found in the binlog files,或者类似 “not found” 的异常。解决方案是在同步大表之前,把 binlog 保留时间调大。MySQL 8.0 的配置项是:
binlog_expire_logs_seconds = 6048007 天足够覆盖绝大多数场景。如果业务上不允许保留太久,至少也要估算全量扫描的最坏耗时,再把 binlog 保留时间设置为它的 2 倍以上。
另外提一句,大表全量扫描时,Flink CDC 通过一致性快照机制避免了长时间锁表。它在 REPEATABLE READ 隔离级别下做一致性读,只会在获取全局读锁的瞬间短暂阻塞写入,正常情况下不会影响线上事务。但如果表上有 DDL 在跑,快照会一直等待,表现为作业卡在启动阶段。解决办法是把全量同步窗口安排在业务低峰,或者确保没有长事务。
4.3 类型与时区:decimal 精度丢失和 8 小时时差
MySQL 的 DECIMAL 类型在 Flink SQL 里默认映射成 DECIMAL,而 ES 没有原生的 decimal 类型,写进去时如果通过 JSON 序列化,很可能会变成浮点数,导致精度丢失。金额这种字段是不能有精度误差的,所以在建 Sink 表时,我通常会显式转换:金额字段 CAST 成 DOUBLE,然后在 ES 索引的 mapping 里定义为 scaled_float,同时声明 scaling_factor。比如金额保留两位小数,就设 scaling_factor 为 100,ES 内部用 long 存储,展示时再除以 100,不会丢分。
时区问题更隐蔽。MySQL 的 DATETIME 不带时区,Flink CDC 在解析时默认按 UTC 处理,如果应用写入时是北京时间,ES 里查出来的时间就可能差 8 小时。最直接的办法是在 MySQL CDC 连接器参数里指定时区:
'debezium.event.deserialization.failure.handling.mode' = 'fail', 'debezium.database.server.timezone' = 'Asia/Shanghai'第二个参数让 Debezium 在解析时间字段时按目标时区解释。改完参数后,一定要用一条创建时间和更新时间的记录实测验证,别只看日志里解析对就放心。我遇到过配置看上去生效、但老数据还是差 8 小时的情况,后来是把 ES 索引的时间字段映射成epoch_millis,在 SQL 层用UNIX_TIMESTAMP()先转成毫秒,彻底绕开时区干扰。金融、订单这类对时间敏感的同步,建议直接用事件时间戳(epoch 毫秒),展示层再按业务时区格式化。
4.4 ES 写入问题:幂等、字段漂移与 mapping 冲突
ES sink 写入时,如果源表的主键和 ES 文档的_id不一致,更新操作就失去幂等性。Flink 默认把声明的主键字段作为文档_id,所以建表时主键选对了,幂等就对了。如果表没有合适的唯一键,同步前得先做一次去重逻辑,或者用多个字段拼接的虚拟主键。
字段漂移问题往往发生在源表加列之后。YAML 模式的try_to_evolve能自动加字段,但前提是 ES 端没有对该字段做动态映射冲突。比如新字段在 MySQL 是字符串,ES 自动映射成 text 没问题;但如果之后源表字段被改成数值型,ES 的 mapping 里已经存在 keyword 字段,再写入数字就会报 mapping 异常。更稳妥的做法是:在同步链路启动前就手工创建 ES 索引模板,把常用的字段类型预定义好,并设置dynamic: true允许新增字段但按动态规则映射。字段删减和类型变更这种操作,还是要走人工评审,先改目标端 mapping,再放行源端变更。
速查表整理一下常见问题:
| 现象 | 可能原因 | 排查优先级 |
|---|---|---|
| 作业启动后立即失败 | server-id 冲突或权限不足 | 先查这个 |
| 报 binlog 位点找不到 | binlog 保留时间短于全量耗时 | 高 |
| 金额字段值变了 | DECIMAL 转浮点丢精度 | 高 |
| 时间差 8 小时 | Debezium 时区设置不对 | 中 |
| ES 文档重复 | 主键声明缺失或选错 | 高 |
| 新字段写入报 mapping 异常 | ES 已有映射与新类型冲突 | 中 |
4.5 Checkpoint 与端到端一致的边界
Flink CDC 的可靠传输依赖 Checkpoint。作业开启 Checkpoint 后,binlog 消费位点和 ES 批量写入的状态会定期持久化,故障重启时从最近一次 Checkpoint 恢复,做到不丢不重(至少一次语义加上幂等写入,达到最终一致)。
Checkpoint 间隔建议设 10 秒到 30 秒,太频繁会让源库和状态后端压力变大,太稀疏会导致故障恢复窗口内重复处理大量数据。配置方式:
SET 'execution.checkpointing.interval' = '20s'; SET 'execution.checkpointing.mode' = 'EXACTLY_ONCE';注意,ES Sink 的“精确一次”其实是一个相对概念,官方支持通过幂等写入达到端到端至少一次+幂等覆盖,但 ES 本身不提供跨文档事务。如果业务要求严格数据一致性,同步链路之外还要加对账机制:定期比对 MySQL 记录数和 ES 文档数,或者抽样比对关键字段值。我把对账脚本放到定时调度里,每天跑一次,这是实时同步任务最后的保险。
5. 生产环境调优与日常维护
5.1 资源怎么估算:从数据量倒推并发与内存
给同步任务分配资源,不能拍脑袋。先说一个粗略的计算思路。假设订单表日增量 20 万条,平均每条变更事件序列化后约 500 字节,一天的 binlog 变更量就是 100MB 左右,摊到每秒大约 1KB~2KB 的消费流量。这种量级,一个 slot 绰绰有余。真正吃资源的环节是全量快照阶段,扫描千万行数据时的吞吐可能到每秒几万行,需要临时为它分配更多内存和 IO。
并行度设置有个经验规律:单个源表同步,并行度设 1 就够;单个表但数据量极大(超过 5 千万行),并行度设 2~4;多个表同步,并行度略大于表数量。并行度再大也没有明显收益,反而增加 binlog 连接数和源库压力。TaskManager 内存建议分配 2~4GB,其中托管内存占比高一些,因为 CDC 解析需要不少堆外内存。
ES 端的写入并发可以独立调,把 bulk 参数合理配置后,再观察 ES 集群的 CPU 和 reject 指标。如果 ES 频繁返回 429,不是同步作业慢,而是写入太快,应该降低 bulk 大小而不是提高并行度。反过来如果 ES 没有压力而同步延迟仍然大,那就检查 Flink 作业的背压,先加 sink 并行度。
5.2 监控指标:这几项必须盯
实时同步作业的监控,我重点看四个维度。
第一个是 Flink 的 Checkpoint 状态。Checkpoint 连续失败,数据恢复保障就失效了,这个问题必须最高优先级处理。第二个是延迟指标:source 端的 currentFetchEventTimeLag 和 fetchDelay,分别反映事件时间延迟和当前消费位点落后程度。正常情况应该稳定在几秒内,如果持续上涨,说明同步处理不过来。第三个是 sink 端的写入速率 numRecordsOutPerSecond 和瞬时 RPS,用来判断链路吞吐是否达标。第四个是 ES 端的 bulk reject 计数,直观反映写入压力是否超过集群承受能力。
这套指标可以接入 Prometheus + Grafana,也可以先看 Flink UI 上的曲线。我的建议是每天扫一眼这四个指标的曲线变化,比看一堆日志有效得多。
5.3 上线流程与演练建议
同步作业的上线,我的标准流程是:先在测试环境跑通一条最简单的表,验证增量;再切到生产,先同步一张数据量小、业务不依赖的表观察一两天;确认稳定后再逐步扩大到核心表。
每次源表有 schema 变更,都要先确认目标端 ES 的 mapping 能兼容,再放行变更。如果是加列,可以通过 try_to_evolve 自动处理,但上线前还是要看一次变更后的文档样例,防止字段映射成了奇怪的类型。如果是删列、改类型这类破坏性变更,宁可先停同步,手工调整好 ES 端的 mapping 再重新启动。
全量存量迁移还有一个建议:如果表数据量特别大,先用 Flink SQL 做一次批量全量写入,把历史数据导进去,再启动 CDC 增量作业接上。这样可以把增量作业启动时的全量阶段压力分散开,避免一个作业同时承担全量和增量两条负载。两者衔接的时间点要对准,增量作业启动前记录一下 binlog 位点,保证全量数据导完时增量的起点不晚于这个位点,就能做到不重不漏。
我个人实际做下来最大的体会是:同步作业本身的问题往往不在 CDC 解析,而在端到端一致性设计。binlog 保留时间够不够、server-id 规划清不清晰、主键选得对不对、目标端 mapping 有没有预建好,这四个问题提前解决,后面省掉 80% 的补数据精力。再分享一个小习惯:每次上线前,我会在源库手工插入一条带特殊标记的测试数据,等它出现在 ES 里再查出来比对字段值,确认链路没问题后才算真正上线。这个习惯帮我拦住过好几次时区和精度问题,你也值得一试。