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 驱动层配置。换数据库后是否有效,要看对应驱动能力。

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

foreachExecutorType.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 批量操作。