Skip to content

MyBatis 批量操作 ​

批量操作不是“把 for 循环换成一个大 SQL”这么简单。它真正解决的是:大量数据写入时,如何减少网络往返、减少 SQL 解析次数、控制事务锁时间、控制内存、处理局部失败,并且让数据能稳定落库。

商业系统里常见批量场景包括:医疗设备采集记录入库、资产台账导入、订单明细批量生成、消息消费批量落库、权限关系批量绑定、定时任务批量修复状态。面试问批量操作时,面试官真正想听的不是“用 foreach”,而是你是否理解批处理背后的性能、事务和失败边界。

学习目标 ​

学完这一章要能说清楚:

  1. 普通 for 循环逐条插入为什么慢。
  2. MyBatis foreach 批量插入为什么快,又为什么不能无限大。
  3. ExecutorType.BATCH 和 JDBC batch 的工作方式。
  4. MySQL rewriteBatchedStatements=true 为什么会明显影响性能。
  5. 批大小为什么要压测,而不是凭感觉写 10000。
  6. 批量导入事务应该怎么切分,失败行怎么处理。
  7. 批量写入为什么可能导致锁等待、日志膨胀和连接池耗尽。
  8. 面试中如何回答批量插入优化、批量更新、部分失败和幂等问题。

为什么普通 for 循环慢 ​

最直观的写法是循环调用单条 insert。

java
@Transactional(rollbackFor = Exception.class)
public void importStudents(List<Student> students) {
    for (Student student : students) {
        studentMapper.insert(student);
    }
}

对应 XML:

xml
<insert id="insert">
    insert into student(name, age)
    values(#{name}, #{age})
</insert>

它的问题不是“Java for 循环慢”,而是每条数据都会经历一次完整 SQL 执行链路。

mermaid
flowchart TD
    A["循环第 1 条数据"] --> B["发送 insert 到数据库"]
    B --> C["数据库解析和执行"]
    C --> D["返回影响行数"]
    D --> E["循环第 2 条数据"]
    E --> F["再次发送 insert"]
    F --> G["重复网络往返和执行"]

如果插入 10 万条,就可能产生 10 万次 Mapper 调用、10 万次 JDBC 执行、10 万次网络往返。即使在同一个事务中,数据库不用每条都提交,网络和执行开销仍然巨大。

不这样优化会怎样?

  1. 接口耗时很长,请求线程长期占用。
  2. 数据库连接长期不释放,连接池容易被打满。
  3. 一个大事务持锁时间长,影响其他业务写入。
  4. 出错回滚成本大,10 万条全部回滚会拖慢数据库。
  5. 消息消费场景中,消费速度赶不上生产速度,形成消息堆积。

三种常见批量方式对比 ​

方式本质优点风险适用场景
for 循环逐条 insert多次执行单条 SQL写法简单网络往返多,吞吐低少量数据、管理后台低频操作
foreach 拼多 values一条 SQL 插入多行SQL 次数少,直观SQL 过长、参数过多、单次事务压力大几百到几千行一批
ExecutorType.BATCHJDBC 批处理复用预编译语句,适合大量写入要控制 flush、事务、内存、错误处理大批量导入、消息批量落库

方式一:for 循环逐条写入 ​

java
public int singleInsert(List<Student> students) {
    int count = 0;
    for (Student student : students) {
        count += studentMapper.insert(student);
    }
    return count;
}

这个方式可以用于几十条以内的数据,优点是简单、容易定位失败数据。但数据量上来以后,它会把数据库访问次数放大。

商业项目里如果必须逐条处理,通常是因为每条数据都有复杂校验、每条失败要独立记录、或者业务要求部分成功部分失败。这时也不应该无脑一个大事务,而应该设计“校验阶段 + 分批落库 + 错误明细表”。

方式二:foreach 拼接多 values ​

Mapper:

java
int batchInsertByForeach(@Param("list") List<Student> students);

XML:

xml
<insert id="batchInsertByForeach">
    insert into student(name, age)
    values
    <foreach collection="list" item="item" separator=",">
        (#{item.name}, #{item.age})
    </foreach>
</insert>

最终 SQL 类似:

sql
insert into student(name, age)
values (?, ?), (?, ?), (?, ?)

为什么它比 for 循环快?因为多行数据合并成一条 SQL,减少了网络往返和数据库执行次数。

mermaid
flowchart TD
    A["Java 集合 1000 条"] --> B["foreach 生成一条 insert values SQL"]
    B --> C["ParameterHandler 绑定 2000 个参数"]
    C --> D["数据库一次执行多行插入"]
    D --> E["返回影响行数"]

但 foreach 不是越大越好。假设每行 20 个字段,10000 行就是 200000 个参数,SQL 文本非常长,可能出现:

  1. SQL 包超过数据库或驱动限制。
  2. SQL 解析时间变长。
  3. MySQL max_allowed_packet 超限。
  4. 单个事务持锁时间过长。
  5. 失败后整批回滚,难以定位哪条数据有问题。

所以 foreach 常见做法是分批。

java
@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 到数据库。

java
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();
    }
}

