别再 MyBatis-Plus saveBatch 了!5600万条数据的真正批量插入方案

技术栈 :Spring Boot 3 + MyBatis-Plus + MySQL 8 数据规模:5600万条兑换码、6036个活动、单次生成1800万+

一、问题来了

最近做一个营销活动平台,核心需求是这样的:

运营创建一个活动,系统需要给每个活动批量生成大量唯一兑换码。

听起来不复杂?但看看数据量:

复制代码
单次配置:1613 个活动
每个活动:平均 1.17 万条兑换码
总数据量:1886 万条

一开始我用 MyBatis-Plus 的 saveBatch() 跑了一下,然后......就去吃了顿饭,回来还没跑完。

简单算一笔账

sql 复制代码
MyBatis-Plus saveBatch 本质:for 循环 + 单条 executeUpdate
1886万条 × 每条约 30ms(SQL解析 + 参数映射 + 网络往返)
≈ 565800秒 ≈ 14 小时

14 小时生成一批兑换码?运营和用户都会疯。

二、为什么 saveBatch 慢

先看看 MyBatis-Plus saveBatch() 到底做了什么:

java 复制代码
// MyBatis-Plus saveBatch 伪代码
public void saveBatch(List<T> entityList) {
    for (T entity : entityList) {
        // ① 每次 new 一个对象(GC 压力)
        // ② 每次解析一次 SQL(CPU 开销)
        // ③ 每次执行 executeUpdate()(网络往返)
        baseMapper.insert(entity);
    }
}

三个核心瓶颈:

瓶颈 原因 影响
SQL 重复解析 每条 INSERT 都要解析一次 SQL 模板 CPU 浪费
逐条网络往返 每条 INSERT 一次 execute → MySQL → 返回 1886万次网络 IO
大量对象创建 每条 new Entity() GC 频繁,内存压力大

一句话saveBatch 是"伪批量",本质是 for 循环单条插入。

三、JDBC PreparedStatement 真正的批量插入

核心代码

java 复制代码
// ① SQL 只预编译一次,? 占位符不参与 SQL 解析(天然防 SQL 注入)
private static final String INSERT_SQL =
    "INSERT INTO redeem_code (code, activity_id, serial_no, batch_no, "
    + "report_year, deleted, status, create_time, update_time) "
    + "VALUES (?, ?, ?, ?, ?, 0, 1, ?, ?)";

private static final int BATCH_SIZE = 5000;

public void batchGenerate(Long activityId, int quantity, 
                          int startSerial, int year, String batchNo) {
    // ② 整批共享一个 Timestamp 对象,避免 N 次对象创建
    Timestamp now = Timestamp.valueOf(LocalDateTime.now());
    
    try (Connection conn = dataSource.getConnection();
         PreparedStatement ps = conn.prepareStatement(INSERT_SQL)) {
        
        for (int i = 0; i < quantity; i++) {
            int serialNo = startSerial + i;
            String code = buildCode(activityId, serialNo, year);
            
            ps.setString(1, code);
            ps.setLong(2, activityId);
            ps.setInt(3, serialNo);
            ps.setString(4, batchNo);
            ps.setInt(5, year);
            ps.setTimestamp(6, now);  // 复用同一个对象
            ps.setTimestamp(7, now);
            
            ps.addBatch();  // ③ 攒入缓冲区,不发送
            
            // ④ 每 5000 条打包发送一次
            if ((i + 1) % BATCH_SIZE == 0) {
                ps.executeBatch();
            }
        }
        // ⑤ 提交剩余不足 5000 条的尾批
        ps.executeBatch();
        
    } catch (Exception e) {
        throw new RuntimeException("批量生成失败", e);
    }
}

五个优化点逐一拆解

① SQL 只预编译一次

scss 复制代码
conn.prepareStatement(INSERT_SQL)

MySQL 只解析一次 SQL 模板,后续 5000 条只传参数。省掉 4999 次 SQL 解析。

② Timestamp 复用

java 复制代码
Timestamp now = Timestamp.valueOf(LocalDateTime.now());
// 5600万行共享同一个 now 对象
ps.setTimestamp(6, now);  // 第 1 行
ps.setTimestamp(6, now);  // 第 2 行
...
ps.setTimestamp(6, now);  // 第 5600万行

省掉 5600 万次 Timestamp 对象创建。批量插入不要求每行时间精确到毫秒差异。

③ addBatch() 攒批

java 复制代码
ps.addBatch();  // 只放入 JDBC 驱动的内存缓冲区,不发送

参数只在客户端内存暂存,不产生网络 IO

④ 每 5000 条 executeBatch()

scss 复制代码
第 1~5000 条:攒批 → executeBatch() → 1次网络IO发送给MySQL
第 5001~10000 条:攒批 → executeBatch() → 1次网络IO
...

