别再 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 的花式封装都管用。


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

相关推荐
用户9385156350720 小时前
工厂模式与 Nest.js 核心思想 —— 从蜜雪冰城到企业级架构
后端·设计模式·nestjs
用户9385156350720 小时前
实战 Todo CRUD —— 从路由到异常,手写一个完整模块
后端·typescript·nestjs
GetcharZp20 小时前
Qdrant 向量数据库 Golang 实战指南:从零构建高性能检索应用
后端
hsfxuebao21 小时前
OpenSpec 完整使用流程笔记 (SDD)
后端
唐青枫21 小时前
别把 struct 只当成数据盒子:Zig 结构体从入门到实战
后端
why技术21 小时前
AI 写的文章,可能都带着手敲一遍都去不掉的“隐形水印”。
前端·人工智能·后端
北斗落凡尘21 小时前
LangGraph 入门实战(9)--中断
后端·langchain
CodeSheep1 天前
又一个华为天才少年,离职了!
前端·后端·程序员
IT_陈寒1 天前
Vue的双向绑定把我坑惨了,原来这个场景不能用
前端·人工智能·后端
SomeB1oody1 天前
【RustyML入门】4.2. 标准化与归一化
开发语言·后端·机器学习·rust·教程