分类:数据库与中间件 · 作者:管理员 · 发布于 2026-10-13 · 阅读 0 · 标签:MyBatis / 批量插入 / 性能优化 / JDBC / PostgreSQL

一、痛点:1 万条明细为什么插了 40 秒

电商中台经常要面对批量导入:运营上传商品 SKU、订单明细落库、库存流水初始化。假设要插入 1 万条订单明细,很多同学第一版代码是 for 循环逐条 insert,结果跑出几十秒甚至接口超时。慢的根源不是「数据多」,而是下面三件事被放大了 1 万次:

  • 网络往返:每条 insert 都是一次完整的 TCP 交互,1 万条就是 1 万次往返,集群部署下网络开销更明显;
  • SQL 解析:每条语句都独立经过词法/语法解析、优化器、生成执行计划;
  • 事务与日志:逐条提交或一个巨大事务,redo/binlog 频繁刷盘。

结论先行:批量插入优化的本质是减少「网络往返」和「SQL 解析」的次数,而不是减少数据量。

二、第一版:for 循环逐条插入(反面教材)

// 1 万条 = 1 万次网络往返 + 1 万次 SQL 解析
public void insertBad(List<OrderItem> items) {
    for (OrderItem item : items) {
        orderItemMapper.insert(item);
    }
}

这段代码没有语法问题,问题在性能模型:每次 insert 都是一次独立的数据库往返。本地下 develop 环境可能还能忍,换到生产集群(应用与数据库不同机)延迟立刻放大。这是批量导入慢的第一大元凶。

三、第二版:foreach 拼接批量 INSERT

最常见的优化是把 1 万条拼进一条 SQL:

