☰
MySQL批量插入优化:深入rewriteBatchedStatements原理与实战
2026/10/14 8:04:05 网站建设 项目流程

rewriteBatchedStatements=true这个连接参数,我第一次留意到它是在排查一次批量写库慢的问题。当时代码已经规规矩矩用了PreparedStatement.addBatch()和executeBatch(),一万条数据写一张流水表,跨机房环境里居然要几十秒。开 MySQL 的 general_log 一看,数据库实际收到的不是一条批量语句,而是一万条单条 INSERT。

后来把这个参数加到 JDBC URL 里,同样的代码、同样的数据量,耗时掉了一个数量级。圈子里的朋友聊起来,发现很多人对这个参数的理解停留在"加上就能提速",但并不知道驱动在背后做了什么、什么情况下它不生效、哪些坑会等在生产环境炸开。这篇文章把我自己验证过的原理、实测数据和踩坑整理出来,给做 Java 后端、天天跟 MySQL 打交道的人一个参考。

1. 默认情况下,executeBatch 并没有走批量通道

1.1 代码看起来是批量,实际上是逐条发送

先看一段最常见的批量插入写法:

try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO t_order (order_no, user_id, amount) VALUES (?, ?, ?)")) { for (int i = 0; i < 10000; i++) { ps.setString(1, "NO" + i); ps.setLong(2, 1000 + i); ps.setBigDecimal(3, new BigDecimal("19.90")); ps.addBatch(); } ps.executeBatch(); conn.commit(); } }

这段代码在 JDBC API 层面没有任何问题,addBatch()确实把 1 万组参数攒在客户端内存里。但问题是,当连接串里没有rewriteBatchedStatements=true时,MySQL Connector/J 在executeBatch()时并不会把这些参数一次性组装成一条多值 SQL 发给服务端,而是逐个执行。这个参数在大多数版本的 Connector/J 里默认是 false,只有主动打开才会启用重写。

很容易自行验证:在两个环境里跑同一段代码,开启 general_log 看实际到达 MySQL 的语句。默认情况下,MySQL 收到的就是 1 万条独立的 INSERT。所以"批处理"只是应用侧的自嗨,数据库端看到的就是普通单条写入。

1.2 每条 INSERT 都是一次网络往返

MySQL 驱动逐条发送时,每条语句都要经历一次"发送 SQL -> 等待服务端响应"的完整网络往返(RTT)。如果数据库和应用在同一台机器,这个 RTT 很小,可能不到 0.5ms;如果是跨机房、跨云的环境,RTT 跑到 5ms、10ms 非常正常。

算一笔账:按单条执行加网络往返 2ms 估算,1 万条 INSERT 光是等待时间就是 20 秒。真实执行过程中还有日志刷盘、行锁竞争、commit 等开销,所以线上出现几十秒的耗时并不奇怪。换句话说,默认配置下批量写入的瓶颈往往不在数据库执行本身,而在"把一条一条 SQL 送过去"的通信开销上。

1.3 一个贴切的类比

可以把数据库连接想象成一条点菜通道:默认情况下,你每次只能对服务员说"来一盘西红柿炒蛋",等这一盘端上桌,才能说下一句。虽然你早已想好了要点 100 盘,但餐馆只允许你一盘一盘下单。rewriteBatchedStatements=true做的事情,是允许你一次性喊出"西红柿炒蛋来 100 盘,一起上"。

不过这里要澄清一点:这个参数只对 JDBC 驱动生效,而且只影响通过 JDBC Batch API 提交的语句。如果代码里根本没有调用addBatch()/executeBatch(),而是循环单条执行,那加不加这个参数都是一样的。

2. rewriteBatchedStatements=true 之后,驱动到底改写了什么

2.1 最核心的 INSERT 多值合并

开启参数后,驱动遇到批量 INSERT 时会尝试把多条结构相同的语句改写为一条多值 INSERT。假设原始 SQL 是这样的:

INSERT INTO t_order (order_no, user_id, amount) VALUES (?, ?, ?)

批量 5 条时,驱动会改写成类似这样的一条语句:

INSERT INTO t_order (order_no, user_id, amount) VALUES ('NO1', 1001, 19.90), ('NO2', 1002, 19.90), ('NO3', 1003, 19.90), ('NO4', 1004, 19.90), ('NO5', 1005, 19.90)

