你可能想不到,让我彻底把 Flink MySQL Source 搞明白的,不是哪本书,也不是什么源码课,而是凌晨两点的一次线上数据对账。下游报表缺了整整两小时的数据,定位半天,发现同步任务里每个并行子任务都在读同一张 MySQL 表——重复读、重复写,数据不丢才怪。那一刻我才意识到,网上很多“复制就能用”的 Flink 读 MySQL 代码,真放到生产环境,考验的是你对并行度、连接生命周期、状态恢复这些机制的理解有多深。
这篇内容就围绕“Flink MySQL source 自定义开发步骤”展开,从 API 选型、基础实现、并行分片、状态恢复到生产排错,把一条完整的开发链路捋一遍。适合几类人看:一是项目里需要从 MySQL 拉数据到 Flink,又不想引入太重依赖的;二是发现内置 JDBC 连接器不够灵活,想自己控制读取逻辑的;三是想理解 Source 机制,准备面试或做技术沉淀的。不管你是刚接触 Flink,还是已经在生产环境写过 DataStream 作业,这篇都能给你一些可落地的参考。
1. 为什么你会走到自己写 MySQL Source 这一步
1.1 自带连接器的能力边界
很多第一次接触 Flink 的同学会问:Flink 不是有 JDBC 连接器吗?为什么还要自己写 Source?
有,但你要分清它到底解决什么问题。Flink 官方提供的 JdbcDynamicTableSource 和 JdbcInputFormat,本质上更适合“批式查询”或“Table API/SQL 场景下的维表 Join 与一次性导入”。它确实能读 MySQL 表,也能做简单的谓词下推,但它不是一个面向大数据量、带断点续读能力、支持灵活并行分片的通用数据源。
举个例子,你用 JdbcInputFormat 读一张千万级表,默认情况下它是拿一个连接从头读到尾,想并行?可以,但你要手动切分 where 条件,还需要自己管理 task 的并发与数据分片之间的对应关系。而且一旦 job 重启,它不会自动记录“我已经读到哪了”,需要你额外处理偏移量。更别提当你想在读取过程中做状态快照、保证数据不丢不重时,这套连接器几乎没有现成方案。
所以说,内置连接器像一把折叠刀,切纸、开瓶盖都没问题,但你要在一张大桌上把一整根圆木锯成均匀几段,它就不太顺手了。这时候,“自定义 Source”就成了绕不开的路。
1.2 写或不写,先做取舍
写自定义 Source 之前,我建议你先问自己几个问题,别为了炫技而重复造轮子。
| 场景 | 建议方案 |
|---|---|
| 数据量小(万级以内)、一次性导入、无需断点 | 直接用 JdbcInputFormat 或 Table API 的 JDBC 连接器 |
| 需要全量+增量、监听 binlog、精确感知数据变更 | 优先考虑 Flink CDC,而不是自定义 Source |
| 只读存量快照、需要并行分片、需要自管理偏移量 | 自定义 Source 是合理选择 |
| 内部小表定期同步、频率不高、能容忍延迟 | 简单轮询 + 自定义 Source 也可,但要控制查询压力 |
| 下游有幂等保障,可以容忍重复数据 | 自定义 Source 的“至少一次”语义足够用 |
我在实际项目里见过一种典型情况:数据仓库需要把一个订单表全量拉出来,每天一次,但表很大(几亿行),MySQL 实例又是从库,主键自增且连续。这种场景用 Flink CDC 太重,因为 CDC 要监听 binlog、维护位点;用内置 JDBC 连接器又不方便做并行分片;最后只能自己写一个 Source,按主键范围把数据切成多个分片并行读取。
记住一句话:自定义 Source 是解决问题的工具,不是目的。只有当你明确知道内置方案满足不了并行、状态恢复、自定义查询逻辑这些需求时,才值得动手。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义 Source 的第一步:选对 API 接口
2.1 常用接口的取舍
Flink 的 Source 接口经过几代演进,新手很容易搞混。我遇到过不少人把 SourceFunction 和 RichParallelSourceFunction 混着用,看着编译没问题,跑起来才发现并行度根本不起作用。
先厘清这几个接口:
| 接口 | 特点 | 并行度 |
|---|---|---|
SourceFunction<T> |
最基础的 Source,run() 方法里往外发数据 |
只能并行度为 1 |
RichSourceFunction<T> |
在 SourceFunction 基础上增加 open/close 等生命周期方法 |
只能并行度为 1 |
ParallelSourceFunction<T> |
支持并行执行 | 可以大于 1 |
RichParallelSourceFunction<T> |
同时具备生命周期方法和并行能力 | 可以大于 1 |
从 MySQL 读取的场景,几乎一定是 RichParallelSourceFunction。原因很直接:我们需要在 open() 里初始化 JDBC 连接,在 close() 里释放连接,还需要通过 getRuntimeContext() 拿到当前子任务的索引,这样才能实现分片读取。
如果你选了 SourceFunction,就算 addSource(...).setParallelism(4),实际也只有一个 task 在跑,数据量一大就是单点瓶颈,毫无扩展性。
2.2 新的 Source API 与旧接口怎么选
Flink 1.12 之后推出了新的 Source API(org.apache.flink.api.connector.source.Source),像 FileSource、KafkaSource 都是基于新 API 实现的。新 API 在拆分枚举、检查点恢复、事件时间对齐等方面设计更完善,是官方主推的方向。但现实是,社区里大量现存项目还是基于 SourceFunction 写的,很多线上任务至今跑在 Flink 1.13、1.14 上,SourceFunction 还依然可用。
我的建议是:
- 如果你是全新项目,且 Flink 版本 >= 1.14,优先尝试新 Source API,未来升级更平滑。
- 如果团队里有现成的
RichParallelSourceFunction代码,或者你只是做个内部同步工具,用旧接口完全没问题,不用为了新而新。 - 如果你需要快速验证“从 MySQL 读数据”的逻辑,用
RichParallelSourceFunction更简单直接,代码量更少。
从思维层面看,新旧 API 的本质是一样的:都是“一个 task 对应一个分片,不断往外发记录,配合 checkpoint 记录读取进度”。理解了这一点,你写哪种接口都只是语法差异。
2.3 你需不需要运行时上下文
判断该用哪个接口,还有一个很实用的标准:你需不需要访问运行时上下文?
如果只是从一个固定 SQL 读数据、不需要知道当前是第几个子任务、不需要在 open/close 中管理资源,那用最简单的 SourceFunction 就够了。但在 MySQL 场景下,我们需要知道当前 task 的索引来做分片,需要注册状态来记录偏移量,需要访问 checkpoint 相关上下文,这些能力全部来自 RuntimeContext。所以最终选择基本是确定的:RichParallelSourceFunction。
3. 从零到能跑:一个基础版 MySQL Source 的完整实现
3.1 代码实现
先给一个可以直接跑通的基础版本。这个版本只做一件事:从 MySQL 查一张表或一条 SQL,把每行数据转成一个 RowData 对象发往下游。
我用 RowData 做示例,但在简单场景下你也可以直接转成 JSONObject、String 或自定义 POJO。这里先用一个通用的行对象:
java复制import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.source.RichParallelSourceFunction;
import org.apache.flink.types.Row;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.Properties;
public class MysqlSource extends RichParallelSourceFunction<Row> {
private volatile boolean running = true;
private Connection connection;
private PreparedStatement preparedStatement;
private ResultSet resultSet;
private final String url;
private final String username;
private final String password;
private final String query;
public MysqlSource(String url, String username, String password, String query) {
this.url = url;
this.username = username;
this.password = password;
this.query = query;
}
@Override
public void open(Configuration parameters) throws Exception {
super.open(parameters);
Class.forName("com.mysql.cj.jdbc.Driver");
Properties props = new Properties();
props.setProperty("user", username);
props.setProperty("password", password);
props.setProperty("useSSL", "false");
props.setProperty("serverTimezone", "Asia/Shanghai");
props.setProperty("useCursorFetch", "true");
props.setProperty("defaultFetchSize", "1000");
connection = DriverManager.getConnection(url, props);
connection.setAutoCommit(false);
connection.setReadOnly(true);
preparedStatement = connection.prepareStatement(
query,
ResultSet.TYPE_FORWARD_ONLY,
ResultSet.CONCUR_READ_ONLY
);
preparedStatement.setFetchSize(1000);
}
@Override
public void run(SourceContext<Row> ctx) throws Exception {
resultSet = preparedStatement.executeQuery();
int columnCount = resultSet.getMetaData().getColumnCount();
while (running && resultSet.next()) {
Row row = new Row(columnCount);
for (int i = 1; i <= columnCount; i++) {
row.setField(i - 1, resultSet.getObject(i));
}
// 这里不是线程安全的,需要用 synchronized
synchronized (ctx.getCheckpointLock()) {
ctx.collect(row);
}
}
}
@Override
public void cancel() {
running = false;
if (resultSet != null) {
try {
resultSet.close();
} catch (Exception ignored) {
}
}
if (preparedStatement != null) {
try {
preparedStatement.cancel();
} catch (Exception ignored) {
}
}
}
@Override
public void close() throws Exception {
if (resultSet != null) {
resultSet.close();
}
if (preparedStatement != null) {
preparedStatement.close();
}
if (connection != null) {
connection.close();
}
}
}
这段代码的核心逻辑在 run():执行查询,逐行读取结果集,通过 ctx.collect() 发往下游。cancel() 负责中断,close() 负责释放资源,open() 负责初始化连接和查询语句。
3.2 代码里那些容易被忽略的细节
这段代码有几个细节,直接影响你能不能在生产环境用起来。
第一,connection.setAutoCommit(false) 配合 connection.setReadOnly(true) 非常关键。很多人在写 JDBC 流式读取时发现 fetchSize 设置了但没效果,MySQL 照样一次性把全表加载到客户端内存里。原因是:MySQL 的流式返回需要连接处于事务内,并且游标是只读、向前移动的。如果你漏了 setAutoCommit(false),驱动会默默走默认的全量拉取路径,几百万行数据直接吃满 TaskManager 内存。
第二,ResultSet.TYPE_FORWARD_ONLY 和 ResultSet.CONCUR_READ_ONLY 不是随便写的。流式读取必须用 forward-only 游标,如果你设成 TYPE_SCROLL_INSENSITIVE,驱动会为了支持随机跳转而把结果集全部放内存。这个坑很隐蔽,因为小数据量下根本看不出来,等数据量一大就是 OOM。
第三,ctx.getCheckpointLock() 的加锁不能丢。SourceContext.collect() 在检查点触发时,需要保证“正在发送的数据”和“状态快照”是原子操作。如果不加锁,极端情况下会把同一批数据既发送出去又没记录偏移量,导致重启后重复或丢失。这是 Flink 官方明确建议的写法。
第四,也是很多人忽略的:cancel() 方法并不是真正中断线程的地方。当 job 取消时,Flink 会调 cancel(),但这时候可能正阻塞在 resultSet.next() 上,running = false 并不足以让循环立刻退出。所以我的做法是在 cancel() 里把 resultSet 和 preparedStatement 也一起关闭,让阻塞的读取立刻抛异常,从 run() 中退出。
3.3 本地跑通一个最小任务
写完 Source 之后,先别急着部署到集群,本地跑通验证逻辑。最简单的做法是在 main 方法里直接用 StreamExecutionEnvironment:
java复制public static void main(String[] args) throws Exception {
StreamExecutionEnvironment env = StreamExecutionEnvironment.getExecutionEnvironment();
env.setParallelism(1);
String sql = "SELECT id, name, create_time FROM my_table";
DataStreamSource<Row> source = env.addSource(
new MysqlSource("jdbc:mysql://127.0.0.1:3306/test", "root", "123456", sql)
);
source.print();
env.execute("mysql-source-test");
}
这里先用 setParallelism(1) 跑通最简单路径,确保 SQL、驱动、网络都没问题,再慢慢往上加并行度。如果报错,优先检查:
- 驱动是否在 classpath 里,
com.mysql.cj.jdbc.Driver是 MySQL 8.x 的驱动类,MySQL 5.x 需要换成com.mysql.jdbc.Driver或升级驱动版本; - MySQL 是否允许当前 IP 连接;
- SQL 是否有语法错误,表名和字段名是否存在。
4. 并行分片与状态恢复:让 Source 扛住生产压力
4.1 并行分片的两种主流方式
基础版只能单 task 读,数据量大时必须并行。MySQL Source 的并行分片,本质上就是“把一张表的数据按照某种规则切分成多个互不重叠的子集,分别给不同的 task 去读”。
最常见的两种分片方式:
基于整数主键取模
如果表的主键是自增的 BIGINT,最简单的方式是让每个 task 只读主键对并行度取模后等于自己编号的那些行。
sql复制SELECT * FROM my_table WHERE MOD(id, ?) = ?
这种方式写起来简单,但要注意:如果主键不是连续分布(删除过大量行),某些 task 可能读到很少数据,某些 task 读到很多,导致数据倾斜。
基于主键范围分片
先查主键的 MIN(id) 和 MAX(id),再把 [min, max] 区间按并行度切成若干段,每个 task 读一段。
sql复制SELECT * FROM my_table WHERE id >= ? AND id < ?
这种方式对连续自增主键效果最好,数据分布均匀,查询也能走主键索引。但前提是主键类型必须是数值,最好是无符号 BIGINT。如果主键是字符串或者 int 有溢出风险,要提前评估。
4.2 代码改造要点
有了 RichParallelSourceFunction,拿到子任务编号很简单:
java复制int index = getRuntimeContext().getIndexOfThisSubtask();
int parallelism = getRuntimeContext().getNumberOfParallelSubtasks();
然后在 open() 里根据这个编号拼接 SQL,或者设置 PreparedStatement 的参数。比如用取模方式:
java复制String sql = "SELECT * FROM my_table WHERE MOD(id, ?) = ?";
preparedStatement = connection.prepareStatement(sql);
preparedStatement.setInt(1, parallelism);
preparedStatement.setInt(2, index);
如果是范围分片,一般会在 open() 里先执行一条 SELECT MIN(id), MAX(id) FROM my_table,再根据当前 index 算出当前 task 的 [startId, endId) 范围。
这里有一个很实际的经验:分片前先确认主键是数值型且非空。如果主键不是数值,用 MD5(id) 取模也是可以的,但全表会扫一遍,性能很差。如果实在没有合适的分片键,可以考虑按时间字段分片,比如 WHERE create_time >= ? AND create_time < ?,但这种方式更适合时间维度增量读取,后面会讲到。
4.3 状态恢复怎么做
自定义 Source 在生产环境绕不开一个问题:job 重启后,怎么知道该从哪继续读?如果不做状态恢复,全量同步任务每次重启都会从头读一遍,白白增加 MySQL 压力。
对于全量同步场景,最实用的做法是:记录“每个分片已经读到的主键最大值”,checkpoint 时把它存到 Flink 状态里,恢复时从这个值继续往后查。
思路如下:
- 实现
CheckpointedFunction接口,用ListState<Long>保存每个并行子任务已经读到的最大主键。 - 每次
ctx.collect()之后,更新当前内存里记录的最大 ID。 - 在
snapshotState()中把当前最大 ID 存入状态。 - 在
initializeState()中恢复这个值,如果非空,则用WHERE id > lastId重新开始。
代码骨架可以写成这样:
java复制public class MysqlSource extends RichParallelSourceFunction<Row>
implements CheckpointedFunction {
private transient ListState<Long> offsetState;
private long currentMaxId = 0L;
@Override
public void run(SourceContext<Row> ctx) throws Exception {
String sql = currentMaxId > 0
? "SELECT * FROM my_table WHERE id > ? ORDER BY id"
: "SELECT * FROM my_table ORDER BY id";
preparedStatement = connection.prepareStatement(sql);
if (currentMaxId > 0) {
preparedStatement.setLong(1, currentMaxId);
}
resultSet = preparedStatement.executeQuery();
while (running && resultSet.next()) {
long id = resultSet.getLong("id");
Row row = ...; // 组装数据
synchronized (ctx.getCheckpointLock()) {
ctx.collect(row);
currentMaxId = id;
}
}
}
@Override
public void snapshotState(FunctionSnapshotContext context) throws Exception {
offsetState.clear();
offsetState.add(currentMaxId);
}
@Override
public void initializeState(FunctionInitializationContext context) throws Exception {
ListStateDescriptor<Long> descriptor =
new ListStateDescriptor<>("mysql-offset", Long.class);
offsetState = context.getOperatorStateStore().getListState(descriptor);
if (context.isRestored()) {
for (Long value : offsetState.get()) {
currentMaxId = value;
}
}
}
}
注意,这种恢复方式有个前提:表的数据是只追加、不更新、不删除的。如果中间有更新操作把 id 对应的记录改了,或者删除了一些记录,这个方案会漏数据。这是所有基于主键增量的 Source 的天然局限,要清楚认识。
4.4 做一个清醒的判断:Source 很难做到真正的精确一次
很多朋友问:自定义 Source 能做到精确一次(Exactly-Once)吗?
在不借助外部系统事务、不读取 binlog 的前提下,光靠主键偏移量很难做到真正的精确一次。你从 MySQL 读到一条数据,可能在下游已经过了 Kafka、做了计算、写到了目标表,这时候 MySQL 数据源突然挂了,你恢复后从偏移量继续读,那“已经处理成功但还没保存偏移量”和“已经保存偏移量但实际还没发完”的数据,就可能出现重复或丢失。
所以生产环境最务实的组合是:自定义 Source 提供“至少一次(At-Least-Once)语义”,下游消费者自己保证幂等。写目标表时用主键去重,或者用日志表记录处理位点,这样即使 Source 偶发重复,整体链路依然能保证最终一致。
我自己踩过这个坑。早期项目里设计了一个自定义 Source,想尽办法把偏移量做得特别“精确”,结果代码复杂度翻倍,还引入了各种边界问题。后来把重心转移到下游幂等上,Source 只负责“尽量不丢、尽量从断点续读”,整个链路反而稳定很多。
5. 增量同步的正确姿势:自定义 Source 与 Flink CDC 如何取舍
5.1 自定义 Source 做增量同步的痛点
很多人写自定义 MySQL Source,最初目的是想做增量同步。常见做法是轮询查表,按 update_time 或 create_time 过滤最近几分钟的数据。
这个方案在数据量小、对实时性要求不高时能跑,但代价很大:
- 频繁轮询会给 MySQL 增加查询压力,尤其当表有几百 GB 数据时,每次
WHERE update_time > ?都可能触发全表扫描; - 时间字段有延迟或精度问题,比如数据库里只精确到秒,同一秒内的多条更新可能被漏掉;
- 完全无法感知删除操作,用户删了一行,你的同步任务毫无感觉;
- 如果业务表结构变了,比如加了一个字段,你的 SQL 和下游 schema 都要跟着改,维护成本直线上升。
这些痛点说明了一个事实:MySQL 增量同步的本质是“读取数据库变更日志”,而不是“反复执行 SELECT 语句”。用轮询的方式去模拟日志监听,属于绕远路。
5.2 为什么 Flink CDC 成为事实标准
Flink CDC 做的事情,是把自己伪装成 MySQL 的一个从库副本,通过 MySQL 的 binlog 协议接收所有数据变更事件。它天然能感知插入、更新、删除,数据延迟可以做到秒级甚至毫秒级,而且不会因为频繁查询给源库带来额外压力。
我以前也自己写过一个轮询式增量 Source,后来项目转向 Flink CDC,代码量直接少了一半。对于绝大多数“从 MySQL 同步到消息队列/数仓/其他存储”的场景,Flink CDC 基本是默认答案。
它的核心优势:
- 基于 binlog,增量无侵入,不额外产生查询压力;
- 精确感知每一行数据的 insert/update/delete 事件;
- 自带 checkpoint 位点管理,崩溃恢复非常成熟;
- 支持全量+增量一体化(initial 模式),首次启动先做快照,再从快照位点继续监听增量,不需要你手动切换两套逻辑。
5.3 我的推荐组合:全量快照 + CDC 增量
那是不是有了 CDC,自定义 Source 就该被淘汰了?
并不是。我在实际项目里常用的组合是:初次启动或每天定时,用自定义 Source 做一次全量快照;长期运行,用 Flink CDC 做增量监听。这种组合适合那种“实时同步非常重要、但也不能一天跑一次全量”的偏数仓场景。
为什么不全靠 CDC?因为 CDC 首次启动的全量阶段,如果表特别大,binlog 保留时长又不够,比较容易出现位点过期问题。此时先用自定义 Source 做存量迁移,再切到 CDC 做增量,可以降低失败风险。还有一些内部系统表,没有开启 binlog,或者 DBA 不允许你拉取 binlog 日志,这时自定义 Source 就成了唯一选择。
所以我的建议是:不要非此即彼。自定义 Source 和 Flink CDC 是互补关系,而不是替代关系。你团队的真实需求决定了用哪个,甚至两者一起用也很常见。
6. 那些年踩过的坑:MySQL Source 生产环境的排错记录
6.1 连接与驱动层踩坑清单
我在不同项目里排查过很多诡异问题,绝大多数都能归到以下几个根因。
| 异常特征 | 根因 | 解决方式 |
|---|---|---|
启动报 ClassNotFoundException: com.mysql.cj.jdbc.Driver |
驱动没打进作业 jar 或版本不匹配 | 确认 mysql-connector-java 依赖生效,打包时把驱动带进去 |
| 大表读取导致 TaskManager OOM | fetchSize 没生效 |
设置 useCursorFetch=true、autoCommit=false、readOnly=true,三者缺一不可 |
读到的 TIMESTAMP 与数据库不一致 |
时区配置不正确 | JDBC URL 明确写 serverTimezone=Asia/Shanghai,不要依赖默认值 |
| MySQL 实例连接数被打爆 | 每个 task 建多个连接 | 并行度不要设太高,尽量复用连接,或使用连接池 |
| 长时间不返回数据、一直阻塞 | cancel() 没有真正中断 JDBC 阻塞 |
cancel() 同时关闭 ResultSet 和 Statement,让阻塞读取中断 |
| checkpoint 一直不完成,任务卡死 | Source 长时间不触发 checkpoint barrier | 降低单批次读取量,或调大 checkpoint 间隔 |
| 增量阶段发现漏数据 | 轮询 update_time 精度不够或删除数据无法感知 |
改用 CDC 监听 binlog,或接受“最终一致”方案 |
这些坑每一条背后都有真实事故。我尤其要强调第二条,很多新人在本地测试时因为数据量小感觉不到,一上生产就出问题。useCursorFetch=true 是 MySQL Connector/J 特有的属性,用来开启服务端游标,一旦开启,fetchSize 才真正生效。再加上事务内只读游标的限制,这三行配置缺一不可。
6.2 一次 checkpoint 卡死的完整排查链路
分享一次典型的排查经历。当时一个同步任务每隔几分钟就会把 checkpoint 卡住,日志里一直出现:
code复制Checkpoint ... expired before completing.
一开始我以为是下游压力大,结果看了 Metrics,发现 checkpoint 的瓶颈在 Source 端,Source 一个子任务处理耗时特别高。用 jstack 拉线程栈,看到 task 线程卡在 socketRead 上,等 MySQL 返回数据。
进一步排查,发现这个任务并行度是 8,但每个 task 读的数据量差异巨大,有的 task 已经跑完,有的 task 还在扫全表。原因是:SQL 里用了 WHERE MOD(id, ?) = ? 分片,但表里有大量按 id 范围批量插入的数据,导致某些 id 段特别密集,取模后落到同一个 task 上。
这个问题的修复路径很清楚:换成分片方式,从“主键取模”改成“主键范围分片”。先执行 SELECT MIN(id), MAX(id) FROM table,再按 range 均分。这样每个 task 查询的 id 区间基本等长,数据量天然均匀。
这个案例给我们的启发是:先定位瓶颈在哪个 operator,再判断是查询问题还是数据倾斜问题,不要一上来就调并行度或加资源。很多时候加并行度反而加剧了倾斜。
6.3 Source 正确性的验证方法
写完自定义 Source,除了用 print() 看输出对不对,还要有结构化的测试手段。我常用的验证方式:
- 用临时表做语法验证:在本地 MySQL 建一张只有几万行的表,先跑通基础查询逻辑;
- 用
CollectSink收集结果:在测试中把 Source 接到一个自定义的SinkFunction,把所有输出收集到本地集合,再断言数据的条数和内容; - 构造分片测试:设置并行度为 4,分别校验 4 个 task 读到的数据是否互不重叠、合起来是否等于全表;
- 模拟 failover 测试:在读取过程中手动取消 task,重启之后验证是否能从断点续读;
- 压测:用
sysbench或存储过程生成一亿行数据,跑一次全量同步,观察耗时、内存、checkpoint 时间。
如果有条件,还可以引入 Flink 的 DataStreamUtils.collect() 做端到端测试。它能在一个测试方法里启动 MiniCluster,收集流结果并返回 List,非常适合写 JUnit 断言。
6.4 实际调优参数建议
最后给一份我自己多年跑下来还算稳的参数建议。
| 参数 | 建议值 | 说明 |
|---|---|---|
| Source 并行度 | 不大于 MySQL 实例 CPU 核数的一半 | 避免每个 task 的查询把实例压垮 |
fetchSize |
500 ~ 5000 | 太小频繁网络交互,太大浪费内存 |
| checkpoint 间隔 | 10 秒 ~ 30 秒 | 太短给 MySQL 查询和状态后端增加压力 |
autoCommit |
false | 必须关闭,否则游标流式读取失效 |
readOnly |
true | 降低锁开销,避免业务风险 |
defaultFetchSize |
与 fetchSize 保持一致 |
JDBC URL 中设置,让 PreparedStatement 默认生效 |
| job 重启策略 | fixed-delay,最多重试 3 次 | Source 恢复后从偏移量续读,不必无限重试 |
需要说明的是,这些值不是绝对的,与表的行宽、网络带宽、MySQL 配置都有关系。但按这个范围起步,基本不会出现特别离谱的问题。
另外提醒一句:如果自定义 Source 要读取 MySQL 从库,建议在 JDBC URL 里加上 allowMasterSlaveReplication=true 或直接配只读地址,避免查询打到主库影响线上业务。
回看我自己的项目经历,自定义 Flink MySQL Source 这件事,做一次能让你把 Flink 的状态、并行度、checkpoint 机制全部串起来理解一遍。后来在多个项目里,我反而不怎么重复造轮子了,能上 CDC 用 CDC,能上内置连接器用内置连接器,只有真正的存量快照、无法访问 binlog 的场景,才把自定义 Source 拿出来。如果你也正在写这个功能,我的建议是从最简单版本起步,先把串行读取跑通,再逐步加上并行分片和状态恢复,不要一上来就追求完美架构。最后一个小技巧:写自定义 Source 时,尽量把“查询 MySQL 拿 ResultSet”和“把记录转发给 Flink”这两件事解耦,这个设计能让你后面的测试和排错轻松很多。
