一段导入代码逐条 insert 一万条,耗时 28 秒。按经验改成 ExecutorType.BATCH 之后降到 11 秒——但同一条 SQL 在别的项目里只要 1.2 秒。更麻烦的是:批处理循环里"插一条、查一条"的校验逻辑全部失效,查不到刚插入的记录。
两个现象指向同一件事:BATCH 模式改变的是"SQL 什么时候发出去",不是"发出去有多快"。它把语句攒在本地,等 flush 时才真正执行;而攒起来之后发得多快,取决于驱动层的另一个开关。
1. BATCH 攒的是"语句",不是"并行"
| 模式 | 每条 insert 的行为 | 往返次数(1 万条) |
|---|---|---|
SIMPLE |
立即发送、立即执行 | 10,000 |
REUSE |
复用 PreparedStatement,仍逐条发 | 10,000 |
BATCH |
攒在本地,flushStatements 时批量发 |
取决于驱动是否合并 |
第三行的"取决于"就是那 11 秒与 1.2 秒的差别:MyBatis 的 BATCH 只是把语句排进队列,真正合并成一条多 values 的 INSERT 是 MySQL 驱动的事,且需要显式开启。
// 三种组合的实际往返次数
BATCH 但未开 rewriteBatchedStatements: 1 万条 → 仍是 1 万次往返(只是省了 round-trip 的解析)
BATCH + rewriteBatchedStatements=true: 1 万条 → 约 100 次(每 100 条拼一条)
BATCH + 多 values 手写: 1 万条 → 1 次(一条 SQL 一万个 values,需分批)
// 差别不是 20%,是一个数量级
2. 四个条件缺一不可
| 条件 | 不满足时 |
|---|---|
rewriteBatchedStatements=true |
合并失效,逐条发送 |
同一 SqlSession |
跨 session 会各自 flush |
| 同一语句(同 SQL、同参数形状) | 不同 SQL 交替会强制 flush |
关闭 useGeneratedKeys |
需逐条回填自增 id,合并失效 |
第四行最容易被忽略:开了 useGeneratedKeys 就要求每条 insert 返回自己的主键,驱动因此不能把它们合并成一条语句。这是"批量导入关联表"时最常见的性能悬崖。
<!-- 批量插入的 mapper:注意 useGeneratedKeys 与批量是互斥的 -->
<insert id="insertBatch" useGeneratedKeys="false">
INSERT INTO t_log (id, msg, ts) VALUES
<foreach collection="list" item="it" separator=",">
(#{it.id}, #{it.msg}, #{it.ts})
</foreach>
</insert>
<!-- 若必须回填 id,就只能逐条或分批(每批 500 条,可接受) -->
3. "查不到刚插入的":BATCH 与一级缓存的两层错位
| 现象 | 机制 |
|---|---|
循环里 insert 后 select 查不到 |
BATCH 的写还在本地队列,未 flush |
| 同一 session 二次查询返回旧值 | 一级缓存在 flush 前就缓存了"空结果" |
换 SqlSession 后又查到了 |
新 session 走了新连接,并且触发了 flush |
第二行的连锁最阴:一次"查不到"会把空结果写进一级缓存,于是后续同一 session 的相同查询全部返回空,直到 flush 或 session 结束。这类 bug 在测试里往往表现为"偶发"。
// 错误写法:批处理循环里做校验
for (Item it : items) {
mapper.insert(it); // 进本地队列
if (mapper.exists(it.id) == 0) throw ...; // 查不到 → 误判 → 且污染一级缓存
}
// 正确:先攒完,flush 后再校验;或校验改成内存去重
sqlSession.flushStatements();
4. Spring 里的三个退化点
| 写法 | 结果 | 为什么 |
|---|---|---|
SqlSessionTemplate + ExecutorType.BATCH |
有效 | 同一线程绑定同一 session |
| 循环里调两个不同 mapper | 可能退化 | 不同语句交替 → 每次切换都 flush |
混用 JdbcTemplate |
退化 | 另一条连接,与 MyBatis 批队列无关 |
在 @Transactional 外循环 |
每条一个事务 | 等于没批量 |
第二行是"批量写着写着就不快了"的常见原因:循环里既插 A 表又插 B 表,语句交替触发 flush,合并率断崖下降。正确做法是分组:先把 A 表全攒完 flush,再处理 B 表。
// 分组批处理:一次只做一种语句,flush 后再切
try (SqlSession s = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
ItemMapper a = s.getMapper(ItemMapper.class);
for (Item it : items) a.insert(it); // 只做 insert
s.flushStatements(); // 一次 flush
ItemMapper q = s.getMapper(ItemMapper.class);
for (Item it : items) if (q.exists(it.id) == 0) throw new IllegalStateException();
}
5. 还有一层:事务与 binlog
| 配置 | 影响 |
|---|---|
innodb_flush_log_at_trx_commit=1 |
每次提交都 fsync,批量收益被提交次数吃掉 |
| 每条一个事务 | 1 万次 fsync,批量再快也没用 |
| 大事务(10 万行) | undo 膨胀、锁持有久、主从延迟 |
| binlog 格式 ROW | 大事务产生巨大 binlog |
第二行是最常见的"批了但还是慢":SQL 合并了,事务却没合并。批量必须与"一个事务包住整批"配合,否则省下的往返又被 fsync 拿走。
第四行给出上界:一批不宜过大。实践中的甜点区是 500–2,000 条/批,兼顾往返次数、undo 大小与主从延迟。
6. 排错:五种"批了没快"
| 现象 | 八成原因 | 处理方向 |
|---|---|---|
| 从 28s 只降到 11s | 未开 rewriteBatchedStatements |
加连接参数 |
| 循环里查不到刚插的 | 未 flush + 一级缓存污染 | 先 flush 再校验 |
| 两表交替插入变慢 | 语句交替强制 flush | 分组批处理 |
| 每批都失败重试 | 单批过大超 max_allowed_packet |
降到 500–2000 |
| 主从延迟暴涨 | 大事务 | 减小批大小或加 --sleep |
第一行就是开头那个 11 秒:BATCH 省的是驱动层的往返开销,不是网络往返本身。加一行参数就能拿到剩下的 10 倍。
// 一个可直接照抄的连接串(批量场景)
jdbc:mysql://host:3306/db?rewriteBatchedStatements=true&useServerPrepStmts=false
&cachePrepStmts=true&allowMultiQueries=false&useGeneratedKeys=false
// 关键:前两个一起开才有效;useGeneratedKeys 关掉才能真正合并
7. 边界:什么情况批量不适用
| 场景 | 为什么 | 做法 |
|---|---|---|
| 需要每条返回自增 id | 合并失效 | 分批 500 或改用预分配 id |
| 单条失败要独立回滚 | 批量是一损俱损 | 逐条 + 捕获 |
| 写入有唯一键冲突需逐条处理 | 批量报错不指明哪条 | 分批 + 冲突定位 |
| 数据量很小(< 100) | 收益低于复杂度 | 直接逐条 |
| 实时性要求高(写完立即可见) | 需 flush | 直接逐条 |
第一行的解法很实用:预分配 id(雪花/uuid)后就不需要 useGeneratedKeys,批量与主键可以兼得。这也是"批量导入为什么常用 UUID"的真实原因之一——不是为了分布式,是为了绕开自增回填。
判断口径一句话:批量的收益 = 往返次数减少量,而它被三个东西抵消:驱动未合并、事务未合并、每批过大——三者任一存在,看到的就只是"快了一点"。判据是"这条链路上还剩几个往返",而不是"我开了 BATCH 没有"。
8. 自测
ExecutorType.BATCH具体省掉了什么?为什么它本身不能保证快 20 倍?rewriteBatchedStatements做了什么?为什么它与useGeneratedKeys互斥?- "循环里查不到刚插入的行"涉及哪两层机制?为什么后续查询也会错?
- 为什么循环里交替插入两张表会让批量退化?正确做法是什么?
- 批量为什么必须与"一个事务包住整批"配合?批大小的甜点区由什么决定?
- 需要回填自增主键时,有什么办法既保留批量又不逐条?
9. 一句话总结
BATCH 改的是"SQL 什么时候发",不是"发得多快":它把语句攒在本地,等 flushStatements 才执行;真正合并成多 values 的是驱动,需要 rewriteBatchedStatements=true,而 useGeneratedKeys 会让合并失效。三个抵消项——驱动未合并、事务未合并、每批过大——任一存在,看到的就只是"快了一点"。配套纪律:先 flush 再校验(否则一级缓存把空结果缓存住)、分组批处理(语句交替会强制 flush)、批大小 500–2,000。