2115 字
约 7 分钟
11
S08-A27 · 批量模式:为什么"开了 BATCH"还是慢

一段导入代码逐条 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. 自测

  1. ExecutorType.BATCH 具体省掉了什么?为什么它本身不能保证快 20 倍?
  2. rewriteBatchedStatements 做了什么?为什么它与 useGeneratedKeys 互斥?
  3. "循环里查不到刚插入的行"涉及哪两层机制?为什么后续查询也会错?
  4. 为什么循环里交替插入两张表会让批量退化?正确做法是什么?
  5. 批量为什么必须与"一个事务包住整批"配合?批大小的甜点区由什么决定?
  6. 需要回填自增主键时,有什么办法既保留批量又不逐条?

9. 一句话总结

BATCH 改的是"SQL 什么时候发",不是"发得多快":它把语句攒在本地,等 flushStatements 才执行;真正合并成多 values 的是驱动,需要 rewriteBatchedStatements=true,而 useGeneratedKeys 会让合并失效。三个抵消项——驱动未合并、事务未合并、每批过大——任一存在,看到的就只是"快了一点"。配套纪律:先 flush 再校验(否则一级缓存把空结果缓存住)、分组批处理(语句交替会强制 flush)、批大小 500–2,000。

S08-A27 · 批量模式:为什么"开了 BATCH"还是慢
https://www.clxhxhhr.top/posts/6751/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。