原先需要 5 次网络往返的写入,变成了一次往返。而且参数值会被驱动做转义后拼进 SQL,走普通文本协议发送,所以 general_log 里能看到完整的数值。

MySQL 官方文档对改写条件的描述很明确:批量中达到 3 条或以上时,驱动才会尝试重写;不足 3 条时不会做这个优化。改写只针对以 INSERT 开头、单值 VALUES 或 SET 形式的语句,如果语句本身是 INSERT ... SELECT、包含子查询之类的复杂形态,驱动不会贸然改写。带 ON DUPLICATE KEY UPDATE 的 INSERT 也是可以合并的,原理仍然是多值列表,只是在尾部拼接冲突处理逻辑。值列表批量写入时,表上有触发器也能正常逐行触发,这点不用担心。

2.2 不可重写时,驱动会静默回退

很多人担心"打开这个参数之后,万一驱动不会重写,程序会不会报错"。实际上不会。驱动对每条批量语句的判断是"能重写就重写,不能重写就退回逐条发送"。这个回退是静默的,不会抛异常,也不会在日志里提示。

也正因为是静默回退,生产环境里经常出现一种错觉:参数加了,看起来也没报错,但性能没有变化。这时候不要怀疑参数无效,要做的是检查批量语句本身是不是可以被重写的形态。最常见的不可重写案例包括:批量里混入了不同的 SQL 模板、批次数小于 3、语句不是 INSERT 单值格式。

2.3 和 allowMultiQueries、useServerPrepStmts 的边界

连接串里经常同时出现allowMultiQueries、useServerPrepStmts、cachePrepStmts等参数,很容易搞混。简单区分一下:

  • allowMultiQueries=true是让一条 SQL 里可以用分号写多条语句,例如INSERT ...; INSERT ...;,它改变的是单一 Statement 可以承载的语句数量。
  • rewriteBatchedStatements=true是针对批量 API 的优化,核心动作是把多条 INSERT 合并成一条多值 INSERT,并不依赖allowMultiQueries。
  • useServerPrepStmts=true会让驱动优先使用 MySQL 服务端预编译协议,减少重复 SQL 的解析开销。但开启 rewrite 之后,批量 INSERT 会被驱动拿到客户端来重写,通常就不再走服务端预编译了,这属于正常的内部切换。

把它们一起打开通常不会冲突,但要说清楚各自的职责,排查问题时才不会抓瞎。

2.4 一个容易忽略的协议层细节

多值 INSERT 虽然最后只发一次,但它的包体比单条大得多。如果一次批量 5000 条、每条记录平均 500 字节,拼出来的 SQL 就有 2.5MB 左右。这个大小可能会撞上两堵墙:MySQL 服务端的max_allowed_packet,以及连接器客户端的发送包上限。具体报错、避坑办法在第四章细说,这里先记住一个结论:不是批量越大越好。

3. 实测:同样代码,加参数前后差距有多大

3.1 测试环境与测试方法

为了验证效果,我在一套实验环境里做了对比测试。环境情况大致如下:

  • Java 8,mysql-connector-java 8.0.x
  • MySQL 8.0,InnoDB 引擎
  • 表结构简单:id 自增主键 + 几个 varchar/int/datetime 字段
  • 两种网络环境:本机回环(RTT 约 0.3ms)、模拟跨机房延迟(RTT 约 5ms)
  • 每次插入 1 万行,拒绝一切"预先把多值拼好"的写法,一律走 PreparedStatement.addBatch() + executeBatch(),每 1000 条调用一次 executeBatch()

每组数据跑 5 次取中位数,避免 JVM 预热等因素干扰。以下数字只用于说明量级差异,不同机器、不同表结构、不同缓冲池大小会带来明显浮动。

3.2 测试结果与解读

我把数据整理成了下面这张表:

场景未开启参数(毫秒)开启参数(毫秒)提升幅度
本机回环,1 万行4200850约 5 倍
模拟跨机房,1 万行530002200约 24 倍

解读一下:本机回环场景里,RTT 很小,默认逐条发送的通信开销有限,但从 4 秒多降到 0.85 秒,说明除了网络往返,减少每条 SQL 的解析和日志记录开销也有帮助。跨机房场景是这种参数最能发挥价值的地方,网络延迟越大,节省的往返次数越值钱。

