MyBatis 批量操作
批量操作不是“把 for 循环换成一个大 SQL”这么简单。它真正解决的是:大量数据写入时,如何减少网络往返、减少 SQL 解析次数、控制事务锁时间、控制内存、处理局部失败,并且让数据能稳定落库。
商业系统里常见批量场景包括:医疗设备采集记录入库、资产台账导入、订单明细批量生成、消息消费批量落库、权限关系批量绑定、定时任务批量修复状态。面试问批量操作时,面试官真正想听的不是“用 foreach”,而是你是否理解批处理背后的性能、事务和失败边界。
学习目标
学完这一章要能说清楚:
- 普通 for 循环逐条插入为什么慢。
- MyBatis
foreach批量插入为什么快,又为什么不能无限大。 ExecutorType.BATCH和 JDBC batch 的工作方式。- MySQL
rewriteBatchedStatements=true为什么会明显影响性能。 - 批大小为什么要压测,而不是凭感觉写 10000。
- 批量导入事务应该怎么切分,失败行怎么处理。
- 批量写入为什么可能导致锁等待、日志膨胀和连接池耗尽。
- 面试中如何回答批量插入优化、批量更新、部分失败和幂等问题。
为什么普通 for 循环慢
最直观的写法是循环调用单条 insert。
@Transactional(rollbackFor = Exception.class)
public void importStudents(List<Student> students) {
for (Student student : students) {
studentMapper.insert(student);
}
}对应 XML:
<insert id="insert">
insert into student(name, age)
values(#{name}, #{age})
</insert>它的问题不是“Java for 循环慢”,而是每条数据都会经历一次完整 SQL 执行链路。
flowchart TD
A["循环第 1 条数据"] --> B["发送 insert 到数据库"]
B --> C["数据库解析和执行"]
C --> D["返回影响行数"]
D --> E["循环第 2 条数据"]
E --> F["再次发送 insert"]
F --> G["重复网络往返和执行"]如果插入 10 万条,就可能产生 10 万次 Mapper 调用、10 万次 JDBC 执行、10 万次网络往返。即使在同一个事务中,数据库不用每条都提交,网络和执行开销仍然巨大。
不这样优化会怎样?
- 接口耗时很长,请求线程长期占用。
- 数据库连接长期不释放,连接池容易被打满。
- 一个大事务持锁时间长,影响其他业务写入。
- 出错回滚成本大,10 万条全部回滚会拖慢数据库。
- 消息消费场景中,消费速度赶不上生产速度,形成消息堆积。
三种常见批量方式对比
| 方式 | 本质 | 优点 | 风险 | 适用场景 |
|---|---|---|---|---|
| for 循环逐条 insert | 多次执行单条 SQL | 写法简单 | 网络往返多,吞吐低 | 少量数据、管理后台低频操作 |
foreach 拼多 values | 一条 SQL 插入多行 | SQL 次数少,直观 | SQL 过长、参数过多、单次事务压力大 | 几百到几千行一批 |
ExecutorType.BATCH | JDBC 批处理 | 复用预编译语句,适合大量写入 | 要控制 flush、事务、内存、错误处理 | 大批量导入、消息批量落库 |
方式一:for 循环逐条写入
public int singleInsert(List<Student> students) {
int count = 0;
for (Student student : students) {
count += studentMapper.insert(student);
}
return count;
}这个方式可以用于几十条以内的数据,优点是简单、容易定位失败数据。但数据量上来以后,它会把数据库访问次数放大。
商业项目里如果必须逐条处理,通常是因为每条数据都有复杂校验、每条失败要独立记录、或者业务要求部分成功部分失败。这时也不应该无脑一个大事务,而应该设计“校验阶段 + 分批落库 + 错误明细表”。
方式二:foreach 拼接多 values
Mapper:
int batchInsertByForeach(@Param("list") List<Student> students);XML:
<insert id="batchInsertByForeach">
insert into student(name, age)
values
<foreach collection="list" item="item" separator=",">
(#{item.name}, #{item.age})
</foreach>
</insert>最终 SQL 类似:
insert into student(name, age)
values (?, ?), (?, ?), (?, ?)为什么它比 for 循环快?因为多行数据合并成一条 SQL,减少了网络往返和数据库执行次数。
flowchart TD
A["Java 集合 1000 条"] --> B["foreach 生成一条 insert values SQL"]
B --> C["ParameterHandler 绑定 2000 个参数"]
C --> D["数据库一次执行多行插入"]
D --> E["返回影响行数"]但 foreach 不是越大越好。假设每行 20 个字段,10000 行就是 200000 个参数,SQL 文本非常长,可能出现:
- SQL 包超过数据库或驱动限制。
- SQL 解析时间变长。
- MySQL
max_allowed_packet超限。 - 单个事务持锁时间过长。
- 失败后整批回滚,难以定位哪条数据有问题。
所以 foreach 常见做法是分批。
@Transactional(rollbackFor = Exception.class)
public void importStudents(List<Student> students) {
int batchSize = 500;
for (int i = 0; i < students.size(); i += batchSize) {
int end = Math.min(i + batchSize, students.size());
studentMapper.batchInsertByForeach(students.subList(i, end));
}
}方式三:ExecutorType.BATCH
ExecutorType.BATCH 底层会把多次相同语句的参数累积起来,最后统一 flush 到数据库。
public void batchInsertByExecutor(List<Student> students) {
SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
try {
StudentMapper mapper = sqlSession.getMapper(StudentMapper.class);
int batchSize = 500;
for (int i = 0; i < students.size(); i++) {
mapper.insert(students.get(i));
if ((i + 1) % batchSize == 0) {
sqlSession.flushStatements();
sqlSession.clearCache();
}
}
sqlSession.flushStatements();
sqlSession.commit();
} catch (Exception e) {
sqlSession.rollback();
throw e;
} finally {
sqlSession.close();
}
}执行链路可以这样理解:
flowchart TD
A["mapper.insert 第 1 次"] --> B["BatchExecutor 记录 SQL 和参数"]
B --> C["mapper.insert 第 2 次"]
C --> D["相同 SQL 继续追加参数"]
D --> E["达到 batchSize 或手动 flush"]
E --> F["JDBC executeBatch"]
F --> G["数据库批量执行"]
G --> H["commit 或 rollback"]flushStatements() 很重要。如果一直不 flush,大量参数和批处理结果会堆在内存中。真实导入几十万行时,不 flush 可能导致 JVM 内存升高,甚至 OOM。
rewriteBatchedStatements=true 是什么
MySQL JDBC 驱动中经常看到这个配置:
spring.datasource.url=jdbc:mysql://localhost:3306/demo?rewriteBatchedStatements=true它的作用是让驱动把批量参数改写成数据库更容易高效执行的形式。没有这个参数时,很多情况下虽然 Java 侧调用了 addBatch / executeBatch,驱动仍可能按多条语句发送,吞吐提升有限。
有了它以后,类似多次:
insert into student(name, age) values (?, ?)
insert into student(name, age) values (?, ?)
insert into student(name, age) values (?, ?)可能被驱动改写成:
insert into student(name, age)
values (?, ?), (?, ?), (?, ?)注意:它不是 MyBatis 的配置,而是 MySQL JDBC 驱动层配置。换数据库后是否有效,要看对应驱动能力。
foreach 和 ExecutorType.BATCH 怎么选
| 问题 | 更偏 foreach | 更偏 ExecutorType.BATCH |
|---|---|---|
| 数据量 | 几百到几千 | 几千到几十万 |
| SQL 形态 | 一条多 values 能表达 | 单条 SQL 重复执行 |
| 错误定位 | 整条 SQL 失败,定位较粗 | 可结合批次定位 |
| SQL 长度 | 容易变长 | SQL 模板短,参数批量 |
| 驱动优化 | 不依赖 batch 改写 | 依赖 JDBC batch 能力 |
| 代码复杂度 | 较简单 | 需要手动 flush/commit |
常见经验:
- 后台小批量导入:
foreach分批足够。 - 采集系统高吞吐落库:优先考虑
ExecutorType.BATCH或原生 JDBC batch。 - 数据量特别大:考虑导入工具、临时表、分区表、离线任务,不要只靠接口同步导入。
批量更新怎么做
批量更新比批量插入更复杂,因为每行更新条件和值可能不同。
写法一:循环 update + BatchExecutor
public void batchUpdateStatus(List<AssetStatusChange> changes) {
SqlSession session = sqlSessionFactory.openSession(ExecutorType.BATCH, false);
try {
AssetMapper mapper = session.getMapper(AssetMapper.class);
for (AssetStatusChange change : changes) {
mapper.updateStatus(change);
}
session.flushStatements();
session.commit();
} catch (Exception e) {
session.rollback();
throw e;
} finally {
session.close();
}
}<update id="updateStatus">
update asset
set status = #{status},
update_time = #{updateTime}
where id = #{id}
</update>这种方式可读性好,适合每行条件不同的批量更新。
写法二:case when 批量更新
<update id="batchUpdateStatusByCase">
update asset
set status = case id
<foreach collection="list" item="item">
when #{item.id} then #{item.status}
</foreach>
end,
update_time = now()
where id in
<foreach collection="list" item="item" open="(" separator="," close=")">
#{item.id}
</foreach>
</update>这种方式 SQL 一次完成,但 SQL 会变长,而且复杂字段多时可读性下降。它适合小批量状态修正,不适合超大批量。
批大小怎么估算
批大小没有通用固定值,必须压测。可以从 500 或 1000 开始,观察这些指标:
| 指标 | 看什么 |
|---|---|
| 单批耗时 | 是否稳定,是否有长尾 |
| 数据库 CPU | SQL 解析和执行是否过高 |
| redo/binlog/事务日志 | 写日志压力是否明显 |
| 锁等待 | 是否影响在线业务 |
| 连接池 | 连接是否长期占用 |
| JVM 内存 | 批处理参数和对象是否堆积 |
| 错误定位 | 一批失败后能否定位问题数据 |
批大小过小,网络往返多;批大小过大,事务和内存压力大。生产值应该来自压测,而不是复制别人项目的数字。
事务边界怎么设计
批量导入最容易犯的错误是:十万条数据一个事务。
@Transactional(rollbackFor = Exception.class)
public void importAll(List<Row> rows) {
for (Row row : rows) {
mapper.insert(row);
}
}这样做的风险:
- 事务时间太长,锁和连接一直占用。
- binlog/redo/undo 或事务日志压力大。
- 中途失败时全部回滚,用户体验差。
- 无法清楚记录哪些成功、哪些失败。
更适合商业导入的模型:
flowchart TD
A["上传导入文件"] --> B["创建导入任务记录"]
B --> C["解析并校验数据"]
C --> D["按 500 或 1000 条分批"]
D --> E["每批开启独立事务落库"]
E --> F["记录成功数和失败明细"]
F --> G{"还有下一批吗"}
G -- "有" --> D
G -- "无" --> H["更新任务状态和统计结果"]这种设计允许部分成功、错误可追踪、失败可重试,也不会让一个事务拖住数据库太久。
商业 Demo:医疗采集记录批量入库
表结构
create table collect_record (
id bigint primary key auto_increment,
task_id bigint not null,
device_code varchar(64) not null,
metric_code varchar(64) not null,
metric_value varchar(128) not null,
collect_time datetime not null,
created_at datetime not null,
unique key uk_device_metric_time(device_code, metric_code, collect_time),
key idx_task_time(task_id, collect_time)
);唯一索引用来保证幂等:同一设备、同一指标、同一采集时间不能重复入库。
Mapper
public interface CollectRecordMapper {
int insertOne(CollectRecordDO record);
int batchInsert(@Param("list") List<CollectRecordDO> records);
}foreach XML
<insert id="batchInsert">
insert into collect_record(
task_id, device_code, metric_code, metric_value, collect_time, created_at
)
values
<foreach collection="list" item="item" separator=",">
(#{item.taskId}, #{item.deviceCode}, #{item.metricCode},
#{item.metricValue}, #{item.collectTime}, #{item.createdAt})
</foreach>
</insert>Service 分批
@Service
public class CollectImportService {
private final CollectRecordMapper collectRecordMapper;
public CollectImportService(CollectRecordMapper collectRecordMapper) {
this.collectRecordMapper = collectRecordMapper;
}
@Transactional(rollbackFor = Exception.class)
public void importOneBatch(List<CollectRecordDO> records) {
if (records.isEmpty()) {
return;
}
collectRecordMapper.batchInsert(records);
}
public void importAll(List<CollectRecordDO> records) {
int batchSize = 500;
for (int i = 0; i < records.size(); i += batchSize) {
int end = Math.min(i + batchSize, records.size());
importOneBatch(records.subList(i, end));
}
}
}注意:上面 importAll 直接调用同类 importOneBatch 时,如果依赖 Spring 代理事务,可能存在自调用导致事务不生效的问题。真实项目可以把“单批落库方法”放到另一个 Spring Bean,或者使用 TransactionTemplate 明确控制事务。
部分失败怎么处理
批量导入不应该只返回“成功/失败”。商业系统通常要能告诉用户哪一行失败、为什么失败、能不能重试。
推荐流程:
flowchart TD
A["读取一批数据"] --> B["基础校验"]
B --> C{"是否有格式错误"}
C -- "有" --> D["记录错误行并跳过"]
C -- "无" --> E["进入批量落库"]
E --> F{"批量 SQL 是否失败"}
F -- "否" --> G["记录本批成功"]
F -- "是" --> H["降级为小批或单条定位"]
H --> I["记录失败原因和原始行号"]常见做法:
- 先做内存校验:必填、长度、枚举、日期格式。
- 再做数据库校验:唯一键、外键或业务存在性。
- 大批失败后拆小批定位。
- 失败明细写入导入错误表。
- 支持用户下载错误报告后修正重传。
幂等和重复数据
批量导入、消息批量消费、定时任务补偿都要考虑重复执行。
常见幂等方案:
| 方案 | 说明 |
|---|---|
| 唯一索引 | 从数据库层阻止重复写入,最可靠 |
| 业务流水号 | 例如 request_id、message_id、task_id + row_no |
| 状态机 | 只允许从合法旧状态更新到新状态 |
| 去重表 | 先写去重记录,再处理业务数据 |
不要只依赖 Java 内存 Set 去重。服务重启、多节点部署、任务重试后,内存去重会失效。
线上排查:批量写入慢
flowchart TD
A["批量写入慢"] --> B["确认批量方式"]
B --> C{"是否逐条 insert"}
C -- "是" --> D["改 foreach 或 JDBC batch"]
C -- "否" --> E["检查 batchSize 和 SQL 长度"]
E --> F["检查数据库日志和锁等待"]
F --> G["检查唯一索引冲突和回滚"]
G --> H["检查连接池、事务时长、redo/binlog 压力"]
H --> I["压测不同批大小并确定上线参数"]重点排查项:
- 是否真的开启了批处理,而不是代码看起来批量。
- MySQL 是否配置
rewriteBatchedStatements=true。 - 批大小是否过大导致 SQL 包、锁等待、日志压力。
- 是否有唯一键冲突导致整批失败和重试。
- 是否在一个事务里做了远程调用或耗时计算。
- 是否有触发器、外键、二级索引过多影响写入。
- 是否写入同时伴随大量查询,导致锁竞争。
常见坑
| 问题 | 后果 | 正确做法 |
|---|---|---|
| 十万条一个事务 | 锁时间长,回滚成本高 | 分批事务,记录任务进度 |
foreach 一次拼太多 | SQL 过长,参数过多 | 500/1000 起压测 |
| BatchExecutor 不 flush | 内存上涨,结果迟迟不发出 | 定期 flushStatements |
| 失败只返回系统异常 | 用户不知道哪行错 | 记录失败明细 |
| 没有唯一索引 | 重试产生重复数据 | 用业务唯一键保证幂等 |
| 自调用事务方法 | 事务可能不生效 | 跨 Bean 调用或 TransactionTemplate |
| 批量里远程调用 | 事务持有太久 | 远程调用放事务外 |
| 盲目删除索引提速 | 查询和约束受影响 | 评估写入和查询平衡 |
面试标准回答
MyBatis 批量插入怎么优化
不能用 for 循环逐条 insert,因为会产生大量数据库交互。常见方案有 foreach 拼多 values 和 ExecutorType.BATCH。foreach 简单直观,但要控制 SQL 长度和参数数量;ExecutorType.BATCH 复用预编译语句,适合大量重复写入,但要定期 flush、控制事务和内存。MySQL 还要关注 rewriteBatchedStatements=true。生产中批大小要压测,常从 500 或 1000 开始,并配合唯一索引、失败明细和分批事务。批量导入部分失败怎么办
我不会把所有数据放进一个大事务里。一般先创建导入任务,解析文件并做基础校验,然后按固定批次落库,每批独立事务。批量失败时可以拆小批或单条定位,记录错误行号、错误原因和原始数据。对于可能重复导入的数据,用唯一索引或业务流水号保证幂等。foreach 和 ExecutorType.BATCH 区别
foreach 是 MyBatis 动态 SQL 把多行 values 拼成一条 SQL,减少 SQL 执行次数;ExecutorType.BATCH 是 JDBC 批处理,把同一条 SQL 的多组参数累积后 executeBatch。foreach 写法简单但 SQL 可能很长,BatchExecutor 更适合大量重复写入,但需要控制 flush、事务和错误处理。关联知识点
本章小结
批量操作的核心不是一个标签或一个参数,而是系统性控制吞吐和风险:减少网络往返,复用 SQL 模板,控制批大小,缩短事务,保证幂等,记录失败明细,并通过压测找到适合业务和数据库的参数。能讲清这些,才说明真正理解了 MyBatis 批量操作。
