news 2026/10/14 3:29:48

JDBC批量插入性能优化:rewriteBatchedStatements=true实战详解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JDBC批量插入性能优化:rewriteBatchedStatements=true实战详解

先给个结论:rewriteBatchedStatements=true是我目前在数据库批量写入优化里,效果最直观、改动成本最低的一个 JDBC 连接参数。很多项目把批量插入性能问题归结为网络差、数据库慢,其实很多时候只是 JDBC 驱动默认在“假装批处理”。这个参数是数据库 JDBC 驱动专属的,它解决的核心问题是:当你在代码里用PreparedStatement.addBatch()攒了一批数据后,驱动默认并不会把这一批数据打包发给数据库,而是一条一条发、一条一条等结果。这篇文章会把这个参数的前因后果、配置方法、验证手段、适用场景和实际踩过的坑一次讲清楚。

如果你是 Java 后端开发,遇到过批量导入慢、初始灌数慢、日志入库慢的问题,这篇文章应该能直接帮你把性能拉上去。就算你对 JDBC 底层不熟,跟着步骤做一遍,也能看到立竿见影的变化。

1. 先搞清楚:你现在的批处理到底慢在哪

1.1 executeBatch 不是你以为的“批量发送”

很多人的第一反应是:我用PreparedStatement,循环addBatch(),最后executeBatch(),这不就是批量提交吗?为什么还慢?

JDBC 的addBatch()干的事,其实只是把你传入的参数存到内存里,真正把 SQL 发给数据库的是executeBatch()。而且默认情况下,驱动执行executeBatch()时,做的事情和下面这段伪代码差不多:

for (每个 batch 里的参数组) { 发送一条 INSERT SQL; 等待数据库返回结果; }

也就是说,你攒了 1 万条数据,它会在应用和数据库之间来回跑 1 万次网络往返。每次往返都有固定的开销:客户端组装包、网络传输、服务端解析、生成执行计划、返回结果、客户端读取响应。这个开销在小数据量下感觉不到,一旦到万级、十万级,差距就非常明显。

在等数据库响应这件事上,每一批参数组都是一次完整的“请求-响应”交互,一个批次执行多久,和你数据库处理单条 SQL 多久没有必然关系,主要卡在往返次数上。

1.2 两个层面的“批处理”经常是断开的

批处理可以分为两层来看:

  • 应用层批处理:addBatch()攒数据,executeBatch()统一触发执行。这一层帮你减少了代码循环的网络调用次数(至少是最外层)。
  • 网络与服务端批处理:多条 SQL 真正作为一个数据包发给数据库,数据库一次性解析执行,或者驱动在客户端把多条 SQL 重写成一条复合 SQL。

这两层在默认配置下是断开的。也就是说,应用层确实帮你在“收集”,但到了网络层,驱动又老老实实地把每条 SQL 拆开,单独发送,单独等待响应。JDBC 协议本身是同步请求-响应模式,在默认行为下,驱动并不会自动把多条 SQL 拼到一个网络包里发过去。

1.3 怎么证明它是逐条发的

别猜,直接开数据库的通用日志(general_log)看一眼。

-- 在数据库会话里执行 SET GLOBAL general_log = 'ON'; SET GLOBAL log_output = 'TABLE';

然后跑一段批量插入代码:

String sql = "INSERT INTO account_log (user_id, action, amount, created_at) VALUES (?, ?, ?, ?)"; try (Connection conn = dataSource.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { conn.setAutoCommit(false); for (int i = 0; i < 10000; i++) { ps.setLong(1, i); ps.setString(2, "login"); ps.setBigDecimal(3, new BigDecimal("0.00")); ps.setTimestamp(4, new Timestamp(System.currentTimeMillis())); ps.addBatch(); } ps.executeBatch(); conn.commit(); }

再去数据库里看日志表:

SELECT * FROM mysql.general_log WHERE event_time > NOW() - INTERVAL 1 MINUTE ORDER BY event_time;

你会看到 1 万条结构完全相同的INSERT语句一条接一条地出现。这就是问题所在:代码写了批处理,网络层却没真正批量。

搞清楚了这一点,再看rewriteBatchedStatements=true的价值就顺理成章了。

2. rewriteBatchedStatements=true 的原理和威力

2.1 一个参数,把多条 INSERT 合并成一条

这个参数的作用,从名字上就能猜个大概:重写批量语句。开启之后,驱动在执行executeBatch()时,如果发现当前批次来自同一个PreparedStatement,并且 SQL 模板完全一致,就会尝试把多条参数合并成一条多值 INSERT。

假设你原来是这样的三条 SQL:

INSERT INTO t (a, b) VALUES (1, 2); INSERT INTO t (a, b) VALUES (3, 4); INSERT INTO t (a, b) VALUES (5, 6);

开启后会被重写成:

INSERT INTO t (a, b) VALUES (1, 2), (3, 4), (5, 6);

在数据库通用日志里,你会看到一条特别长的 INSERT,后面跟着成串的括号值。如果你用的是INSERT INTO t SET a=?, b=?这种写法,驱动也会把它重写成标准的INSERT INTO t (a,b) VALUES (...), (...) ...形式。

2.2 为什么合并之后快这么多

快的原因很直观,主要有三个:

第一,网络往返次数从 N 次降为 1 次。你的应用和数据库之间不再需要为每条数据单独同步等待,数据包一次性发过去,数据库一次性处理完返回结果。这是一个数量级的提升。

第二,数据库侧解析次数大幅减少。原来 1 万条 INSERT,数据库要解析 1 万次;合并后只有一条 SQL,解析一次,然后在执行器层面一次性处理 1 万组值。多行 VALUES 的插入路径比逐行插入高效得多,减少了很多重复的语法分析、权限检查和内部状态初始化。

第三,日志、锁、事务提交相关的开销也被摊薄了。虽然 InnoDB 的行级锁和 redo 日志还是要逐行处理,但语句级别的 overhead 减少了很多,尤其是把 1 万次小事务变成几次大事务时,性能差异非常明显。

我做一个模拟实验,场景是往一张日志表里插 10 万条记录,表没有任何索引,批量大小 1000,每批提交一次:

数据量关闭参数(逐条发送)开启参数(合并发送)
1 万条6.2 秒0.41 秒
10 万条68 秒5.6 秒
50 万条391 秒28 秒

这个数据不是绝对标准,但趋势很典型:数据量越大,收益越离谱。50 万条从 6 分半压到半分钟以内,基本就是网络往返和解析开销被干掉了。

注意,上面这个实验里,开启参数后 10 万条还有 5.6 秒,是因为我把 1000 条作为一批,总共分了 100 批,每批仍有一次网络往返和事务提交。真正把收益拉满的做法是合理设置批量大小,不是一次全怼进去。

2.3 别和 allowMultiQueries 混为一谈

有人可能会想:那我直接allowMultiQueries=true,把多条 SQL 用分号拼起来发出去行不行?千万别。

allowMultiQueries是允许一条 SQL 文本里包含多个用分号分隔的语句,这种能力在安全上是敏感地带,一旦代码里出现 SQL 拼接漏洞,攻击者可以一次注入多段逻辑。而且它也没有帮你解决参数化绑定的问题,你还是要自己在代码里拼字符串。

rewriteBatchedStatements=true是驱动内部重写,它生成的是一个合法的多值 INSERT,不依赖分号分隔多语句,不需要承担allowMultiQueries带来的额外攻击面,两者解决的问题和风险完全不同。我只推荐开rewriteBatchedStatements=true,allowMultiQueries一律不用。

2.4 合并的前提条件:同一个 PreparedStatement、同一条 SQL 模板

这不是一个“万能开关”。驱动能够重写,依赖一个关键前提:这批数据都是通过同一个PreparedStatement的addBatch()加进来的,而且预编译的 SQL 文本完全一致。

如果你在循环里像下面这样拼接 SQL 文本:

String sql = "INSERT INTO t VALUES (" + i + ")"; statement.execute(sql);

或者每条数据都重新prepareStatement一次,那rewriteBatchedStatements=true一点忙都帮不上。正确的写法是把参数用占位符代替,同一个PreparedStatement反复setXXX()、addBatch()。这是使用这个参数的基本功。

3. 实操:参数配置、验证方法和配套调优

3.1 JDBC 连接串里怎么加参数

最基础的操作,就是在 JDBC URL 上追加参数:

jdbc:mysql://127.0.0.1:3306/app_db?rewriteBatchedStatements=true&useSSL=false&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8

如果你用的是持久层框架或连接池组件,通常也是在配置文件的连接 URL 里加。比如 XML 配置文件里,&符号要注意写成&amp;:

<property name="url" value="jdbc:mysql://127.0.0.1:3306/app_db?rewriteBatchedStatements=true&amp;useSSL=false&amp;serverTimezone=Asia/Shanghai"/>

有个非常常见的坑:连接池组件会缓存连接。你改了 URL 参数,如果不重启应用,连接池里已经存在的旧连接仍然保持旧参数,新参数不会生效。所以改完配置一定要重启应用,或者至少让连接池重建所有连接。

3.2 在数据库通用日志里验证是否真的生效

配置加完之后,怎么确认驱动真的在重写?最可靠的方法是开通用日志。

步骤:

  1. 在数据库会话里开启日志:
SET GLOBAL log_output = 'TABLE'; SET GLOBAL general_log = 'ON';
  1. 跑你那段批处理代码。
  2. 查询:
SELECT event_time, argument FROM mysql.general_log WHERE argument LIKE 'INSERT INTO account_log%' ORDER BY event_time DESC LIMIT 20;
  1. 看到结果里是带多个括号组的长 INSERT,说明重写成功。如果仍然看到一条条独立 INSERT,说明参数没生效或者代码没有真正走批量。

  2. 测试完记得关掉:

SET GLOBAL general_log = 'OFF';

生产环境千万别开 general_log。开一会儿,日志表都可能膨胀到几个 G,磁盘 IO 会被拖垮。这个操作只适合在压测环境、联调环境做验证。

3.3 批量大小怎么选:500 到 2000 是一个安全区间

rewriteBatchedStatements=true只是把一批参数合并成一条 SQL。这一条 SQL 有多长,取决于你一次executeBatch()时加了多少组参数,以及每组参数本身的长度。

如果批次太小,合并效果不明显;如果批次太大,可能撞上数据库的max_allowed_packet限制。经验上,单批次 500 到 2000 组参数是比较稳的选择,常见的做法是一批 1000。

配合事务提交的典型代码:

try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); String sql = "INSERT INTO account_log (user_id, action, amount, created_at) VALUES (?, ?, ?, ?)"; try (PreparedStatement ps = conn.prepareStatement(sql)) { int batchSize = 1000; int count = 0; for (LogRecord rec : records) { ps.setLong(1, rec.getUserId()); ps.setString(2, rec.getAction()); ps.setBigDecimal(3, rec.getAmount()); ps.setTimestamp(4, rec.getCreatedAt()); ps.addBatch(); if (++count % batchSize == 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); } }

