JDBC 批量插入优化:从 1000 次网络 IO 到 1 次
一句话结论(30s)
批量插入的 100 倍提速不是魔法,而是三步物理规律的叠加——executeBatch 把 N 次网络往返压成 1 次(30x)、rewriteBatchedStatements=true 把 N 次 SQL 解析合成 1 次(再 3x,累积 100x)、分批事务减少 redo log 刷盘——因为单条插入 90% 时间耗在网络延迟和 SQL 解析,而不是真正执行。
核心原理(2min)
- 单条为什么慢:10000 条数据 = 10000 次完整往返,网络延迟(0.5~2ms)+ SQL 解析占 90% 以上时间。
- executeBatch:
addBatch × N后一次executeBatch,网络往返 1 次,30x 提升。 - rewriteBatchedStatements=true:驱动把多条 INSERT 重写成
VALUES (1),(2),(3)真正一条 SQL,解析也只 1 次,累积 100x。 - 两个坑:MyBatis foreach 拼接要防
max_allowed_packet超限(每 500-1000 条分批)与${}注入;事务逐条提交会刷 redo log,应每 500 条一个事务。
底层深入(5-10min)
一个真实的对比
需求:导入 10000 条用户数据。两种写法,两段代码,两个数量级的差距。
// 写法一:单条插入(新手写法)
for (User user : users) {
jdbcTemplate.update("INSERT INTO t_user(name, email) VALUES(?, ?)",
user.getName(), user.getEmail());
}
// 耗时:~15000ms(约 0.67 条/ms)
// 写法二:批量插入
String sql = "INSERT INTO t_user(name, email) VALUES(?, ?)";
try (PreparedStatement ps = conn.prepareStatement(sql)) {
for (User user : users) {
ps.setString(1, user.getName());
ps.setString(2, user.getEmail());
ps.addBatch();
}
ps.executeBatch();
}
// 耗时:~450ms(约 22 条/ms)
30 倍以上的性能差距。 这不是魔法——这是”网络往返”的基本物理规律。
想一想:为什么
executeBatch能快 30 倍? 因为单条插入的耗时大头不是”数据库执行”,而是”网络往返 + SQL 解析”。每一条INSERT都要从应用走到 MySQL、再走回来,10000 条就是 10000 次往返;executeBatch把 10000 条攒在一起、一次网络往返送过去,把 10000 次”路上的时间”压缩成 1 次——这就是 30 倍的来源。
为什么单条插入这么慢?
一次 INSERT 语句的执行链路:
Java 应用 → JDBC Driver → 网络发送 → MySQL Server → 解析 SQL → 执行 → 返回结果 → 网络返回 → JDBC Driver → Java 应用
10000 条数据就是 10000 次完整的往返——其中网络延迟(每个往返约 0.5~2ms)和 SQL 解析成本占了 90% 以上的时间。而批量插入:
Java 应用 → addBatch × 10000 → executeBatch → 网络发送一次 → MySQL 解析一次 → 批量执行 → 返回一次
核心收益:把 N 次网络往返压缩为 1 次,把 N 次 SQL 解析压缩为 1 次。
MyBatis 的 foreach 标签:方便但有暗坑
<insert id="batchInsert">
INSERT INTO t_user(name, email) VALUES
<foreach collection="list" item="user" separator=",">
(#{user.name}, #{user.email})
</foreach>
</insert>
生成的 SQL:
INSERT INTO t_user(name, email) VALUES
('张三', '[email protected]'),
('李四', '[email protected]'),
...
('王五', '[email protected]')
一条 SQL 搞定,10000 条数据只需一次网络往返。比 JDBC 原生的 executeBatch 更简洁——MyBatis 在驱动层做了 SQL 拼接。
foreach 的两个暗坑
坑一:SQL 长度限制。 MySQL 的 max_allowed_packet 默认 64MB,单条 SQL 不能超过这个值。10000 条数据拼接成一条 INSERT 可能超过限制。解决:每 500-1000 条分批。
// 每 500 条执行一次
List<User> batch = new ArrayList<>(500);
for (User user : users) {
batch.add(user);
if (batch.size() >= 500) {
mapper.batchInsert(batch);
batch.clear();
}
}
坑二:SQL 注入风险。 虽然 MyBatis 的 #{} 会做预编译占位(防注入),但 foreach 拼接的值如果直接用 ${} 就有注入风险。绝不要在 foreach 中用 ${} 拼接用户输入的值。
想一想:为什么
max_allowed_packet是 foreach 拼接的暗坑? 因为 foreach 把 10000 条拼成一条超长 SQL,体积可能超过 MySQL 的max_allowed_packet(默认 64MB),语句会被服务端直接拒绝。所以拼接式批量不能无脑一次性全拼,要按 500-1000 条分批,把单条 SQL 的体积控制在限制之内。
rewriteBatchedStatements=true:驱动层的最后一块拼图
即使使用了 addBatch() + executeBatch(),MySQL JDBC Driver 默认也不会真的重写成一条 SQL。它会逐条发送:INSERT INTO t VALUES(1);INSERT INTO t VALUES(2);... ——网络往返是少了,但 SQL 解析次数没变。
在 JDBC URL 中加上这个参数:
jdbc:mysql://localhost:3306/db?rewriteBatchedStatements=true
驱动会在 executeBatch() 时,自动把多条 INSERT 重写成 INSERT INTO t VALUES (1),(2),(3)... 的形式——真正的一条 SQL,解析也只需一次。
实测对比(10000 条数据)
| 方案 | 耗时 | vs 单条 | 网络往返 | SQL解析 |
|---|---|---|---|---|
| 单条 for 循环 | 15000ms | 1x | 10000次 | 10000次 |
| executeBatch(不加 rewrite) | 2000ms | 7.5x | 1次 | 10000次 |
| executeBatch + rewrite | 150ms | 100x | 1次 | 1次 |
| foreach 拼接 | 180ms | 83x | 1次 | 1次 |
executeBatch + rewrite 是理论上的最优解——SQL 在服务端仍走预编译(Prepare Statement),安全性不受影响。
想一想:为什么加了
rewriteBatchedStatements=true还能再快一截? 因为不加它时,驱动只是”少了几次网络往返”,但每条 INSERT 仍是独立 SQL,MySQL 要解析 10000 次;加了之后驱动把多条 INSERT 重写成VALUES (1),(2),(3)...一条 SQL,解析也从 10000 次降到 1 次。网络往返和 SQL 解析是两个独立的成本,所以要分两步各砍一刀,而不是指望一个参数全包。
事务管理:批量操作的生命线
批量插入 10000 条,如果第 9000 条失败了怎么办?
// 方案一:全部成功或全部回滚(一致性优先)
@Transactional
public void batchInsert(List<User> users) {
for (User user : users) {
mapper.insert(user);
}
}
// 方案二:逐条提交,失败跳过(可用性优先)
public void batchInsertBestEffort(List<User> users) {
for (User user : users) {
try {
mapper.insert(user);
} catch (Exception e) {
log.error("插入失败: {}", user, e);
// 记录到死信表,后续人工处理
}
}
}
方案二还需要注意:如果每条 INSERT 单独一个事务,10000 条数据 → 10000 次事务提交(每次 flush redo log)→ 性能急剧下降。分批事务是折中:每 500 条一个事务,既保证部分失败不影响整体(粒度可控),又避免逐条提交的 IO 开销。
想一想:为什么逐条提交会拖慢批量插入? 因为每次 commit 都要把事务的 redo log 刷到磁盘(保证持久性),这是最贵的 IO 操作之一。10000 条逐条提交 = 10000 次刷盘;每 500 条一个事务 = 20 次刷盘,IO 成本直接砍了两个数量级。这也解释了为什么”分批事务”是批量插入的生命线。
总结
批量插入的优化路径只有三步,每一步都能带来数量级的提升:
- 减少网络往返:
for→executeBatch(30x) - 减少 SQL 解析:加
rewriteBatchedStatements=true(3x,累积 100x) - 减少事务提交次数:逐条提交 → 分批事务(视场景 2-5x)
100 倍的性能提升不是传说——这是网络和数据库底层机制决定的物理规律。写批量插入的时候,脑子里想想那个”数据包在路上跑”的画面,你就知道每一层优化在做什么了。
章末提问
Q1:executeBatch 为什么能提速,本质减少了什么?
结论先行:本质是把 N 次网络往返压缩成 1 次,把”数据包在路上跑”的时间省掉。
因为单条插入 90% 以上时间耗在网络延迟和 SQL 解析,而不是真正执行;addBatch × N 后一次 executeBatch,让 N 条 INSERT 只走一次网络、MySQL 只收一次。注意:此时每条仍是独立 SQL,SQL 解析次数没变,所以只有约 30 倍的提升。
Q2:rewriteBatchedStatements=true 做了什么,和 executeBatch 有什么区别?
结论先行:它把多条 INSERT 重写成 VALUES (1),(2),(3) 一条 SQL,进一步把 N 次 SQL 解析压成 1 次。
因为默认驱动即使收到 executeBatch 也是逐条发送,网络往返少了但解析还是 N 次;加了 rewriteBatchedStatements=true 后,驱动在 executeBatch 时合成真正的一条 SQL,解析也只 1 次,累积到 100 倍。两者是叠加关系:executeBatch 砍网络,rewrite 砍解析。
Q3:批量插入 10000 条,第 9000 条失败了怎么办?事务怎么设计? 结论先行:折中方案是”每 500 条一个事务”——粒度可控、失败只影响一批,又避免逐条提交的 IO 开销。 因为全部一个事务意味着 1 条失败全部回滚(一致性优先但粒度太粗),逐条提交又意味着 10000 次 redo log 刷盘(性能灾难)。每 500 条一个事务,把”失败的影响范围”和”刷盘次数”都控制在可接受的水平;若要求可用性优先,则逐条 try-catch 把失败记录到死信表。