执行链路可以这样理解:

mermaid
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 驱动中经常看到这个配置:

properties
spring.datasource.url=jdbc:mysql://localhost:3306/demo?rewriteBatchedStatements=true

它的作用是让驱动把批量参数改写成数据库更容易高效执行的形式。没有这个参数时,很多情况下虽然 Java 侧调用了 addBatch / executeBatch,驱动仍可能按多条语句发送,吞吐提升有限。

有了它以后,类似多次:

sql
insert into student(name, age) values (?, ?)
insert into student(name, age) values (?, ?)
insert into student(name, age) values (?, ?)

可能被驱动改写成:

sql
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

常见经验:

  1. 后台小批量导入:foreach 分批足够。
  2. 采集系统高吞吐落库:优先考虑 ExecutorType.BATCH 或原生 JDBC batch。
  3. 数据量特别大:考虑导入工具、临时表、分区表、离线任务,不要只靠接口同步导入。

批量更新怎么做 ​

批量更新比批量插入更复杂,因为每行更新条件和值可能不同。

写法一:循环 update + BatchExecutor ​

java
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();
    }
}
xml
<update id="updateStatus">
    update asset
    set status = #{status},
        update_time = #{updateTime}
    where id = #{id}
</update>

这种方式可读性好,适合每行条件不同的批量更新。

写法二:case when 批量更新 ​

xml
<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 开始,观察这些指标:

指标看什么
单批耗时是否稳定,是否有长尾
数据库 CPUSQL 解析和执行是否过高
redo/binlog/事务日志写日志压力是否明显
锁等待是否影响在线业务
连接池连接是否长期占用
JVM 内存批处理参数和对象是否堆积
错误定位一批失败后能否定位问题数据

批大小过小,网络往返多;批大小过大,事务和内存压力大。生产值应该来自压测,而不是复制别人项目的数字。

事务边界怎么设计 ​

批量导入最容易犯的错误是:十万条数据一个事务。

java
@Transactional(rollbackFor = Exception.class)
public void importAll(List<Row> rows) {
    for (Row row : rows) {
        mapper.insert(row);
    }
}

这样做的风险:

  1. 事务时间太长,锁和连接一直占用。
  2. binlog/redo/undo 或事务日志压力大。
  3. 中途失败时全部回滚,用户体验差。
  4. 无法清楚记录哪些成功、哪些失败。

更适合商业导入的模型:

mermaid
flowchart TD
    A["上传导入文件"] --> B["创建导入任务记录"]
    B --> C["解析并校验数据"]
    C --> D["按 500 或 1000 条分批"]
    D --> E["每批开启独立事务落库"]
    E --> F["记录成功数和失败明细"]
    F --> G{"还有下一批吗"}
    G -- "有" --> D
    G -- "无" --> H["更新任务状态和统计结果"]

这种设计允许部分成功、错误可追踪、失败可重试,也不会让一个事务拖住数据库太久。

商业 Demo:医疗采集记录批量入库 ​

表结构 ​

sql
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 ​

java
public interface CollectRecordMapper {
    int insertOne(CollectRecordDO record);

    int batchInsert(@Param("list") List<CollectRecordDO> records);
}

foreach XML ​

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 分批 ​

java
@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 明确控制事务。

部分失败怎么处理 ​

批量导入不应该只返回“成功/失败”。商业系统通常要能告诉用户哪一行失败、为什么失败、能不能重试。

推荐流程:

mermaid
flowchart TD
    A["读取一批数据"] --> B["基础校验"]
    B --> C{"是否有格式错误"}
    C -- "有" --> D["记录错误行并跳过"]
    C -- "无" --> E["进入批量落库"]
    E --> F{"批量 SQL 是否失败"}
    F -- "否" --> G["记录本批成功"]
    F -- "是" --> H["降级为小批或单条定位"]
    H --> I["记录失败原因和原始行号"]

常见做法:

  1. 先做内存校验:必填、长度、枚举、日期格式。
  2. 再做数据库校验:唯一键、外键或业务存在性。
  3. 大批失败后拆小批定位。
  4. 失败明细写入导入错误表。
  5. 支持用户下载错误报告后修正重传。

幂等和重复数据 ​

批量导入、消息批量消费、定时任务补偿都要考虑重复执行。

常见幂等方案:

方案说明
唯一索引从数据库层阻止重复写入,最可靠
业务流水号例如 request_id、message_id、task_id + row_no
状态机只允许从合法旧状态更新到新状态
去重表先写去重记录,再处理业务数据

不要只依赖 Java 内存 Set 去重。服务重启、多节点部署、任务重试后,内存去重会失效。

线上排查:批量写入慢 ​

mermaid
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["压测不同批大小并确定上线参数"]

重点排查项:

  1. 是否真的开启了批处理,而不是代码看起来批量。
  2. MySQL 是否配置 rewriteBatchedStatements=true。
  3. 批大小是否过大导致 SQL 包、锁等待、日志压力。
  4. 是否有唯一键冲突导致整批失败和重试。
  5. 是否在一个事务里做了远程调用或耗时计算。
  6. 是否有触发器、外键、二级索引过多影响写入。
  7. 是否写入同时伴随大量查询,导致锁竞争。

常见坑 ​

问题后果正确做法
十万条一个事务锁时间长,回滚成本高分批事务,记录任务进度
foreach 一次拼太多SQL 过长,参数过多500/1000 起压测
BatchExecutor 不 flush内存上涨,结果迟迟不发出定期 flushStatements
失败只返回系统异常用户不知道哪行错记录失败明细
没有唯一索引重试产生重复数据用业务唯一键保证幂等
自调用事务方法事务可能不生效跨 Bean 调用或 TransactionTemplate
批量里远程调用事务持有太久远程调用放事务外
盲目删除索引提速查询和约束受影响评估写入和查询平衡

面试标准回答 ​

MyBatis 批量插入怎么优化 ​

text
不能用 for 循环逐条 insert,因为会产生大量数据库交互。常见方案有 foreach 拼多 values 和 ExecutorType.BATCH。foreach 简单直观,但要控制 SQL 长度和参数数量;ExecutorType.BATCH 复用预编译语句,适合大量重复写入,但要定期 flush、控制事务和内存。MySQL 还要关注 rewriteBatchedStatements=true。生产中批大小要压测,常从 500 或 1000 开始,并配合唯一索引、失败明细和分批事务。

批量导入部分失败怎么办 ​

text
我不会把所有数据放进一个大事务里。一般先创建导入任务,解析文件并做基础校验,然后按固定批次落库,每批独立事务。批量失败时可以拆小批或单条定位,记录错误行号、错误原因和原始数据。对于可能重复导入的数据,用唯一索引或业务流水号保证幂等。

foreach 和 ExecutorType.BATCH 区别 ​

text
foreach 是 MyBatis 动态 SQL 把多行 values 拼成一条 SQL,减少 SQL 执行次数;ExecutorType.BATCH 是 JDBC 批处理,把同一条 SQL 的多组参数累积后 executeBatch。foreach 写法简单但 SQL 可能很长,BatchExecutor 更适合大量重复写入,但需要控制 flush、事务和错误处理。

关联知识点 ​

  1. MyBatis 核心全过程原理
  2. MyBatis 动态 SQL
  3. ORM 事务与一致性
  4. MySQL redo log 与 binlog
  5. MySQL 大表覆盖索引优化
  6. ORM 面试题

本章小结 ​

批量操作的核心不是一个标签或一个参数,而是系统性控制吞吐和风险:减少网络往返,复用 SQL 模板,控制批大小,缩短事务,保证幂等,记录失败明细,并通过压测找到适合业务和数据库的参数。能讲清这些,才说明真正理解了 MyBatis 批量操作。