MyBatis 如何执行批量操作?

#Java#面试八股

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.BATCHN 条 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,否则批量只是「看起来批量」。

笔记加载中…