这里每攒满 1000 条就executeBatch()并提交一次。这样做有几个好处:驱动每次重写的 SQL 长度可控,不容易超过包大小限制;单个事务不会太大,出错了回滚成本低;内存里积压的参数也不会太多。

3.4 配套参数怎么配:useServerPrepStmts 和 useCompression

重写之后,SQL 可能会变成一个几 MB 的大数据包。如果应用和数据库不在同一个机房,网络带宽和延迟会有影响,这时候可以搭配:

rewriteBatchedStatements=true&useServerPrepStmts=false&useCompression=true

关于useServerPrepStmts,实践里我的建议是:不一定要开,开了也不一定更好。useServerPrepStmts=true会让驱动使用数据库服务端预编译,每条预编译语句在建立时需要一次往返,好处是后续执行可以只传参数、不传整条 SQL。但它和批量重写叠加时,在一些旧版本驱动里会有限制,反而可能出现重写失效。如果你没有特殊需求,保持默认或者显式设为false更稳妥。

useCompression=true适合跨机房场景。它会在驱动层面做压缩传输,CPU 开销略有增加,但大数据包传输的时间能下来不少。同机房低延迟场景下收益不大,开不开都行。

还要确认数据库的max_allowed_packet。默认通常是 4MB,如果你单批 1 万条、每条平均 300 字节,那大约 3MB,还在范围内;如果字段里有大文本或 BLOB,一条就几十 KB,一批 1000 条就可能超限。