<insert id="batchInsert" parameterType="list">
    INSERT INTO order_item
        (order_id, sku_id, sku_name, price, quantity, amount, create_time)
    VALUES
    <foreach collection="list" item="it" separator=",">
        (#{it.orderId}, #{it.skuId}, #{it.skuName}, #{it.price},
         #{it.quantity}, #{it.amount}, now())
    </foreach>
</insert>

优点立竿见影:1 万条只发一次 SQL,网络往返从 1 万次降到 1 次。但直接拿全量集合去拼会踩三个坑:

  • SQL 体积爆炸:1 万条 VALUES 拼接后轻松超过数据库单包上限(MySQL 5.7 默认 max_allowed_packet 为 4MB、8.0 为 64MB),直接抛 PacketTooBigException;
  • 占位符上限:MySQL 与 PostgreSQL 单条语句可绑定参数个数上限约 65535,一次插太多会报 too many placeholders / 参数超限;
  • OGNL 解析开销:MyBatis 的 foreach 对超大集合的拼接解析本身也有 CPU 和内存成本。

解法是分批:每批 500~1000 条循环执行。分批后 1 万条典型从分钟级降到 3~5 秒,但还能更快。

// 分批 + 每批独立事务,失败只回滚本批
public void insertByBatch(List<OrderItem> items, int batchSize) {
    for (int i = 0; i < items.size(); i += batchSize) {
        List<OrderItem> sub = items.subList(
            i, Math.min(i + batchSize, items.size()));
        orderItemMapper.batchInsert(sub); // 一个事务提交一批
    }
}

四、第三版:ExecutorType.BATCH 攒批执行

MyBatis 有三种执行器:SIMPLE(默认,每条语句创建新 Statement)、REUSE(复用 Statement)、BATCH(复用 PreparedStatement,只绑定参数不执行,直到 flush 才批量发送)。BATCH 模式下调用 mapper 方法只是「攒参数」,提交或关闭 SqlSession 时才真正执行:

// 注意:不能用 Spring 注入的 mapper,必须显式拿 BATCH 类型的 SqlSession
public void insertByBatchExecutor(List<OrderItem> items) {
    try (SqlSession session =
             sqlSessionFactory.openSession(ExecutorType.BATCH, false)) {
        OrderItemMapper mapper = session.getMapper(OrderItemMapper.class);
        for (OrderItem item : items) {
            mapper.insert(item); // 只攒批,不真正执行
        }
        session.commit();        // 统一 flush 到数据库
    }
}

两个要点:

  • Spring 默认注入的 SqlSessionTemplate 是 SIMPLE 执行器,直接 @Autowired mapper 调用根本不会走批处理,这是「我以为开了批处理却没效果」的第一大原因;
  • autoCommit 必须为 false,否则每条都会立即提交,批处理名存实亡。

只换 BATCH 执行器通常快 2~4 倍,真正的量级提升在下一步。

五、第四版:rewriteBatchedStatements,量级提升的关键

MySQL Connector/J 默认对 batched statement 仍然逐条发送(一条 SQL 一个 roundtrip),BATCH 执行器只是帮你攒在了客户端。开启 rewriteBatchedStatements=true 后,驱动会把同构的批量 INSERT 重写为一条多 VALUES 语句再发送,网络往返从 N 次真正变成 1 次:

# MySQL:性能关键参数
spring.datasource.url=jdbc:mysql://app-db:3306/app_db?rewriteBatchedStatements=true&useSSL=false&serverTimezone=Asia/Shanghai
# PostgreSQL:对应参数(pgjdbc 42.2.4+ 支持,注意大小写)
spring.datasource.url=jdbc:postgresql://app-db:5432/app_db?reWriteBatchedInserts=true

两个易错点:

  • rewrite 只对同一条 SQL、只换参数的批量生效;如果批处理里混入了不同 SQL(偶尔穿插 update/select),驱动会退化为分条发送,性能打回原形;
  • 参数名别记混:MySQL 是 rewriteBatchedStatements,PostgreSQL 是 reWriteBatchedInserts,大小写和拼写都不能错,连不上或没效果先查这里。

六、性能对比:1 万条订单明细典型量级

方案耗时(典型量级)说明
for 循环逐条 insert约 30~60 秒1 万次网络往返,最慢
foreach 每批 500 条约 3~5 秒网络往返降到 20 次
ExecutorType.BATCH约 1~3 秒客户端攒批,仍需驱动配合
BATCH + rewriteBatchedStatements约 0.5~1 秒真正的量级提升

绝对值因机器、网络、表结构差异很大,请关注相对倍数:foreach 分批约 10 倍,BATCH + rewrite 相比逐条约 50~100 倍。上线前用真实表结构和数据量压一次再定 batchSize,别拍脑袋。

七、踩坑记录

  1. 分批大小不是越大越好:500~1000 是常见甜点。太小网络往返多;太大单条 SQL 体积惊人,解析和内存开销反而上升,还可能撞参数上限。
  2. useGeneratedKeys 的库差异:MySQL 开启 rewrite 后批量回填自增 id 仍可用;PostgreSQL 下更推荐「先取序列值再插入」或用 RETURNING 收集 id,避免批处理时 getGeneratedKeys 行为不一致。
  3. 事务边界:1 万条包进一个大事务,锁持有时间、undo/binlog 膨胀都很可观。推荐「一批一事务」,失败只回滚本批,也方便定位脏数据;只有业务要求全量原子性时才用大事务。
  4. PG 还有更快的路:几十万条以上直接考虑 COPY(copyFrom / BatchCopy),绕过 JDBC 逐行绑定,是另一个量级,别死磕 INSERT 批处理。
  5. 批处理「没生效」排查清单:executorType 是否真的 BATCH;连接串参数是否加对;autoCommit 是否为 false;批处理中是否混入了非同构 SQL。

八、最佳实践总结

  • 数据量 < 1000:foreach 分批即可,别过度设计;
  • 1000 ~ 10 万:BATCH 执行器 + rewrite/reWrite 参数 + 每批 500 条;
  • > 10 万:优先 COPY / LOAD DATA 等流式导入,并放到异步任务里做,配合进度反馈;
  • 把批处理封装成通用组件:入参为「原始集合 + 批次大小 + 单批执行函数」,统一处理分批、事务、失败日志,避免每个业务各写一套;
  • 一切以压测为准:不同表的字段数、行宽差异很大,batchSize 必须实测,不能照抄别人的数字。

一句话总结:foreach 分批解决 SQL 体积,BATCH 执行器解决预备语句复用,rewrite 参数解决最后一公里的网络往返,三层配齐,批量导入才能从分钟级走向秒级。