导读 :数据迁移、对账导入、日志落库,都会碰到"批量插入怎么这么慢"。很多人代码里明明写了
addBatch()、用了 MyBatis 的 BATCH 模式,速度却和一条条插差不多。原因大多是 MySQL 驱动默认不会把批量语句合并。这篇在本机 MySQL 8.2 上把 9 种写法各插 10 万行,记下耗时和服务端实际收到的 INSERT 条数,再整理参数加了却不生效的几种情况。
一、先说清楚:为什么 addBatch 了还是慢
一句白话:addBatch() 只是把语句攒在客户端,发不发成一条,要看驱动。
MySQL 的 Java 驱动(Connector/J)默认情况下,executeBatch() 会把攒下的 1000 条 INSERT 依次发给数据库,一条一个来回。打个比方,你要寄 1000 封信,攒成一摞交给快递员,快递员还是一封一封跑。省掉的只是你自己写代码的循环,网络往返和 SQL 解析一次没少。
打开 rewriteBatchedStatements=true 之后,驱动会把这 1000 条改写成一条多值 INSERT:
sql
-- 默认:发 1000 次
INSERT INTO t_order(order_no, user_id, amount, status, create_time) VALUES (?, ?, ?, ?, ?)
-- rewriteBatchedStatements=true:合并成 1 次
INSERT INTO t_order(order_no, user_id, amount, status, create_time) VALUES (...), (...), (...)
不管它会怎样:几千行无所谓,一到几十万、上百万行,导入从几秒变几分钟;连的是跨机房的库,每多一次往返就多一份网络延迟,差距会更大。
什么时候该动它:只要你有 executeBatch() 或 MyBatis ExecutorType.BATCH 在写 MySQL,就值得检查一下连接串里有没有这个参数。
二、测试环境和方法
| 项目 | 版本 |
|---|---|
| MySQL | 8.2.0(本机,InnoDB,innodb_flush_log_at_trx_commit=1,max_allowed_packet=64M) |
| 驱动 | mysql-connector-j 9.4.0,另用 8.0.33 复测了参数行为 |
| MyBatis | 3.5.9 |
| JDK | 17.0.12 |
| 机器 | Apple M1 Pro,客户端和数据库在同一台机器 |
表结构是常见的订单表,带自增主键、一个唯一索引和一个普通索引:
sql
CREATE TABLE t_order (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL,
user_id BIGINT NOT NULL,
amount DECIMAL(10,2) NOT NULL,
status TINYINT NOT NULL,
create_time DATETIME NOT NULL,
UNIQUE KEY uk_order_no (order_no),
KEY idx_user (user_id)
) ENGINE=InnoDB;
每种写法开始前删表重建,插入 10 万行,记录耗时,再用 SHOW GLOBAL STATUS LIKE 'Com_insert' 前后相减,看服务端实际执行了多少条 INSERT。同一组跑了两轮,结果接近,下表取第二轮。
⚠️ 客户端和数据库在同一台机器上,网络往返几乎为零,下面的倍数是下限。应用和数据库分属两台机器时,默认模式每条语句都要多一次网络往返,差距只会更大。
三、实测结果:9 种写法插 10 万行
| # | 写法 | 耗时 | 行/秒 | 服务端 INSERT 条数 |
|---|---|---|---|---|
| 1 | JDBC 逐条插,自动提交(只插 5000 行) | 0.76 秒 | 约 6,500 | 5,000 |
| 2 | JDBC 逐条插,一个事务 | 4.50 秒 | 约 22,000 | 100,000 |
| 3 | JDBC addBatch,每 1000 条提交一批,默认参数 |
4.50 秒 | 约 22,000 | 100,000 |
| 4 | 同 3,加 rewriteBatchedStatements=true |
0.98 秒 | 约 103,000 | 100 |
| 5 | 同 4,10 万行攒成一批 | 1.05 秒 | 约 95,000 | 1 |
| 6 | MyBatis <foreach> 拼多值 INSERT,每条 1000 行 |
1.73 秒 | 约 58,000 | 100 |
| 7 | MyBatis ExecutorType.BATCH,默认参数 |
4.59 秒 | 约 22,000 | 100,000 |
| 8 | 同 7,加 rewriteBatchedStatements=true |
1.06 秒 | 约 95,000 | 100 |
| 9 | LOAD DATA LOCAL INFILE 导 CSV |
0.70 秒 | 约 142,000 | 0(不走 INSERT) |
几个结论:
- 第 3 行和第 2 行几乎一样快。 默认参数下
addBatch没有合并,服务端照样收到 10 万条 INSERT,比逐条插只省了一点客户端开销 - 加上参数,同样的代码快了 4.6 倍(4.50 秒 → 0.98 秒),INSERT 条数从 10 万降到 100,正好是批次数
- MyBatis 的 BATCH 模式底层就是
addBatch/executeBatch,所以第 7、8 行和 JDBC 的第 3、4 行表现一致:不开参数一样慢 <foreach>拼 SQL 本身就是多值 INSERT,不依赖这个参数,但比 BATCH + 参数慢一些(1.73 秒 vs 1.06 秒)。差在哪我没有拆开分析,推测是 MyBatis 每次解析动态 SQL、绑定 5000 个参数的开销- 自动提交模式下逐条插最慢,每条都要单独落盘提交,10 万行按这个速度要 15 秒左右(按 5000 行的速度估算)
LOAD DATA最快,但要先写文件,适合离线导入,不适合业务代码里随手用
四、怎么开:三种写法
4.1 直接写在 JDBC URL 上
properties
# application.properties
spring.datasource.url=jdbc:mysql://127.0.0.1:3306/demo?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true
最直接,也最容易排查:打印一下连接池拿到的 URL 就知道有没有生效。
4.2 用 Hikari 的 data-source-properties
不想把参数堆在 URL 里,可以交给连接池转给驱动。Spring Boot 默认的连接池是 HikariCP:
properties
# application.properties:Hikari 会把这些属性原样传给 MySQL 驱动
spring.datasource.hikari.data-source-properties.rewriteBatchedStatements=true
我用 HikariCP 6.3.2 的 addDataSourceProperty("rewriteBatchedStatements", "true") 测过,1000 行批量插入服务端只收到 1 条 INSERT,和写在 URL 上效果一样。Spring Boot 的这个配置项绑定的是 Hikari 的 dataSourceProperties,作用相同;Spring Boot 这一层我没有单独起项目验证。
4.3 代码里的批量写法
参数只对 executeBatch() 起作用,代码要用批量 API。JDBC 写法:
java
// JDBC 批量插入:关自动提交,每 1000 条 executeBatch 一次
try (Connection c = dataSource.getConnection()) {
c.setAutoCommit(false);
try (PreparedStatement ps = c.prepareStatement(
"INSERT INTO t_order(order_no, user_id, amount, status, create_time) VALUES (?, ?, ?, ?, ?)")) {
int i = 0;
for (Order o : orders) {
ps.setString(1, o.orderNo());
ps.setLong(2, o.userId());
ps.setBigDecimal(3, o.amount());
ps.setInt(4, o.status());
ps.setObject(5, o.createTime());
ps.addBatch();
if (++i % 1000 == 0) {
ps.executeBatch();
}
}
ps.executeBatch();
}
c.commit();
}
MyBatis 用 BATCH 执行器,Mapper 里还是普通的单条 insert:
java
// MyBatis BATCH 模式:insertOne 只是攒起来,flushStatements 时才 executeBatch
try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
OrderMapper mapper = session.getMapper(OrderMapper.class);
int i = 0;
for (Order o : orders) {
mapper.insertOne(o);
if (++i % 1000 == 0) {
session.flushStatements();
}
}
session.commit();
}
用 MyBatis-Plus 的同学,saveBatch 走的也是 BATCH 模式加分批提交,一样要开这个参数才会合并。这一条是按它的实现推断的,本文没有测 MyBatis-Plus。
五、参数加了却不生效:先查这几处
我把几种常见情况各跑了一遍,看服务端收到的语句数有没有降下来,9.4.0 和 8.0.33 两个版本的驱动结果一样:
| 情况 | 会合并吗 | 说明 |
|---|---|---|
参数名拼错,写成 rewriteBatchStatements |
❌ | 驱动不报错也不警告,静默按默认处理。最常见 |
用的是 executeUpdate() 循环,没用 addBatch |
❌ | 参数只管 executeBatch() |
UPDATE / DELETE 批量 |
❌ 不会合成一条 | 官方文档说非 INSERT 语句走 multi-queries 方式发送;实测服务端执行的语句数没有减少 |
INSERT ... ON DUPLICATE KEY UPDATE |
✅ | 会合并,但返回值不准,见下文 |
INSERT IGNORE / REPLACE INTO |
✅ | 会合并 |
| SQL 末尾带分号 | ✅ | 这两个驱动版本都能正常合并 |
同时开 useServerPrepStmts=true |
✅ | 照样合并 |
拼错这一条值得多说一句:我在测试里故意写成 rewriteBatchStatements=true,程序正常跑完、数据也都插进去了,只是慢。这种问题只靠看日志是发现不了的,最好在上线前用下面的办法验一次。
5.1 怎么确认真的合并了
在测试库上跑一次批量插入,前后各查一次 Com_insert:
sql
-- 执行批量插入前后各跑一次,看差值
SHOW GLOBAL STATUS LIKE 'Com_insert';
插 10 万行、每批 1000 条,差值是 100 左右说明合并了,是 10 万说明没生效。这是全局计数,生产库上别的业务也在写,要在测试库上看;或者开一会儿 general_log 直接看语句。
六、开了参数之后要注意的三件事
6.1 executeBatch 的返回值变成 -2
开参数后,executeBatch() 返回的数组里每个元素都是 -2(Statement.SUCCESS_NO_INFO,意思是"成功了,但不知道影响几行")。实测 5 行批量插入的返回是:
text
rewrite=true executeBatch result=[-2, -2, -2, -2, -2] generatedKeys=[1, 2, 3, 4, 5]
MyBatis BATCH 模式也一样:flushStatements() 返回的 BatchResult.getUpdateCounts(),不开参数是 [1, 1, 1],开了是 [-2, -2, -2]。
如果你的代码写了"每个元素必须等于 1,否则回滚"这类校验,开参数后会全部判失败。改成按 -2 也算成功,或者事后用 count(*) 对账。INSERT ... ON DUPLICATE KEY UPDATE 的情况官方文档也专门说了:服务端只返回整批的一个总数,驱动没法对应到每条语句。
6.2 自增主键还能拿到
上面那行输出的后半段:getGeneratedKeys() 照样返回了 1 到 5。MyBatis 我也测了 @Options(useGeneratedKeys = true, keyProperty = "id"):<foreach> 多值 INSERT、BATCH 模式不开参数、BATCH 模式开参数三种写法,3 个对象的 id 都回填成了 1、2、3。依赖回填主键的代码不用改。
6.3 一批别攒太大
第 5 行把 10 万行攒成一批,驱动发出去的是 1 条 INSERT。驱动会按 max_allowed_packet 自动拆包,本机是 64M,10 万行没超。但一批太大有两个问题:客户端要把整批对象都留在内存里;单个事务太大,出错回滚和主从同步都慢。实测每批 1000 条已经拿到了几乎全部收益(0.98 秒 vs 1.05 秒),我的建议是每批 500~2000 条,按行宽调。
七、比 rewrite 更快:LOAD DATA
数据本来就是文件(对账单、迁移导出的 CSV),用 LOAD DATA 最快,实测 10 万行 0.70 秒:
sql
-- 客户端读本地文件发给服务端,需要两边都允许
LOAD DATA LOCAL INFILE '/data/import/orders.csv'
INTO TABLE t_order
FIELDS TERMINATED BY ','
(order_no, user_id, amount, status, create_time);
要同时满足:服务端 local_infile=ON(MySQL 8 默认是关的),JDBC URL 加 allowLoadLocalInfile=true。
⚠️
LOCAL模式下是服务端让客户端读文件,连到不可信的数据库时有被读走本地文件的风险,MySQL 官方文档有专门的安全说明。业务代码里一般不开,只在受控的导入工具里用。
八、速查表
| 场景 | 推荐写法 |
|---|---|
| 业务代码批量写几百到几十万行 | addBatch 或 MyBatis BATCH + rewriteBatchedStatements=true,每批 500~2000 |
已经在用 <foreach> 拼 SQL |
能用,不依赖参数;单条别超过几千行,注意 max_allowed_packet |
| 离线导入大文件 | LOAD DATA,导完再建二级索引会更快(本文未测) |
| 检查参数是否生效 | 测试库上看 Com_insert 前后差值 |
| 批量 UPDATE | 参数帮不上,考虑临时表 + JOIN 更新,或 INSERT ... ON DUPLICATE KEY UPDATE |
九、常见问题
Q:开了这个参数有没有风险?
普通的 PreparedStatement 批量插入没发现问题,主键回填也正常。要注意的是返回值变成 -2,以及官方文档提到的:用普通 Statement 拼字符串再批量执行时,合并发送可能放大 SQL 注入的影响。参数化写法不受影响。
Q:<foreach> 和 BATCH 模式选哪个?
我更倾向于 BATCH 模式加参数:本次实测更快,Mapper 只写单条 insert,SQL 长度不会随数据量变。<foreach> 的好处是不依赖连接参数,换个连接池、换个人配环境也不会失效。
Q:TiDB、PolarDB 这类兼容 MySQL 的库也要开吗?
它们用的也是 MySQL 驱动,参数对驱动行为的影响是一样的。服务端对多值 INSERT 的处理各家不同,本文只测了 MySQL 8.2,换库后建议用 5.1 节的方法自己测一下。
参考链接
- MySQL Connector/J 官方文档:Performance Extensions(rewriteBatchedStatements)
- MySQL 8.0 参考手册:Optimizing INSERT Statements
- MySQL 8.0 参考手册:LOAD DATA Statement
- MySQL 8.0 参考手册:Security Considerations for LOAD DATA LOCAL
- MyBatis 3 官方文档:Java API(ExecutorType.BATCH)
你们项目的 JDBC URL 里现在有
rewriteBatchedStatements=true吗?批量插入用的是<foreach>、BATCH 模式,还是 MyBatis-Plus 的saveBatch?评论区说说你们一批插多少行,我把大家的经验值补进速查表。觉得有用的话点个赞、收个藏,下次做数据导入时翻得到。