MyBatis 如何执行批量操作?
结论先行:MyBatis 批量有三种层次:<foreach> 拼多值 INSERT(一条 SQL 插多条,简单直接)、ExecutorType.BATCH 复用 PrepareStatement 逐条 addBatch(适合大量更新/插入)、JDBC 原生 addBatch。若用 MySQL 还必须在 JDBC URL 加 rewriteBatchedStatements=true,否则驱动仍会逐条发,批量等于白做。
1. 三种方式对比
| 方式 | SQL 形态 | 适用 | 优点 | 缺点 |
|---|---|---|---|---|
<foreach> 多值 INSERT | 一条 INSERT ... VALUES (...),(...) | 几百到几千行的一次性导入 | 网络往返最少 | SQL 长度受 max_allowed_packet 限制;无法逐条返回自增 ID |
ExecutorType.BATCH | N 条 INSERT,攒批发送 | 大量行的写入、更新 | 内存与网络都可控 | 需手动 flushStatements();期间查询会强制 flush |
JDBC addBatch | 同 BATCH | 手写 JDBC 场景 | 最底层、最可控 | 样板代码多 |
2. foreach 多值插入
<insert id="insertBatch" useGeneratedKeys="false">
INSERT INTO t_order_item (order_id, sku_id, count, price)
VALUES
<foreach collection="items" item="it" separator=",">
(#{it.orderId}, #{it.skuId}, #{it.count}, #{it.price})
</foreach>
</insert>
public int insertBatch(@Param("items") List<OrderItem> items) {
// 分批,避免单条 SQL 过长
int total = 0;
for (List<OrderItem> part : Lists.partition(items, 500)) {
total += mapper.insertBatch(part);
}
return total;
}
一条 SQL 插入 500 行时,MySQL 只需一次解析、一次网络往返,通常是三种方式里最快的。但要注意:useGeneratedKeys 在多值 INSERT 上取回自增 ID 的行为依赖驱动版本,不可靠。
3. ExecutorType.BATCH:真正的批处理
@Service
public class OrderImportService {
private final SqlSessionFactory sqlSessionFactory;
public OrderImportService(SqlSessionFactory sqlSessionFactory) {
this.sqlSessionFactory = sqlSessionFactory;
}
public void importOrders(List<Order> orders) {
// 必须手动提交:BATCH 模式下不会自动 commit
try (SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
OrderMapper mapper = session.getMapper(OrderMapper.class);
for (int i = 0; i < orders.size(); i++) {
mapper.insert(orders.get(i));
if (i % 500 == 0) { // 每 500 条刷一次
session.flushStatements();
}
}
session.flushStatements();
session.commit(); // 一次性提交
}
}
}
Spring 下用 SqlSessionTemplate 指定执行器:
@Bean
public SqlSessionTemplate batchSqlSession(SqlSessionFactory factory) {
return new SqlSessionTemplate(factory, ExecutorType.BATCH);
}
关键点:ExecutorType.BATCH 的 BatchExecutor 会把 Statement 缓存起来,只有调用 flushStatements() 或事务提交时才真正 executeBatch()。在批处理过程中执行查询会强制 flush,因为查询需要看到最新数据。
4. 为什么必须加 rewriteBatchedStatements
MySQL 驱动默认的 PreparedStatement.addBatch() 只是把参数攒在客户端,executeBatch() 时仍然逐条发送 INSERT。开启重写后,驱动会把多条 INSERT 合并成一条多值语句。
# 关键参数:开启批量重写 + 允许一次性发送
jdbc:mysql://db:3306/order?rewriteBatchedStatements=true&useServerPrepStmts=false&cachePrepStmts=true
| 参数 | 作用 |
|---|---|
rewriteBatchedStatements=true | 把批量 INSERT/UPDATE 重写成多值形式 |
useServerPrepStmts=false | 客户端预编译,配合批量重写效果更好 |
cachePrepStmts=true | 缓存预编译语句,减少解析 |
allowMultiQueries=true | 允许一次发送多条语句(部分场景需要) |
不开 rewriteBatchedStatements 时,10 万条 INSERT 就是 10 万次网络交互,耗时相差一个数量级。
5. 事务与内存边界
批量操作必须放在一个事务里:BATCH 模式的 commit() 是延迟的,中途失败会整批回滚(如果你没有分段提交)。同时要注意内存:10 万条对象一次性加载进 JVM 容易 OOM,正确姿势是「分页读 + 分批写 + 每批提交」。
int pageSize = 1000;
long lastId = 0;
while (true) {
List<Order> page = orderMapper.selectAfterId(lastId, pageSize);
if (page.isEmpty()) break;
orderImportService.importOrders(page); // 每批独立事务
lastId = page.get(page.size() - 1).getId();
}
6. 常见追问
追问一:BATCH 模式下能拿到自增主键吗?
能,但只在 flushStatements() 之后。BatchExecutor 在刷批时通过 getGeneratedKeys() 回填,所以在 flush 前读取 order.getId() 仍是 null。
追问二:BATCH 模式下为什么查询会变慢?
因为每次查询前都要 flushStatements(),把攒的批次先发出去,等于打乱了批处理节奏。批处理过程中应避免混入查询。
追问三:foreach 一次拼多少条合适?
经验值是 500~1000 行,或保证单条 SQL 不超过 1MB。具体看 max_allowed_packet 和列宽,超过就分批。
追问四:批量 UPDATE 也可以用 foreach 吗?
可以,但需要 CASE WHEN 写法,SQL 会很长且索引利用不一定好。大量更新建议 ExecutorType.BATCH + 主键条件。
追问五:MyBatis-Plus 的 saveBatch 是什么原理?
它内部就是 ExecutorType.BATCH + 每 1000 条 flushStatements() 一次,默认批次大小可在 MybatisPlusProperties 里调。它不是「一条 SQL 插多条」,所以仍需 rewriteBatchedStatements 才能发挥最大性能。
小结
批量操作的选型只有一句话:数据量小、要一条 SQL 完成 → <foreach>;数据量大、要可控内存与事务 → ExecutorType.BATCH + 分批提交。而在 MySQL 上,无论哪种方式,都别忘了 rewriteBatchedStatements=true,否则批量只是「看起来批量」。