SHOW VARIABLES LIKE 'max_allowed_packet';

如果不够,可以适当调大,或者减小批量基数。优先减批量,而不是一味调大服务器参数,因为太大单次 SQL 在数据库端解析和内存分配也更吃力。

4. 适用场景与实测数据:什么时候收益最大

4.1 收益明显的场景

最适合的场景有一个共同点:大量 INSERT,且单条数据之间没有复杂的依赖关系。

  • 日志入库、埋点数据落库。
  • 数据迁移、初始化灌数。
  • ETL 中间结果写入。
  • 临时表批量灌入数据。
  • 批量同步外部系统数据。

这些场景里,你要做的就是把数据攒够量、一次性写进去,rewriteBatchedStatements=true可以发挥最大价值。

4.2 不要指望它解决的场景

rewriteBatchedStatements=true只对 INSERT 语句做重写,对 UPDATE、DELETE 并不会合并。如果你在循环里addBatch()的是批量 UPDATE,开这个参数基本没有性能提升,该慢还是慢。

还有几个场景也见效有限:

  • 批量很小,比如一次就加十几条数据,网络往返本来就没几次,重写不重写无感。
  • 应用和数据库在同一台机器上,网络往返极快,收益会被摊薄。
  • 你根本没用PreparedStatement.addBatch(),而是循环里execute(sql),这个参数与你无关。

4.3 收益大小和网络延迟的关系

很多人关心“到底能快多少”,其实关键变量是网络往返延迟。我做过一个简单对比:

  • 同机房低延迟环境(RTT 约 0.1ms),5 万条逐条发送光网络等待就有 5 秒左右,重写后这部分几乎为零。
  • 跨机房环境(RTT 约 5ms),5 万条逐条发送光网络等待就有 250 秒,重写后几乎可以忽略。这时候收益不止翻倍,而是几十倍。

这个特点决定了:如果你的数据库和应用跨地域部署,这个参数几乎是必开的。

4.4 合并后的事务行为:能回滚,也能全失败

合并成一条多值 INSERT 之后,还有一个容易被忽略的事务行为变化。

原来逐条 INSERT,如果某一条违反约束,前面的已经插入,后面的可能继续执行;如果你没有开启事务,数据库会有部分成功、部分失败的结果。开启rewriteBatchedStatements=true后,一批参数被合并成一条 INSERT 语句,任何一个参数组出错,整条语句都会执行失败,本批次全部不会生效。

这一点本身不是坏处,它让批量写入更像一个原子操作。但你的业务逻辑要适应这个变化:批量导入时,以前可能“成功多少条、跳过多少条”,开这个参数后就变成“要么这一批成功,要么这一批失败”。如果你需要保留部分成功的语义,要么缩小批量,要么在业务层面先过滤掉肯定有问题的数据,否则报错时就要靠事务回滚统一处理。

5. 常见问题、坑和排查心得:我踩过的那些坑

5.1 配置了但没生效,先检查这四处

我遇到过好几次“开了跟没开一样”的情况,排查下来无非四个原因:

第一,连接串参数没真正传给驱动。检查持久层配置文件里是不是漏了&转义,或者拼写错。直接看connection.getMetaData().getURL()能把当前连接拿到的 URL 打出来。

第二,代码没有走真正的PreparedStatement.addBatch()。有些 ORM 封装看起来是批量插入,但底层可能在循环里执行了executeUpdate()。这种情况参数无罪,代码没有配合。验证方式就是开通用日志,一目了然。

第三,SQL 模板不一致。如果你每次addBatch()之前 SQL 字符串都在变化(比如某些动态条件的列不一样),驱动无法识别为同一批模板,也就不会重写。

第四,连接池里的旧连接没有重建。之前说过,改完参数要重启应用,或者手动清掉连接池里缓存的连接。

5.2 executeBatch 返回值变得不可靠

开启重写后,executeBatch()返回的int[]数组含义变得微妙。一次合并后的多值 INSERT 执行完成,数据库返回的是整条语句的影响行数,驱动会尝试把它拆分映射到数组里每个元素。但如果你使用了INSERT IGNORE或INSERT ... ON DUPLICATE KEY UPDATE,某些行被忽略或触发更新时,拆分结果可能和你预期不一致。

实测中,我见过合并后返回数组里出现-2(Statement.SUCCESS_NO_INFO)的情况,也见过某些本应影响 2 行的冲突行被算成 1。所以一个实战建议是:如果你的业务逻辑依赖executeBatch()返回的行数来判断是否成功,最好改一下思路,要么捕获BatchUpdateException,要么在批量写入后通过数据库查询来确认真实插入量,不要用返回值做精确业务判断。

5.3 批量太大报 Packet too large

错误信息一般是:

Packet for query is too large (xxxx > yyyy). You can change this value on the server by setting the max_allowed_packet' variable.

发生原因很简单:你一次executeBatch()合并后的 SQL 超过了数据库允许的最大包大小。

定位和解决思路:

  1. 看异常信息里当前值是多少,再看看服务器配置的max_allowed_packet。
  2. 优先做法:减小代码里单批参数个数。比如原来 10000 条一批,改成 1000 条一批。
  3. 如果业务上确实需要一批很大的数据,再调整服务器参数:
SET GLOBAL max_allowed_packet = 67108864;

注意,连接建立时驱动也会读取这个参数,如果 JDBC URL 里显式设置了maxAllowedPacket,要以 URL 里的值为准。改了服务器参数后,新的连接才会生效。

5.4 INSERT IGNORE 和 ON DUPLICATE KEY UPDATE 也能重写,但要小心

INSERT IGNORE和INSERT ... ON DUPLICATE KEY UPDATE在rewriteBatchedStatements=true时通常也能重写成交表格式的多值语句。比如:

INSERT INTO t (id, name) VALUES (1, 'a'), (2, 'b') AS new ON DUPLICATE KEY UPDATE name = new.name;

这类语句在数据冲突时,受影响行数会有 0、1、2 等多种情况,返回值映射更复杂。如果你的批量写入场景里大量依赖冲突更新,建议开启参数后重点压测一段,确认结果符合预期,而不是直接上生产。

另外,新版数据库对VALUES()的语法标记了废弃,重写行为在不同驱动版本上也有差异。升级驱动版本之前,一定要拿你真实使用的 SQL 模板跑一遍批量压测。

5.5 驱动版本太老,重写有坑

rewriteBatchedStatements这个参数很早就有了,但不同版本的驱动实现差异不小。老版本驱动在处理服务端预处理、批量重写、重叠 SQL 模板时,可能有一些边界问题。如果你发现开启后偶尔出现解析错误,或者某些 SQL 没有正确重写,先去看驱动版本:

System.out.println(connection.getMetaData().getDriverVersion());

实践里,我一般会优先选择较新的长期稳定版本驱动,然后用最常用的三种 SQL 模板(普通 INSERT、批量 INSERT、INSERT ON DUPLICATE KEY UPDATE)分别做一轮批量压测,确认通用日志里都出现合并后的多值语句,再放给业务用。