有几个容易忽略的现象也值得一提。开启参数后,如果单批条数太少(例如每批只有 2 条),驱动不会重写,耗时和默认基本持平。另外,连接建立本身不受这个参数影响,所以只查一条数据、只更新一条数据时,完全没有必要开它。

3.3 用 MySQL general_log 确认 SQL 真的被重写了

口说无凭,验证办法很简单。在 MySQL 里临时打开通用查询日志:

SET GLOBAL log_output = 'TABLE'; SET GLOBAL general_log = 'ON';

跑完批量代码后,去查日志表:

SELECT * FROM mysql.general_log ORDER BY event_time DESC LIMIT 20;

然后记得关闭日志:

SET GLOBAL general_log = 'OFF';

如果参数生效,日志里会看到一条很长很长的 INSERT,VALUES 后面跟了几百上千个括号;如果参数没生效,日志里是连续的单条 INSERT。这个方法最直观,也是我在排查"参数到底有没有生效"问题时的第一选择。线上环境不建议长时间开 general_log,它会放大磁盘写入开销,测完立刻关。

3.4 确认连接参数真的被用上了

还有一种情况是"代码加了参数,但连接池里实际拿出来的连接没有生效",常见原因包括:配置写在数据源 URL 中被后面的属性覆盖、连接池初始化早于配置加载、或者驱动 URL 拼接时把参数拼到了错误的位置。可以在启动时短暂打印连接信息:

Connection conn = dataSource.getConnection(); System.out.println(conn.getMetaData().getURL()); conn.close();

看到打印出来的 URL 里确实带上了rewriteBatchedStatements=true,再进入下一步排查。这一步很笨,但能省掉后面大把怀疑时间。

4. 这个参数不是银弹:几个容易踩的坑

4.1 批量过猛,直接撞上 max_allowed_packet

这是开启参数后最典型的线上事故。连接串上加了rewriteBatchedStatements=true,然后代码一改,把原来每批 1000 条调到每批 10000 条,结果半夜写入任务开始抛类似这样的异常:

Packet for query is too large (8,088,245 > 4,194,304). You can change this value on the server by setting the 'max_allowed_packet' variable.

原因很清晰:重写后的多值 INSERT 变成一个巨包,超过了 MySQL 服务端允许接收的最大包大小。处理办法是双管齐下:

  1. 把单批条数降下来。一般单批 1000-5000 条是相对安全的值,具体看单行字段长度。
  2. 如果确实需要更大的单批,考虑在数据库端调大max_allowed_packet,但要同时关注网络设备对单包 MTU 的限制,以及驱动客户端自身的发送上限。

不要一上来就把max_allowed_packet调到 1GB,那是给自己埋雷。

4.2 持久层框架的三种批量写法,只有一种能吃到红利

很多后端项目用了持久层框架,这个参数在框架下能不能生效,取决于你用的是哪种批量写法。我对三种常见用法的结论是:

写法实际发生什么这个参数有效吗
foreach 标签拼多值 INSERT在 SQL 拼接阶段就把多行写成一条大 SQL,一次发送,全程没走 executeBatch不依赖它,本身已经是多值形态
打开 BATCH 执行器,循环调用 Mapper 方法框架内部把语句攒进 addBatch(),flushStatements 或 commit 时执行 executeBatch()有效,这是典型的受益场景
普通循环调用单条 insert 方法,不开 BATCH 执行器每行一次单条 INSERT,不会攒批无效,加多少参数都没用

我见过不止一次有人在 xml 里写了个 foreach,然后以为批量插入已经走 JDBC Batch 了,结果在 URL 上折腾半天没效果。实际上 foreach 拼出来的本来就是一条多值 SQL,不需要这个参数也能享受"一次往返"的好处,只是要注意 SQL 别超过max_allowed_packet。真正需要这个参数的是 BATCH 执行器的攒批模式。

4.3 批量 UPDATE 别指望它像 INSERT 一样合并

rewriteBatchedStatements 对批量 INSERT 的收益是数量级的,但对批量 UPDATE 就要降低预期。UPDATE 没有多行列表语法,驱动很难把UPDATE t SET a=? WHERE id=?这种语句在多行参数的情况下合并成一条高效语句。实测下来,批量 UPDATE 的耗时和默认模式差异不大,甚至为了判断能否重写还会多花一点客户端 CPU。

如果压测时批量 UPDATE 慢,正确的思路不是继续压这个参数,而是考虑把多条更新合并成一条条件更新。常见做法是用 CASE WHEN 把一次循环改成一条 SQL:

UPDATE t_order SET status = CASE order_id WHEN 101 THEN 2 WHEN 102 THEN 3 ELSE status END, update_time = NOW() WHERE order_id IN (101, 102);

这种方式把 N 次网络往返压成一次,在跨机房场景下收益非常明显。代价是 SQL 拼接逻辑要自己在代码里生成,不像 INSERT 多值那样由驱动代劳。

4.4 自增主键回填:不同驱动版本差异很大

批量 INSERT 之后经常要拿到生成的自增主键。默认逐条发送时,getGeneratedKeys()能拿到每个 id;重写成多值 INSERT 后,驱动需要额外解析服务端返回的多值结果,不同版本的 Connector/J 这里的行为并不完全一致。我看到过某些 5.x 老驱动在这种场景下只返回部分主键的情况,8.0 系列整体好很多。

所以如果你的业务强依赖批量插入后回填自增 id,改这个参数之前,先用和线上完全一致的驱动版本、数据库版本做一次验证,不要想当然。验证方式不复杂:插入 3 条测试数据,逐一打印getGeneratedKeys()返回的 id 集合,确认数量是 3、内容正确即可。

4.5 加上参数还是慢?按这个顺序排查

最后给一个排查清单,按顺序检查,基本能定位 90% 的问题:

  1. URL 里真的带上了参数吗?打印连接 URL 确认,排除配置被覆盖。
  2. 批量语句能被重写吗?必须是单条 INSERT,批次数大于等于 3,不能是复杂子查询形态。
  3. 代码真的调用了executeBatch()吗?如果你用了持久层框架,确认 BATCH 执行器已经生效。
  4. SQL 重写后有没有撞上包大小限制?看异常是不是 PacketTooBigException。
  5. 驱动版本是否是较新的 8.0.x?老驱动对重写的支持不完整,建议先升级再测。

这套排查链路我用了很多次,比盲目调 batch size 有效得多。

5. 围绕批量写入的配套参数与建议

5.1 batch size 怎么定

开启rewriteBatchedStatements之后,批次数并不是越大越好。太大,内存里攒的参数多、重写后的 SQL 包大、一旦中途失败回滚范围也大;太小,重写收益不明显。我比较习惯的单批值是 1000-3000 条,如果单行字段少、记录很短,可以放到 5000 条;如果单行里有长文本、JSON 字段,建议压到 500 条以内再观察。

5.2 事务边界要和批量大小一起考虑

批量插入通常意味着大事务。如果一次性塞 10 万行并提交一个事务,MySQL 会长期持有大量行锁、积累很多 undo log,commit 时也可能卡顿。这里给个实践建议:在允许失败补偿的业务上,可以按照 batch size 分批提交;如果是强一致业务不能拆事务,至少控制单批总数据量,必要时对整个写入链路做限流。

5.3 连接池配置里怎么放这个参数

以常见的连接池组件为例,最终效果都是要让连接 URL 带上参数,写法类似:

spring.datasource.url=jdbc:mysql://db.example.com:3306/app_db?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true&useSSL=false

如果你用的是自定义连接池且配置了 dataSourceProperties,注意别把 rewriteBatchedStatements 误写成一个独立属性,比如写成dataSource.rewriteBatchedStatements=true很可能是无效的,它必须作为 JDBC URL 的一部分传给驱动。这是很多人加完参数没效果的隐藏原因之一。

5.4 生产环境落地前的最后一件事

上线前至少做一次带真实数据量的压测,并且同时关注三条指标:单批耗时、TPS 波动、以及max_allowed_packet相关告警。顺便把 general_log 打开跑一小段,SQL 形态确认没问题再关掉。这样上线后即使出问题,也容易排除是参数配置引起的。

说到底,rewriteBatchedStatements=true是一个性价比极高的连接参数,但它解决的是"批量 API 默认逐条发送"这一个具体问题。它不替你把所有写入慢的毛病都治好,也不是所有框架场景下都会自动生效。理解了驱动在背后的改写逻辑,用起来心里才有底。

我现在的习惯是,新建数据库连接配置时先把这个参数写上,然后在涉及批量 INSERT 的功能里每批控制在 2000 行左右。这个参数本身不复杂,复杂的是搞清楚自己代码里的批量到底是怎么发出去的。把这一步想透,很多所谓的神秘性能问题都会变成明牌。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询