1886 万条数据 ≈ 3772 次网络往返(而不是 1886 万次)。

为什么是 5000?

批次大小 网络往返 内存峰值 风险
100 18.86 万次 极低 太慢
5000 3772 次 ~10MB 平衡点
50000 378 次 ~100MB MySQL binlog 峰值高
全量1次 1 次 ~3.7GB OOM 风险

⑤ 尾批提交

java 复制代码
ps.executeBatch();  // 提交最后不足 5000 条的剩余

四、效果对比

sql 复制代码
┌─────────────────────────────────────────────┐
│        MyBatis-Plus saveBatch                │
│                                             │
│  Java对象 ─→ SQL解析 ─→ 网络IO ─→ MySQL      │
│  Java对象 ─→ SQL解析 ─→ 网络IO ─→ MySQL      │
│  Java对象 ─→ SQL解析 ─→ 网络IO ─→ MySQL      │
│  ...(重复 1886 万次)                        │
│                                             │
│  耗时:≈ 14 小时                              │
└─────────────────────────────────────────────┘

┌─────────────────────────────────────────────┐
│        JDBC PreparedStatement 批处理         │
│                                             │
│  SQL预编译(1次)                             │
│  参数1 → addBatch ┐                          │
│  参数2 → addBatch ├→ executeBatch → MySQL    │
│  ...              │  (5000条/次)             │
│  参数5000 → addBatch ┘                       │
│                                             │
│  耗时:≈ 17 分钟                              │
└─────────────────────────────────────────────┘
指标 saveBatch JDBC 批处理 提升
网络往返 1886 万次 3772 次 5000 倍
SQL 解析 1886 万次 1 次 1886 万倍
Java 对象 1886 万个 0 个 无 GC 压力
总耗时 ≈ 14 小时 ≈ 17 分钟 ≈ 50 倍

五、面试追问

Q1:为什么不用 MyBatis <foreach> 拼接批量 INSERT?

xml 复制代码
<!-- 这个方案有什么问题? -->
INSERT INTO redeem_code VALUES 
<foreach collection="list" item="item" separator=",">
    (#{item.code}, #{item.activityId}, ...)
</foreach>

两个致命问题:

  • SQL 长度超限 :MySQL max_allowed_packet 默认 4MB,5 万条就超限
  • 内存膨胀:拼接出的 SQL 字符串本身就占大量内存

PreparedStatement.addBatch() 是 JDBC 协议级批处理,MySQL 驱动将参数打包发送,不受 SQL 长度限制。


Q2:事务怎么控制?中途失败了怎么办?

@Transactional 加在整个方法上,一个活动的所有兑换码要么全部成功,要么全部回滚。不会出现"只生成了一半"的数据。


Q3:会不会有 SQL 注入风险?

不会。PreparedStatement? 占位符内容不会被当作 SQL 语法解析 ,JDBC 驱动自动转义。而且编码是 buildCode() 纯计算生成的,不包含任何用户输入。

六、总结

场景 推荐方案
< 1000 条 saveBatch() 够用,开发效率优先
1000 ~ 100 万条 PreparedStatement + addBatch()
> 100 万条 PreparedStatement + 分批提交(5000条/批)

核心思路:减少网络往返 > 减少 SQL 解析 > 减少 Java 对象创建。

这不是什么高深的技术,但就是这种"回归 JDBC 本质"的做法,在批量场景下往往比任何 ORM 的花式封装都管用。


如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。有问题欢迎留言讨论。

相关推荐
武子康1 分钟前
Java-07 深入浅出 MyBatis数据库一对多关系模型实战:表结构设计与查询实现
java·后端
花椒技术44 分钟前
企业内部 Agent 落地复盘:Gateway、Skill 和二次确认如何串起受控业务执行
后端·agent·ai编程
我是一颗柠檬3 小时前
【MySQL全面教学】MySQL事务与ACID Day9(2026年)
数据库·后端·mysql
枕星而眠3 小时前
数据结构八大排序详解(一):四大简单排序
c语言·数据结构·c++·后端
IT_陈寒3 小时前
React useEffect闭包陷阱差点把我整失业了
前端·人工智能·后端
苍何3 小时前
爆肝两周,我把 Codex 最全实战指南开源了
后端
bug菌4 小时前
【SpringBoot 3.x 第254节】夯爆了,数据库访问性能优化实战详解!
数据库·spring boot·后端
Rust研习社4 小时前
从碎片化到标准化:cargo-bp 如何重构 Rust 开发逻辑
后端·rust·编程语言
锋行天下4 小时前
一句mysql复杂查询搞崩一个壮汉
后端·mysql·go
不肯过江东丶4 小时前
大聪明教你学Java | Spring AI Lab:一个让你 3 分钟接入 AI 对话能力的 Spring Boot 工具箱
spring boot·后端