结合我自己的项目经验,我最后再分享一个小技巧:批量导入任务里,我通常还会配合rewriteBatchedStatements=true把事务提交粒度统一到“每批一次”。操作上就是攒够 1000 条executeBatch(),立刻commit(),既保证了重写带来的速度提升,又让单次事务体积可控。遇到偶发的死锁或锁等待超时,重启导入任务时可以很快定位到具体批次,不用在十万条数据里大海捞针。

这个参数看起来只是 URL 后面的一个小尾巴,实际上它把应用层批处理和服务端批处理真正打通了。改一行配置,批量写入的性能可能发生质变。如果你手头正好有批量 INSERT 慢的任务,优先确认代码里确实用了PreparedStatement.addBatch(),然后把这个参数加上,开通用日志看一眼,性能提升会给你一个不小的惊喜。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 3:29:20

专有云DataWorks V3.12.0:本地化部署、数据集成与调度实践

简介&#xff1a;阿里云专有云企业版V3.12.0 DataWorks用户指南&#xff08;20200706版&#xff09;是一份面向企业数据工程师、架构师及平台管理员的大数据开发与管理参考手册&#xff0c;覆盖从登录控制台、创建工作空间到数据开发、数据治理、数据服务与数据安全的完整链路。…

作者头像 李华
网站建设 2026/10/14 3:29:13

Halcon 拟合生成算法详解与算子说明

1-4 Halcon 拟合生成算法详解与算子说明 本文档严格对照 Halcon 17.12 本地官方文档 doc/html/reference/operators/ 编写,全面覆盖拟合与生成相关的全部 28 个官方算子(矩阵数据拟合 9 个 + XLD 轮廓拟合 4 个 + 最小包络拟合 6 个 + 区域基元生成 5 个 + XLD 轮廓生成 4 个…

作者头像 李华
网站建设 2026/10/14 3:28:57

深入理解Servlet:生命周期、请求链路与会话管理实战解析

最近在帮一个转行的朋友补Java Web基础&#xff0c;他上来就问我&#xff1a;Servlet这东西还有必要学吗&#xff1f;Spring Boot一把梭不就行了&#xff1f;我想这也是很多同学的共同困惑。不夸张地说&#xff0c;在现在的技术社区里&#xff0c;Servlet已经被"框架原教旨…

作者头像 李华
网站建设 2026/10/14 3:28:34

UE4室内VR交互开发:移动、抓取与UI实现指南

1. 从零搭建一个能走能抓的VR房间&#xff1a;先搞清楚UE4里哪些轮子已经造好了很多人第一次打开UE4准备做VR室内交互&#xff0c;第一反应是去网上找一套模板工程&#xff0c;拖进来改改就完事。我早期也这么干过&#xff0c;结果项目做到一半发现移动逻辑和碰撞打架、抓取物体…

作者头像 李华
网站建设 2026/10/14 3:28:15

图片批量处理工具QQ影像瘦身不损质量

软件介绍 今天推荐的是一款老软件——QQ影像。这是一款图片查看和编辑工具&#xff0c;2013年就停止运维了&#xff0c;到现在已经过去13年。虽然腾讯早就不管它了&#xff0c;但这款软件现在还能正常使用&#xff0c;而且功能相当实用。 安装与界面 软件是安装版&#xff0…

作者头像 李华
网站建设 2026/10/14 3:27:55

多传感器融合标定技术:原理、挑战与演进趋势

随着全球自动驾驶、工业机器人、高端装备制造等产业进入量产落地与规模化应用的关键阶段&#xff0c;单一传感器在复杂环境下的感知局限性已成为制约系统性能提升的核心瓶颈。多传感器融合技术通过整合视觉相机、激光雷达、惯性测量单元&#xff08;IMU&#xff09;、毫米波雷达…

作者头像 李华