Flink 写 MySQL,很多人第一步就搞错了方向。拿到需求第一反应是自定义一个 Sink,然后在invoke()里逐条executeUpdate(),跑起来发现吞吐几百条每秒,背压飙到 99%,然后开始怀疑 Flink 不行。其实问题不在 Flink,而在 JDBC Sink 本身的机制你没吃透。
这篇博文我会把 JDBC Sink 从底层原理到参数配置再到实战调优完整拆开讲,重点围绕 MySQL 实时写入、批处理优化和最佳实践三个方向。内容偏向实操,适合正在用 Flink SQL 或 DataStream API 做实时入仓入 MySQL 的读者,包括刚接触 Flink 的开发者,以及被写入性能问题折磨了一段时间的进阶用户。
1. JDBC Sink 的核心机制与适用边界
1.1 为什么实时写入 MySQL 不能直接用单条插入
很多从传统 Java 开发转过来的朋友,第一次写 Flink 程序时,下意识会这么做:在RichSinkFunction的invoke()方法里写一条INSERT INTO,然后用PreparedStatement.executeUpdate()逐条提交。表面上逻辑没问题,实测起来就会发现,数据量一旦过万,写入延迟和背压立刻失控。
原因在于单条插入的代价被严重低估了。每一次executeUpdate()背后都包含一次完整的网络往返、SQL 解析、事务开启与提交。MySQL 单机能够稳定支撑的写入事务数大概是每秒几百到一千这个量级,而 Flink 单并行度每秒能处理几千甚至上万条数据。这意味着 Sink 的写入能力远远跟不上上游的处理速度,反压会通过算子链一路往上传递,最终整个作业的吞吐被拖垮。
JDBC Sink 的设计初衷正是为了解决这个问题。它不是逐条写,而是把数据攒在缓冲区里,攒到一定规模或等待一段时间后,一次性通过 JDBC 批量执行接口提交给 MySQL。批量写入的核心价值有两个:一是显著减少网络交互次数,二是充分利用 MySQL 服务端对批量执行的原生优化,比如重写批量 SQL、减少日志刷盘次数等。理解了这一层,你才能真正理解参数调优的方向,而不是靠猜。
1.2 哪些场景该用 JDBC Sink,哪些该绕开
JDBC Sink 适合的场景主要分三类。第一类是实时报表场景,比如运营大屏、交易流水查询,数据量在每秒几千到几万条,需要秒级延迟写入 MySQL,供 BI 工具查询。第二类是维度表更新场景,比如订单表需要关联商品维度、用户维度,把 Kafka 中的变更流实时同步到 MySQL 维表。第三类是轻量级结果存储,比如实时计算出的汇总指标、规则引擎的触发结果,需要持久化到关系型数据库供业务方使用。
但有些场景我建议你直接放弃 JDBC Sink。写入量达到每秒十万甚至百万级别时,MySQL 本身的写入能力就是瓶颈,再怎么调 Sink 参数都没有用。这时候应该考虑分布式存储方案,或者把 MySQL 只作为最终结果存储,中间引入消息队列削峰。另外,如果需要复杂的事务逻辑,比如一笔交易涉及多张表的联合更新、跨库操作,JDBC Sink 也扛不住,应该回到业务系统里去做,而不是放在 Flink 里硬撑。
还有一点容易被忽略:JDBC Sink 对 DDL 的容忍度很低。如果你的上游数据结构经常变化,比如频繁加字段、改类型,那么用 JDBC Sink 会非常痛苦,每次都要改表结构、重启作业。这种情况下建议先落到后续可扩展的存储层,再通过其他方案同步到 MySQL。
2. MySQL 实时写入的参数配置与调优
2.1 核心参数逐个拆解
Flink 官方提供的 JDBC Sink 参数并不算多,但你如果不知道每个参数背后对应的实际物理行为,很容易把配置改成一堆无效参数。下面这张表是生产环境里我实际验证过的参数清单和推荐值:
| 参数名 | 默认值 | 推荐值 | 作用说明 |
|---|---|---|---|
sink.buffer-flush.max-rows | 100 | 1000 | 攒批的最大行数,达到即触发批量写入 |
sink.buffer-flush.interval | 1s | 2s-5s | 攒批的最大等待时间,超时即触发写入 |
sink.max-retries | 3 | 3-5 | 单批次写入失败后的重试次数 |
sink.connection-pool-size | 1 | 与并行度匹配 | 连接池大小,决定并发写入能力 |
sink.parallelism | 无 | 与并行度匹配 | Sink 算子的并行度设置 |
max-rows和interval是两个需要联动调优的参数,它们共同决定了“多久写一次”和“一次写多少”。如果只设置max-rows不设置interval,遇到低峰期时数据攒不满批次,就可能长时间不写入,造成数据延迟。比如你设置了max-rows=1000,但高峰期一过每秒只有 100 条数据,那这批数据要攒 10 秒才能写入,延迟就上来了。反过来只设置interval=2s不设置max-rows,高峰期每秒来 1 万条,每 2 秒才触发一次写入,单次写入量高达 2 万条,容易把 MySQL 打爆。
合理的组合方式是让两者互相兜底。高峰期由max-rows兜住单批大小,避免单次写入过大;低峰期由interval兜住延迟,保证数据不会长时间停留在缓冲区里。推荐的生产配置是max-rows=1000、interval=2s起步,然后根据监控数据逐步调整。
2.2 连接池大小与 Sink 并行度的匹配关系
这是调优中最容易被忽视的部分。sink.connection-pool-size默认值是 1,也就是说整个 Sink 算子只有一条连接。如果你给 Sink 设置了 4 个并行度,但连接池只有 1,那么同一时刻只有一个子任务能拿到连接,另外三个子任务在等待锁,写入能力被严重限制。
连接数的计算逻辑并不复杂。总连接数等于 Sink 并行度乘以每个子任务需要持有的连接数。正常情况下,每个并行子任务持有 1 到 2 条连接就够了。比如 Sink 并行度为 4,连接池大小设置为 4 或 8 都是合理的。连接池太大反而有问题,MySQL 服务端与客户端的连接本身就有内存开销,连接过多还会增加 MySQL 的线程调度负担,极端情况下会触发连接数超限。
生产环境我一般这样估算:先让 Sink 并行度保持与上游并行度一致,避免数据倾斜和算子间数据传输;然后设置connection-pool-size等于 Sink 并行度,最多不超过并行度的 2 倍。如果发现瓶颈在 MySQL 侧的连接等待,优先检查慢查询和锁等待,不要无脑加大连接池。
2.3 字段映射与 SQL 模板的细节处理
JDBC Sink 在 Flink SQL 中是通过INSERT INTO语句来定义写入逻辑的,在 DataStream API 中则是通过JdbcExecutionOptions和JdbcConnectionOptions组合配置。无论哪种方式,最核心的都是 SQL 模板的正确性。
Flink SQL 场景下,你写的INSERT INTO语句中的字段顺序必须和目标表完全一致,字段类型也要兼容。这里有一个常踩的坑:MySQL 的DATETIME类型和 Flink 的TIMESTAMP类型在底层 JDBC 驱动中转换时,可能会有时区偏差。如果你在 Flink 侧设置过table.local-time-zone,那写入 MySQL 时务必要核对时间字段是否偏移了 8 个小时。这个坑我遇到过不止一次,表现就是数据本身不错,但时间字段在数据库里总是差了几小时,排查半天最后发现是时区配置问题。
DataStream API 场景下,SQL 模板里建议使用占位符方式构造 PreparedStatement,比如INSERT INTO user_log (user_id, action, ts) VALUES (?, ?, ?)。这样做的目的是让 JDBC 驱动缓存执行计划,避免每次写入都重新解析 SQL。如果你用字符串拼接的方式构造 SQL,性能差异可以达到一个数量级,而且还有 SQL 注入风险,虽然 Flink 作业里数据源通常是可信的,但编程习惯要养好。
3. 从慢到快:批处理优化与吞吐提升实践
3.1 一次批量写入的完整生命周期
在调优之前,先搞清楚一次批量写入在底层到底发生了什么。Flink 的 JDBC Sink 内部有一个缓冲区,上游数据到达后,invoke()方法会把数据写入缓冲区,同时检查当前缓冲区的行数是否达到max-rows阈值。如果达到了,就会触发一次批量写入;如果没有达到,再检查距离上次写入的时间是否超过了interval,超过则同样触发写入。
触发写入后,Sink 会拿到一条连接,把缓冲区中的数据按照提前编译好的 PreparedStatement 批量填充,然后调用 JDBC 的executeBatch()方法。MySQL 驱动接收到批量请求后,会把多条 SQL 合并成一个请求发送到服务端。服务端执行完成后,驱动返回批量执行结果,Sink 清空缓冲区,继续处理后续数据。
这里面最重要的一个环节是 JDBC 驱动层面的rewriteBatchedStatements参数。这个参数默认是关闭的。关闭状态下,驱动虽然调用的是executeBatch(),但实际发送给 MySQL 的还是逐条 SQL,只是减少了网络交互次数,并没有真正利用 MySQL 服务端的批量优化能力。打开这个参数后,驱动会把多条 INSERT 语句重写成一条多值的 INSERT 语句,比如INSERT INTO t (a, b) VALUES (1, 2), (3, 4), (5, 6),写入性能可以提升数倍。
3.2 写入瓶颈的定位方法
当你发现 Sink 写入性能不达标时,先不要急着调參数,先定位瓶颈到底在哪个环节。我总结了一个三层定位法。
第一层是看 Flink 作业的背压情况。如果 Sink 算子出现高背压,说明瓶颈在 Sink 自身,要么是攒批参数不合理,要么是连接池不够用,要么是目标端处理能力不足。如果背压源头在上游算子,那就先解决上游的处理能力问题,不要动 Sink 配置。
第二层是看 MySQL 服务端的状态。通过SHOW PROCESSLIST查看当前连接执行情况,如果大量连接处于Waiting for table metadata lock状态,说明表结构或索引有锁等待问题;如果大量连接处于Updating状态且耗时很长,说明单条 SQL 执行效率有问题,大概率是索引缺失或驱动没有走批量执行。
第三层是看网络和驱动层面的耗时。如果 MySQL 慢日志里没有明显慢查询,但 Flink 侧单批次写入耗时依然很高,问题可能出在网络往返延迟上。批量大小设置得过大时,单批次数据要在网络上传输很久,加上 MySQL 解析和执行时间,整体耗时自然就上去了。这时候应该适当减小max-rows,把单批大小降下来。
3.3 一个真实调优案例的完整记录
之前我处理过一个实时订单写入 MySQL 的作业,高峰期每秒约 8000 条订单数据,要求延迟不超过 5 秒。最初的配置是max-rows=100、interval=1s、并行度 4,实测写入吞吐只有每秒 2500 条左右,背压长期 99%,订单延迟达到 20 秒以上。
第一步,把max-rows从 100 调整到 1000,interval从 1s 调整到 2s。原因是原来的配置下,每秒触发大约 8 次批量写入,每次只有 100 条,网络交互依然是主要开销。调整后每秒触发 4 次批量写入,每次 2000 条左右,吞吐提升到每秒 6000 条。
第二步,在 JDBC URL 中加入rewriteBatchedStatements=true&useSSL=false&useUnicode=true&characterEncoding=utf8。这个改动立竿见影,吞吐直接提升到每秒 12000 条,瓶颈瞬间从 Sink 转移到了上游 Kafka 消费能力。
第三步,把连接池大小从默认的 1 调整为与并行度一致的 4,同时确认 MySQL 的max_connections和wait_timeout配置没有拖后腿。最终吞吐稳定在每秒 15000 条左右,背压归零,延迟降到 3 秒以内。完整的数据链路配置可以在 DataStream API 中这样写:
JdbcExecutionOptions executionOptions = JdbcExecutionOptions.builder() .withBatchSize(1000) .withBatchIntervalMs(2000) .withMaxRetries(3) .build(); JdbcConnectionOptions connectionOptions = JdbcConnectionOptions.builder() .withUrl("jdbc:mysql://host:3306/db?rewriteBatchedStatements=true&useSSL=false") .withDriverName("com.mysql.cj.jdbc.Driver") .withUsername("user") .withPassword("password") .withConnectionPoolSize(4) .build();这个案例说明一个道理:性能问题不能靠单一参数解决,要结合攒批大小、连接池、驱动参数和 MySQL 服务端能力综合判断。而且每一步调整都要用监控数据验证,不要一次性把所有参数都改了,否则出了问题根本不知道是哪一步引起的。
4. 高可靠写入:事务与一致性设计
4.1 两阶段提交机制的使用前提
默认情况下,JDBC Sink 采用 at-least-once 语义。作业失败重启或网络抖动时,极少数批次可能会被重复写入,导致 MySQL 中出现重复数据。如果你的业务对数据一致性要求严格,需要倒回去重新消费,那么 Flink 的 JdbcSink 配合 XA 事务可以实现端到端的 exactly-once 语义。
实现方式是使用 Flink 提供的JdbcXaSink,它基于两阶段提交协议,在 Flink 的 Checkpoint 机制驱动下,先预提交事务,确认 Checkpoint 完成后,再提交事务。这个机制本身设计得很好,但在 MySQL 场景下有一个必须提前确认的前提:MySQL 是否开启了 XA 支持。MySQL 从 5.7 版本开始原生支持 XA 事务,但默认事务隔离级别下表现并不如你想象的完美,官方文档也明确说明 XA 事务在崩溃恢复时有数据丢失风险。
所以我的建议是:在 MySQL 上启用 exactly-once 语义前,必须先做几项验证。一是确认 MySQL 版本和存储引擎,InnoDB 下才支持 XA 事务;二是测试故障恢复场景,手动 kill -9 掉作业进程后重启,检查是否有数据丢失或重复;三是确认 Checkpoint 的超时时间大于事务超时时间。如果这几项验证不过关,千万不要直接上正式环境。
4.2 幂等写入:用主键或唯一索引兜底
相比于复杂的 XA 事务,我个人更推荐用幂等写入来保证一致性。思路是在目标表上建立主键或唯一索引,写入 SQL 使用INSERT ... ON DUPLICATE KEY UPDATE语法,或者使用REPLACE INTO。这样即使作业重复写入同一批数据,最终表中的数据也是幂等的,不会出现重复行。
这种方案的实现成本很低,在 Flink SQL 中只需要把 INSERT 语句改写一下:
INSERT INTO order_stats (order_id, user_id, amount, update_time) VALUES (?, ?, ?, ?) ON DUPLICATE KEY UPDATE amount = VALUES(amount), update_time = VALUES(update_time);在 DataStream API 中,只需要把JdbcSink的 SQL 模板替换为上述语句即可。这里要注意VALUES()函数在 MySQL 8.0.20 及以上版本中已经标记为废弃,官方推荐改用AS new_alias语法,不过两种写法在绝大多数生产环境都能正常使用。
幂等方案的核心优势是简单可靠,不依赖分布式事务,也不依赖 Flink 的 Checkpoint 机制。它的代价是需要精心设计主键或唯一索引。如果你的目标表天然有业务主键,比如订单号、用户 ID,直接用即可。如果没有,可以考虑用业务流水号加哈希字段拼接一个组合唯一索引。这个方案的潜在坑是REPLACE INTO在表上有外键约束时会先删除后插入,可能触发级联删除,所以有外键关系的表建议优先使用ON DUPLICATE KEY UPDATE。
4.3 脏数据隔离与重试策略设计
写 MySQL 和写消息队列不同,MySQL 对数据类型和约束要求很严。上游 Kafka 里的数据经常会有脏数据,比如字段长度超限、非法的日期格式、字符串中包含了目标表字符集不支持的字符。这类数据在写入时会导致批次失败,如果重试策略设计不当,还会拖垮整个作业。
我在生产环境中遇到过最典型的问题就是脏数据导致重复失败。某个批次里有一条数据的 JSON 解析异常,在 Sink 端的 SQL 填充阶段直接抛错,整个批次不断重试,Checkpoint 一直失败,最终作业反复重启。排查半天发现其他数据都是好的,就那一条异常数据害了整个作业。
解决方案是在写入 Sink 之前,利用 Flink 的侧输出流或过滤算子对数据做清洗和校验。具体做法是把核心字段的合法性检查前置到 Sink 上游的算子中,不合格的数据拆分到侧输出流,落到单独的目录或消息队列里做人工处理。这样可以保证进入 Sink 的数据都是合法的,Sink 批次失败的概率大幅降低,作业的稳定性显著提升。
另外,重试次数要克制。sink.max-retries设置得太大没有意义,因为 MySQL 服务端持续异常时,再怎么重试都是浪费时间,反而加剧服务端压力。生产环境我一般设置 2 到 3 次,配合 Flink 自身的失败恢复机制,超过重试次数会触发作业重启,由 Checkpoint 机制从上次成功的位置恢复,保证不会丢数据。
5. 实战踩坑与问题排查速查
5.1 高频故障现场还原
下面这些错误信息,是在各种项目现场出现频率最高的几个,我把对应的原因和解法一并整理出来。
| 错误信息 | 常见原因 | 解决方案 |
|---|---|---|
Communications link failure | 网络中断或wait_timeout超时导致连接已断开 | 在 JDBC URL 中添加autoReconnect=true,适当增大wait_timeout |
Data too long for column | 字段长度超出目标表定义 | 上游清洗截断,或修改表字段长度 |
Deadlock found when trying to get lock | 多并行度写入同一批数据时发生行锁竞争 | 显式设置主键写入顺序,或降低并行度/批大小 |
Batched update is not supported | 驱动版本过低或未开启批量执行 | 升级 MySQL 驱动至 8.x,并在 JDBC URL 中添加rewriteBatchedStatements=true |
Out of memory | 缓冲区积压过多数据 | 检查背压,调小max-rows或调大 Sink 并行度 |
Deadline exceeded | Checkpoint 时间超时,通常伴随 XA 事务长时间未提交 | 调大 Checkpoint 超时时间,或改用幂等写入方案 |
这里有一个高频问题值得展开说:MySQL 的死锁。JDBC Sink 并行度较高时,多个子任务同时写入同一张表,如果目标表上没有合理的主键或索引,MySQL 内部的行锁分配会变得非常随机,死锁概率大幅上升。遇到这个问题,第一反应不是调大重试次数,而是检查表结构。如果表上有明确主键,Flink 写入的数据没有跨主键更新,死锁概率就会低很多。如果确实需要并发更新同一行数据,那么从业务层面就要控制并发度,这不是 Sink 能解决的问题。
5.2 连接管理方面的必踩经验
wait_timeout是 MySQL 服务端默认的超时参数,默认值是 8 小时。这个参数本身没有太大问题,但 Flink 的 Long-Running 作业如果长时间没有数据写入,比如凌晨低峰期,连接会处于空闲状态。到了 MySQL 的wait_timeout时点,服务端会主动断开空闲连接,客户端并不知道,下一次写入时使用了已经失效的连接,就会报Communications link failure。
这个问题的解法有两个层面。客户端层面,在 JDBC URL 里加autoReconnect=true,让客户端在连接失效后自动重连。服务端层面,设置wait_timeout为更大的值,或者在应用层配置连接池定期清理空闲连接。但从 Flink 的角度看,更稳妥的方案是把sink.buffer-flush.interval设置得短一些,保证即使低峰期也会有数据写入触发,连接不会长时间空闲。比如设置interval=2s,那么最坏情况下,每 2 秒就会尝试写入一次,连接不会被 MySQL 判定为长时间空闲。
5.3 监控指标与报警阈值建议
收藏一套靠谱的监控指标,比出问题后再去翻日志高效得多。我在生产环境主要盯以下四个指标:
第一是 Sink 算子的发送速率,这个指标直接反映写入吞吐,异常下降说明上游或 Sink 内部出了问题。第二是背压比率,持续大于 0.8 就需要介入排查,持续 1.0 意味作业随时可能崩溃。第三是 Sink 算子的数据滞留时间,通过currentEmitEventTimeLag可以直观看到数据延迟,超过业务 SLA 就报警。第四是 MySQL 侧的慢查询数和锁等待数,这些数据可以从 MySQL 的 performance_schema 里取,慢查询暴涨说明 Sink 生成的 SQL 有问题。
报警阈值的设置没有统一标准,要根据业务容忍度来确定。一般来说,数据延迟超过 5 分钟必须报警,背压持续 1.0 超过 10 分钟必须报警,Sink 写失败次数在 5 分钟内超过 10 次必须报警。这些阈值在初期可以设置得宽松一些,跑过一两周后再根据实际表现校准。
5.4 几条值得长期遵循的实践经验
最后分享几条我在多个项目中反复验证过的心得。
批次大小的选择不要拍脑袋。先评估你的目标表每秒能稳定写入多少行,然后反推批次大小和间隔。MySQL 单表在普通配置下,批量写入能够稳定支撑每秒几千到一两万行,超过这个量级就要考虑拆表或降低并发。当前主流云数据库实例性能会好一些,但也需要在测试环境压测确认,不要照搬任何经验值。
并行度不要盲目调大。很多人以为并行度越大吞吐越高,实际上在 JDBC Sink 场景下,并行度增加意味着并发的数据库连接和事务数增加,MySQL 的锁竞争会更激烈,性能反而下降。合适的并行度需要结合表结构、数据分布和目标端能力综合评估,实践下来 4 到 8 个并行度是大多数场景的甜点区。
写入 SQL 要保持简洁。字段越多,单条数据序列化到 SQL 的成本越高,批量填充的开销也越大。只写入下游真正需要消费的字段,能省掉不少性能损耗。如果流里的数据结构很宽,但目标表只需要其中几个字段,用 Flink SQL 的字段投影把数据裁剪后再送入 Sink,实测可以提升 20%-30% 的吞吐。
另外,我始终保留一个原则:JDBC Sink 只承担写入职责,不要在里面做任何业务逻辑处理。做过滤、做清洗、做字段映射,都应该放在上游算子中完成,让 Sink 保持纯粹。这样出问题时排查边界清晰,性能瓶颈也好定位。
Flink JDBC Sink 本身不复杂,复杂的是对 MySQL 特性的理解、对并发模型的把握,以及对数据链路的整体思考。希望这篇文章能帮你少踩几个坑,真正把实时写入这条链路做稳做快。