去年我接手了一个订单中心的数据同步需求:MySQL 里接近亿级的订单数据要同步到 ES,支撑后台的实时筛选和搜索。最初我对方案的预期很简单——定时跑个 batch 把数据导过去就完事,直到业务方明确要求下单后十几秒内必须能在管理后台搜到订单,我才意识到这事儿的复杂度比表面看起来高得多。最终我选择了 FlinkCDC 这条链路:MySQL Binlog → FlinkCDC → Elasticsearch。这篇博文会把整个实战过程完整记录下来,包括环境版本怎么选、Binlog 怎么开、任务怎么写、上线后踩了哪些坑,以及后期运维需要盯住的几个关键指标,给准备做类似数据同步的朋友一个可以直接参考的完整链条。
1. 先聊选型:为什么最终是 FlinkCDC 而不是 DataX 或 Canal
1.1 我实际对比过的三种同步方案
接到这个需求时,团队内部对“工具选型”其实讨论过好几轮。大家最熟悉的自然是 DataX,调研了一圈之后发现它并不适合这个场景。DataX 是典型的离线批量同步工具,适合固定时间窗口的全量导出,比如凌晨 2 点把昨天的订单导出到数仓。但我们的业务要求是秒级延迟——订单状态一旦变更,后台搜索必须立刻能看到。DataX 的定时调度的最短粒度通常也是分钟级,而且每次跑全量对源库压力也不小,所以直接排除。
另一个常见选项是 Canal 监听 Binlog,然后自己写一个消费端转发到 ES。Canal 本身很成熟,但它本质上只解决了“怎么拿到 Binlog 变更”这一层,后续的解析、过滤、幂等写入、状态管理全部要自己实现。我们团队当时人力紧张,不想再维护一套自研消费框架。而且 Canal 在高并发写入场景下,自己管理位点、做重启恢复的成本不低,一个不小心就会造成数据丢失或重复。
FlinkCDC 的优势在于它把整条链路串起来了:FlinkCDC 负责读取 Binlog 变更事件,Flink 作为计算引擎负责处理数据转换,ES Connector 负责批量写入。Flink 的 Checkpoint 机制天然保证了“至少一次”的语义,任务挂掉重启后可以自动从最近一次 Checkpoint 恢复,不用自己维护消费位点。此外 Flink 生态对 JSON、数据库连接器、ES 都有现成的 Connector,开发成本低很多。
1.2 从同步模式看三种方案的差异
为了更直观地判断选型,我把三个方案在几个维度的表现列了一张表。这个表也是我当时给团队汇报时用的,直接决定了大方向。
| 对比维度 | DataX | Canal + 自研消费 | FlinkCDC |
|---|---|---|---|
| 同步模式 | 定时全量/增量 | 实时增量 | 全量 + 实时增量 |
| 秒级延迟 | 无法保证 | 可以 | 可以 |
| 断点续传 | 需要自行设计 | 需要自行维护位点 | Checkpoint 自动恢复 |
| 开发成本 | 低 | 高 | 中 |
| 对源库压力 | 大 | 小 | 小 |
| 数据转换能力 | 弱 | 需要自研 | 强大,支持 SQL/DataStream |
“全量 + 实时增量”是 FlinkCDC 最吸引我的一点。很多存量表已经存在大量历史数据,如果用 Canal,你需要先跑一次历史全量,再切换增量,衔接阶段很容易丢数据。FlinkCDC 的 StartupOptions.initial() 模式会把历史数据先读一遍,再无缝切换到 Binlog 增量,中间不需要人工干预,这个体验是其他方案很难比的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备:版本匹配是这场实战最大的隐形门槛
2.1 官方兼容矩阵并不够用
很多人一上来就写代码,结果反复编译报错,最大的问题出在版本匹配上。FlinkCDC、Flink、ES Connector 这三者的版本必须放在一起考虑,单看某一方的文档很容易踩坑。我最终的选型组合是:Flink 1.17.2 + FlinkCDC 2.4.1 + ES 7.17.8 + MySQL 8.0。这个组合我从 2023 年底用到现在,稳定运行了大半年,中间只有一次是因为服务器磁盘写满导致任务挂掉,数据层面没有出过问题。
为什么不建议用 FlinkCDC 2.3 以前的版本?因为 2.3 之前的 MySQL CDC 基于旧的 TableSource 实现,对并行读取支持不好,而且和 Flink 1.15+ 的兼容性有坑。2.4 版本开始,增量快照框架已经比较成熟,可以支持多并行度读取多个分片,性能提升明显。ES Connector 的版本则建议和 Flink 主版本保持一致,比如 Flink 1.17 就选 flink-connector-elasticsearch7:1.17.2,混搭版本往往在序列化器上出问题。
2.2 MySQL Binlog 参数配置
要让 FlinkCDC 正常工作,前提是 MySQL 已经开启 Binlog 并且格式正确。我犯过的第一个低级错误是直接在线上库改配置,结果 MySQL 必须重启才能生效,直接在业务高峰期把订单服务搞出了一个分钟级抖动。所以如果你是从零搭建,最好在初始化数据库时就把下面这些参数配好,不要等到要接数据同步了再补。
在 my.cnf 的 [mysqld] 段加上:
ini复制[mysqld]
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
gtid_mode = ON
enforce_gtid_consistency = ON
expire_logs_days = 7
binlog_format = ROW 是必须的,因为 FlinkCDC 需要拿到每一行变更前后的完整数据,Statement 格式只能拿到 SQL 语句,无法还原变更内容。binlog_row_image = FULL 同样重要,它保证更新操作时 Binlog 里记录了所有字段的旧值和新值;如果设置成 MINIMAL,Binlog 只记录被修改的字段,主键以外的其他字段拿不到,后续写入 ES 就会丢失字段。
修改完配置后,确认参数是否生效:
sql复制show variables like 'log_bin';
show variables like 'binlog_format';
show variables like 'binlog_row_image';
2.3 为 FlinkCDC 单独建账号
生产环境千万不要直接用 root 账号跑同步任务,权限太大且不好审计。我习惯单独建一个账号,只授予 FlinkCDC 需要的权限:
sql复制CREATE USER 'cdc_user'@'%' IDENTIFIED BY 'Password123!';
GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'cdc_user'@'%';
FLUSH PRIVILEGES;
SELECT 用于读取快照数据,RELOAD 用于执行一致性快照时短暂锁定相关表,SHOW DATABASES 用于自动发现库表,REPLICATION SLAVE 和 REPLICATION CLIENT 用于读取 Binlog 和获取位点信息。这些权限就能满足全量加增量的所有需求,最小化安全风险。
同步账号建好后,在 ES 侧也要做一些准备。我习惯先用 DSL 把索引定义好,而不是让 Flink 自动创建索引。自动创建的索引 mapping 非常粗糙,比如日期字段可能被识别成 text,后续查询只能遍历,性能很差。下面是订单索引的简化版配置:
code复制PUT /orders
{
"mappings": {
"properties": {
"order_id": { "type": "keyword" },
"user_name": { "type": "text", "analyzer": "ik_max_word" },
"order_status": { "type": "keyword" },
"total_amount": { "type": "double" },
"create_time": { "type": "date", "format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" }
}
}
}
order_id 用 keyword 类型,因为后续要把它作为 ES 文档的 _id,keyword 类型适合精确匹配。user_name 用 text 加分词器,方便搜索。order_status 这种枚举值用 keyword 避免无谓分词。create_time 设置成 date,并兼容多种格式,防止序列化时由于格式不一致导致写入失败。
3. 核心任务开发:一条从 MySQL 到 ES 的完整数据管道
3.1 两种实现方式:DataStream API 与 Flink SQL
Flink 接入数据同步有两条路子:DataStream API 和 Flink SQL。如果你是 Java 开发者,习惯代码控制一切,推荐 DataStream API;如果只想快速看效果、不关心底层细节,Flink SQL 更省事。两个方案我都跑通了,下面分别说下关键配置和适合场景。
先看 Flink SQL 版本,它大概是最短路径。只要在 Flink SQL 客户端里建两张表,一条 INSERT 语句就能搞定:
sql复制CREATE TABLE orders (
order_id BIGINT,
user_name STRING,
order_status STRING,
total_amount DOUBLE,
create_time TIMESTAMP(3),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'mysql-cdc',
'hostname' = '127.0.0.1',
'port' = '3306',
'database-name' = 'shop',
'table-name' = 't_order',
'username' = 'cdc_user',
'password' = 'Password123!',
'server-time-zone' = 'Asia/Shanghai',
'scan.startup.mode' = 'initial'
);
CREATE TABLE es_orders (
order_id BIGINT,
user_name STRING,
order_status STRING,
total_amount DOUBLE,
create_time TIMESTAMP(3),
PRIMARY KEY (order_id) NOT ENFORCED
) WITH (
'connector' = 'elasticsearch-7',
'hosts' = 'http://es01:9200',
'index' = 'orders',
'username' = 'elastic',
'password' = 'xxxx',
'bulk.flush.max.actions' = '1000',
'bulk.flush.max.size.mb' = '5',
'bulk.flush.interval.ms' = '1000',
'sink.delivery-guarantee' = 'at-least-once',
'format' = 'json'
);
INSERT INTO es_orders
SELECT order_id, user_name, order_status, total_amount, create_time
FROM orders;
SQL 方案最大的优点是简单清晰,建表语句即文档,团队里其他人接手也容易。但它有一个明显的短板:无法灵活处理 Debezium 的变更消息类型,尤其是 DELETE 操作。Flink SQL 的 ES Sink 在收到 DELETE 事件时,依赖表定义中的 PRIMARY KEY 来匹配文档,如果业务上有特殊需求——比如某些字段在更新前后要做额外加工——SQL 写起来就比较别扭,最后还是得落到 DataStream API 上。
3.2 Source 端:构建 FlinkCDC 数据源
我的生产任务是用 DataStream API 写的,因为它灵活可控,排查问题也容易。构建 FlinkCDC Source 的核心代码大致是这样的:
java复制import com.ververica.cdc.connectors.mysql.source.MySqlSource;
import com.ververica.cdc.debezium.JsonDebeziumDeserializationSchema;
import org.apache.flink.streaming.api.environment.StreamExecutionEnvironment;
import org.apache.flink.streaming.api.datastream.DataStreamSource;
import org.apache.flink.api.common.eventtime.WatermarkStrategy;
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
// 启动 Checkpoint,保证任务故障恢复后不会丢数据
env.enableCheckpointing(60_000);
env.getCheckpointConfig().setCheckpointStorage("file:///data/flink/checkpoints");
env.setParallelism(1);
MySqlSource<String> source = MySqlSource.<String>builder()
.hostname("127.0.0.1")
.port(3306)
.databaseList("shop")
.tableList("shop.t_order")
.username("cdc_user")
.password("Password123!")
.serverTimeZone("Asia/Shanghai")
.startupOptions(StartupOptions.initial())
.deserializer(new JsonDebeziumDeserializationSchema())
.build();
DataStreamSource<String> stream =
env.fromSource(source, WatermarkStrategy.noWatermarks(), "mysql-cdc-source");
几个细节我强调一下。databaseList 和 tableList 一定要写清楚,tableList 的格式是“库名.表名”。如果是多表同步,可以用逗号分隔;如果某个表不想全量同步,只同步部分字段,可以在下游做过滤,不要在 source 端硬设置。startupOptions(StartupOptions.initial()) 的含义是任务启动时先做一次全量快照,再自动切换到增量模式,这个模式最适合首次上线。
关于并行度,我这里 env.setParallelism(1) 其实是个保守做法。FlinkCDC 2.4 之后虽然支持 source 并行读取,但多并行度对 MySQL 的 Binlog 位点管理要求更高,如果业务量没有大到单并行度处理不过来,保持 1 是最稳的。真正需要扩展时,瓶颈往往在 ES 写入端,可以单独给 Sink 设置更高的并行度。
3.3 数据转换:理解 Debezium 消息结构
FlinkCDC 默认的反序列化器 JsonDebeziumDeserializationSchema 会把 Binlog 变更事件转换成 JSON 字符串,格式大致如下:
json复制{
"before": null,
"after": {
"order_id": 1001,
"user_name": "张三",
"order_status": "PAID",
"total_amount": 299.00,
"create_time": "2024-01-15 10:00:00"
},
"op": "c"
}
其中 op 字段是关键:c 表示新增,u 表示更新,d 表示删除,r 表示初始化快照阶段读取到的一行数据。在写 Map 函数时,必须根据 op 类型做分支处理,尤其是 DELETE 操作。如果直接拿 after 里的字段覆盖写入 ES,删除操作就会变成“往 ES 里塞了一条值全为 null 的文档”,这是新手最容易踩的坑之一。
我的转换函数大概是这样的逻辑:当 op 为 d 时,只从 before 节点里取主键字段,构造一个只有 order_id 的 ES 文档,并标记为删除;其他操作则从 after 节点读取数据,映射到业务对象。由于 before 在删除事件中同样包含完整旧值,也可以用来记录日志,方便排查。
3.4 Sink 端:ES 幂等写入的关键配置
ES Sink 的配置直接决定了写入性能和数据的正确性。我的生产配置如下:
java复制import org.apache.flink.streaming.connectors.elasticsearch7.ElasticsearchSink;
import org.apache.http.HttpHost;
ElasticsearchSink<OrderInfo> sink = ElasticsearchSink.<OrderInfo>builder()
.setHosts(new HttpHost("es01", 9200, "http"))
.setBulkFlushMaxActions(1000)
.setBulkFlushMaxSizeMb(5)
.setBulkFlushInterval(1000)
.setRestClientFactory(restClientBuilder -> {
restClientBuilder.setHttpClientConfigCallback(httpClientBuilder -> {
// 如果 ES 开启了安全认证,在这里设置账号密码
return httpClientBuilder;
});
})
.build();
setBulkFlushMaxActions(1000) 表示攒够 1000 条数据执行一次批量写入;setBulkFlushMaxSizeMb(5) 表示批量数据达到 5 MB 也触发写入;setBulkFlushInterval(1000) 表示即使数据量没达到阈值,最多等 1 秒也会强制 flush。这三个参数是典型的“攒批”策略,平衡实时性和写入压力。如果你的 ES 集群写入压力大,可以把 setBulkFlushMaxActions 调低到 500,避免超大 bulk 请求拖垮集群。
但比攒批更关键的是一件事:必须指定文档 ID。ES Connector 在写入时,如果数据中没有显式指定 _id,ES 会自动生成随机 ID,带来的后果是同一业务主键的数据在重复消费或任务重跑时会产生重复文档。指定 _id 的方法是在构建 IndexRequest 时调用 .id(orderId),比如:
java复制IndexRequest indexRequest = new IndexRequest("orders")
.id(String.valueOf(orderInfo.getOrderId()))
.source(mapper.writeValueAsBytes(orderInfo), XContentType.JSON);
这样 ES 的写入语义就从“新增”变成了“upsert”,同一 order_id 的数据重复写入时,后者直接覆盖前者,既保证了最终一致,也避免了重复文档。这一点在生产环境是铁律,应用在从 Checkpoint 恢复的场景下尤为重要。
4. 增量与存量衔接:最容易被忽略的数据一致性细节
4.1 StartupOptions 的几种启动模式怎么选
FlinkCDC 提供的启动模式不止一种,选错会直接影响同步结果。我把几种模式的应用场景整理一下:
| 启动模式 | 行为 | 适用场景 |
|---|---|---|
initial() |
先做全量快照,再无缝切换增量 | 首次上线、存量数据大 |
latest() |
只从当前 Binlog 最新位点开始监听 | 只关心新增数据 |
timestamp(long) |
从指定时间点开始读取 Binlog | 需要恢复某段时间的数据 |
specificOffset(String) |
从指定的 Binlog 文件 + 位点开始 | 精确跳过某段数据 |
大部分场景直接用 initial() 即可,它内部会自动处理全量和增量的衔接。但要注意一个潜在问题:如果全量快照阶段数据量很大,任务运行时间很长,MySQL 的 Binlog 过期时间设置太短,会导致快照结束时增量位点已经被清理,任务直接报错。所以我前面配置了 expire_logs_days = 7,正常情况下全量快照不会跑超过 7 天,这个时间窗口是够用的。
4.2 验证双写过程的三个检查点
全量加增量衔接是否成功,不能光看任务状态是 RUNNING,还要主动做数据校验。我上线时的检查步骤很简单,但非常有效。
第一步,全量阶段结束后,对比 MySQL 和 ES 的文档数量。这里要小心 ES 的 count API 有近实时性,刚写入的数据可能有短暂延迟,所以对比时最好在同步任务运行稳定几分钟后再查。
第二步,在 MySQL 里手工执行一次 UPDATE,把某条订单的状态从“已支付”改成“已发货”,然后在 ES 里查询这条订单,确认状态字段是“已发货”。这一步验证的是增量更新链路。
第三步,测试 DELETE 操作。删掉一条测试订单,确认 ES 里对应文档也被删除。如果发现删除不同步,大概率是转换函数没有处理 op = "d" 的情况,回到上一节的逻辑检查。
这三个检查点全部通过后,我才会放心地把任务正式挂到生产环境。实际上这个习惯帮我避开过一个大问题——有一段时间我发现 ES 里订单状态一直是旧值,检查了很久才发现是全量阶段写入后,增量阶段的 UPDATE 因为字段名不匹配被丢弃了,ES mapping 里 order_status 在写入时被 JSON 序列化成 orderStatus,最后统一字段名才解决。
5. 上线后的踩坑清单:从时区偏移到磁盘爆满
5.1 时区问题导致 ES 中的时间少了 8 小时
上线第一天我就遇到了一个诡异的 Bug:MySQL 里创建的订单时间是下午两点,ES 里查出来却是早上六点,整整少了 8 个小时。排查后确认是时区配置不一致的问题。MySQL 连接串里如果使用默认时区,或者 FlinkCDC 没有显式配置 serverTimeZone,时间类型会被当成 UTC 时间处理,而 ES 默认显示的是本地时区,于是出现了偏差。
解决方法是在 FlinkCDC 构建 Source 时显式指定时区:
java复制.serverTimeZone("Asia/Shanghai")
同时,MySQL 连接参数里也要加上 serverTimezone=Asia/Shanghai。如果业务上对时间精度要求高,更稳妥的做法是在 ES mapping 中直接用 epoch_millis 存储时间戳,这样彻底绕开时区解析的歧义。我在后续的新索引中全面改用了时间戳存储,时间字段只用于排序和范围过滤,展示层再格式化成本地时间,再也没有出过时区问题。
5.2 任务重启后重复消费,ES 出现脏数据
Flink 的 Checkpoint 保证了故障恢复后不会丢数据,但默认的 ES Sink 语义是“至少一次”,这意味着重复消费时同一批数据可能被写入多次。当时我没有在索引请求里指定 _id,结果任务因为网络抖动重启后,ES 里出现了一批内容相同但 _id 不同的重复文档。
这个问题的根源正是前面强调的“必须指定文档 ID”。只要 _id 是业务主键,ES 的写入就变成了覆盖操作,重复消费多少次最终结果都一样。如果你用的是 Flink SQL 版本,注意建表语句里声明 PRIMARY KEY (order_id) NOT ENFORCED,这个声明就是告诉 ES Sink 用 order_id 作为文档 ID。
恢复重复数据的方式也很简单:删掉 ES 索引里所有文档,任务从最近 Checkpoint 恢复,或者如果数据量不大,直接全量重新同步一次。关键是清理完后立刻检查任务配置,不要把在线恢复当成常态,治本才是正事。
5.3 Binlog 日志把磁盘写到告警
运营一段时间后,我收到了服务器磁盘空间告警。登录上去看了一下,MySQL 的 Binlog 占了几十 GB。原因是 expire_logs_days 虽然设置了 7 天,但业务高峰期写入量大,单日 Binlog 增量就能吃掉大量磁盘。而 FlinkCDC 的消费位点如果因为某种原因落后很多,MySQL 也不会主动清理“还在被消费”的日志,双管齐下,磁盘直接爆掉。
处理方式分两步:短期扩容磁盘并手动清理掉已经确认消费完的旧 Binlog,长期则要监控 Binlog 生成速率和 Flink 消费位点的差值。我后来写了一个定时脚本,每小时对比一次 MySQL 当前的 Binlog 位点和 Flink 任务消费到的位点,差值超过阈值就告警。这样即使下游任务挂了,也能在磁盘爆满前发现并处理。
5.4 ES 集群写入超时导致作业失败
随着订单量增长,有一段时间 ES 集群频繁出现写入超时,Flink 任务每隔一两天就失败一次。从 ES 侧看,bulk 队列积压严重,部分节点 CPU 达到 90% 以上。从 Flink 侧看,重试几次后直接会导致任务重启,重启后又会从 Checkpoint 恢复大量数据,形成恶性循环。
排查后确认是我们的 bulk 参数设置得太激进。当时为了追求吞吐,把 setBulkFlushMaxActions 调到了 5000,一次批量请求的体量太大,ES 节点承受不住。把参数回调到 1000,同时把 setBulkFlushInterval 降到 500 毫秒,让数据更均匀地流入 ES,任务就稳定了。这里的原则是:ES 写入的瓶颈通常在集群内部,而不是 Flink 攒批的速度,参数设置要留足余量,不要试图压榨到极限。
6. 运维与进阶:同步链路稳定运行的关键保障
6.1 监控位点与延迟
任务上线只是起点,长期稳定运行靠的是监控。除了 Flink WebUI 自带的 Checkpoint 监控外,我重点盯下面几个指标:
- Checkpoint 完成时间:如果一次 Checkpoint 耗时超过几分钟,说明状态太大或并行度不足,需要考虑优化。
- Source 端读取位点:通过 Flink WebUI 的 Source 算子信息,可以看到当前读取到的 Binlog 位置。
- MySQL Binlog 位点:对比数据库侧当前最新位点和任务消费位点,差值越大说明同步延迟越高。
延迟问题最常见的诱因是 ES 写入变慢导致背压,Backpressure 会传导到 Source 端,让 Binlog 消费速度放慢。如果发现延迟持续走高,优先检查 ES 集群写入性能,而不是 FLink 任务本身。
6.2 多表、分库场景的处理思路
订单同步跑稳定后,我又接了一个新的需求:把用户表和订单明细表也同步到 ES,而且用户表的更新频率很低,订单明细表的数据量比订单表还大一个量级。多表同步在 FlinkCDC 里的处理方式是 databaseList("shop") 加 tableList("shop.t_order,shop.t_user,shop.t_order_item")。但要注意,把多张表放进同一个 Source 时,通过 op 字段的 source.table 信息可以做路由,下游根据表名决定写哪个 ES 索引。
分库分表场景我也实际测试过,FlinkCDC 支持 databaseList("shop_*") 这样的正则匹配。同步到 ES 时,如果分库分表后的主键在全局不唯一(比如不同库的订单 ID 都是 1001),一定要在拼接 ES 文档 ID 时加上库表信息,否则不同来源的数据会互相覆盖。
6.3 后续可以扩展的方向
这套链路跑顺之后,可以扩展的方向还不少。比如 ES 里只存需要查询的索引字段,把 MySQL 作为唯一数据源,ES 只承担查询加速的职责,这个架构模型可以推广到很多业务场景。另外 Flink 也支持把同一份 CDC 数据同时写入多个下游,比如一份写 ES 供搜索,一份写 Kafka 供其他系统消费,只要在 stream 上做几个分支即可,不需要重复监听 Binlog,对 MySQL 的压力是一样的。
我个人在实际操作中还有一个很深的体会:不要试图把所有字段都同步到 ES。ES 是查询层,不是存储层,字段越多 mapping 越复杂,索引体积越大,写入性能和查询性能都会受影响。我通常只同步查询条件、排序字段和展示列表所需的字段,剩下的数据需要时再回 MySQL 查,这样 ES 集群的负担能降下来不少。
