【总结】MyBatis-Plus批量插入与清表的正确姿势

MyBatis-Plus批量插入与清表的正确姿势

Code review 里经常出现这样两个争论:

① "saveBatch 一把梭传几百上千条,不手动分批插入会有性能问题吧?"

② "代码里清表直接 delete from xxx 没加条件,应该改 truncate 才对吧?"

这两个说法都对了一半。这篇就把批量插入和批量删除的底层机制一次讲透,最后给一张决策速查表。


MyBatis-Plus saveBatch 的三层真相,以及清表到底该用 DELETE 还是 TRUNCATE ?

一、先明确一点:saveBatch 内部本来就是"分批"的

这是最多人不知道的一点。MyBatis-Plus(本文以 3.5.x 为例)的 ServiceImpl 里:

java 复制代码
@Transactional(rollbackFor = Exception.class)
@Override
public boolean saveBatch(Collection<T> entityList, int batchSize) {
    String sqlStatement = getSqlStatement(SqlMethod.INSERT_ONE);
    return executeBatch(entityList, batchSize,
        (sqlSession, entity) -> sqlSession.insert(sqlStatement, entity));
}

saveBatch(list) 不传 batchSize 时默认 1000executeBatch 底层会打开一个 ExecutorType.BATCH 的 SqlSession,逐条 insert每攒满 batchSize 条就 flushStatements() 一次,整个过程包在一个事务里。

也就是说:

  • JDBC 层addBatch + executeBatch,每 1000 条向驱动刷一次------这已经是"分批"了;
  • SQL 层 :反复执行的是同一条单行 INSERT 语句(参数不同),根本不会拼出一条超大 SQL;
  • 事务层 :一次 saveBatch 调用 = 一个事务(方法上有 @Transactional,传播级别 REQUIRED,外层有事务则加入外层)。

所以"传 500 条进去"和"手动拆成 5 × 100 调 5 次"相比:

维度 一把梭 手动拆 5 次
JDBC 执行形态 完全一样(每 1000 条 flush) 完全一样
速度 无差别 无差别
事务边界 1 个事务 5 个事务(若每次独立提交)

手动分批改变的只有事务边界,不是速度。 想靠"分批"提速,方向就找错了。


二、真正决定批量插入速度的:JDBC URL 里的 rewriteBatchedStatements

既然 JDBC batch 只是"客户端攒语句",那 MySQL 驱动默认(rewriteBatchedStatements=false)在 executeBatch() 时会把语句一条一条发到网络------攒批省掉的只是客户端重复解析的开销,网络往返一次没少。

加上这个参数后:

复制代码
jdbc:mysql://host:3306/db?characterEncoding=utf8&useSSL=false&rewriteBatchedStatements=true

驱动会把批里的 INSERT 重写成多值插入,网络往返从 N 次降到几乎 1 次:

sql 复制代码
-- 无 rewrite:1000 行 = 1000 次往返
INSERT INTO t (...) VALUES (...);  -- ×1000

-- 有 rewrite:1000 行 ≈ 1 次往返
INSERT INTO t (...) VALUES (...), (...), (...), ...;

这是零代码、改一行配置 的优化,批量写入普遍有 5~10 倍(高延迟网络下更多)的提速。

量级估算(内网、近百列的宽表,单行 INSERT 约 3~4KB)

单批行数 无 rewrite(逐条发) 有 rewrite(多值插入) 需要应用层分批吗
~200 ≈ 0.2~0.5s ≈ 几十 ms 不需要
~1000 ≈ 1~3s ≈ 0.2~0.5s 不需要
数千 ≈ 5~15s ≈ 1~2s 边缘,看场景
上万 分钟级 + 大事务风险 秒级 需要(500~1000 条/事务)

一个配套注意项:max_allowed_packet

rewrite 后多值 INSERT 的单包体积 = 行大小 × 批内行数。近百列的宽表 1000 行一批 ≈ 4MB ,贴近 MySQL 5.7 默认 max_allowed_packet=4M 的上限------加参数时顺手确认服务端该值(8.0 默认 64M 无虞)。驱动超限时会自动拆包,但配置上留好余量更稳。

真实踩坑见闻:不少项目里同一个应用连的多个库,有的加了 rewriteBatchedStatements=true、有的没加------通常是某个人在自己负责的库上踩过慢批量的坑,修了却没推广。全局 grep 一下 JDBC URL 就能发现这种不一致。


三、顺带分清:项目里常见的三种"批量插入"

写法 SQL 形态 特点
① MP saveBatch 同一条单行 INSERT × N,JDBC 攒批 默认 1000/批;线上速度取决于 URL 有没有 rewrite
② Mapper XML <foreach> 手拼多值 一条多值 INSERT 天然就是 rewrite 后的形态;但千行以上要自己拆 ,否则撑爆 max_allowed_packet
③ MP InsertBatchSomeColumn 注入器 一条多值 INSERT 框架级多值插入,需要自定义 AbstractMethod 注入,改造成本最高

①和②经常出现在同一个项目里(比如"清表重灌"用 ②、业务落库用 ①),知道它们底层形态不同,就不会产生"saveBatch 会拼超大 SQL"的误解。

多值 INSERT(写法②)的拆分粒度怎么算

max_allowed_packet 限制的是一条 SQL 语句的字节数<foreach> 拼出来的整条语句就是 一个包。行宽 × 行数一乘就能判断风险:

表形态 单行 SQL 文本 4M 上限(5.7 默认)能装 64M 上限(8.0 默认)能装
窄表(10 列左右,多为数字) ~150-250 B ~2 万行,基本碰不到 25 万行
宽表(近百列,decimal + 文本) ~3-4 KB ~1000 行,贴线 ~1.6 万行
宽表 + 长文本(名称 / JSON 串) ~6-8 KB ~500 行,随便爆 ~8000 行

两个容易翻车的细节:

  • byte ≠ char:包大小按字节算,中文在 utf8mb4 下一个汉字 3 字节,按"字符数"估会偏小 3 倍;
  • 行宽是运行时变量 :测试环境数据短,2KB/行跑得好好的;生产数据一长,同样 1000 行体积翻几倍当场报错。所以粒度不能靠测试验证,要靠公式确定。

确定性三步法:

sql 复制代码
-- ① 查上限
SELECT @@max_allowed_packet;   -- 5.7 默认 4M,8.0 默认 64M
text 复制代码
② 量真实行宽:拿生产同构数据拼一小段多值 INSERT,
   sql.getBytes(UTF_8).length ÷ 行数(p6spy 或单测里做)

③ 套公式:
安全条数 = min( 500~1000 ,  0.8 × max_allowed_packet ÷ 单行字节数 )
                ↑事务体积约束        ↑包体积约束(20% 余量防数据漂移)

近百列的宽表在 4M 下算出来就是 600~800 条,取 500 一批比较稳;窄表则永远被事务约束封顶,包体积根本不构成瓶颈。代码上一行拆完(hutool):

java 复制代码
List<List<Entity>> chunks = CollUtil.split(list, 500);
for (List<Entity> chunk : chunks) {
    mapper.batchInsert(chunk);
}

还有一个关键的不对称:saveBatch + rewriteBatchedStatements=true 时驱动会自动按 maxAllowedPacket 拆包 ,包体积这层是框架兜底的;而手拼 <foreach> 整条 SQL 一次成型、毫无兜底。真超限时的报错长这样,看到要条件反射:

text 复制代码
ERROR 1153 (08S01): Got a packet bigger than 'max_allowed_packet'
-- 或驱动侧:Packet for query is too large (8388608 > 4194304)

四、什么时候才真的需要应用层分批?------事务体积

万行级以上,瓶颈不再是速度而是单个大事务

  • undo log 膨胀:回滚段持续增长,内存压力大;
  • 行锁持有时间长:插入期间锁不释放,并发写等待;
  • binlog 尖峰 + 主从延迟:一个大事务传到从库要"一口气"重放;
  • 连接被长时间占用:连接池被慢批任务占满的风险。

到这个量级才值得在应用层每 500~1000 条提交一个事务(saveBatch(list, 500) 在外层无事务时即可实现分事务;或用 TransactionTemplate 精确控制)。

结论:几百行的业务批量,一把梭 saveBatch 是完全合理的,不要为了"分批"而分批。


五、批量删除:全表 DELETE vs TRUNCATE

先上对比表(MySQL / InnoDB):

DELETE FROM t(无 where) TRUNCATE TABLE t
本质 DML,逐行删除,O(n) DDL(近似 drop + 重建),整段回收数据页,O(1) 与行数无关
日志 每行写 undo log + binlog 行镜像 binlog 仍记一条 DDL 语句;省掉的是逐行 undo
回滚 事务内可回滚 隐式提交,不可回滚
AUTO_INCREMENT 保留续号 重置归零
触发器 触发 DELETE 触发器 不触发
外键 可删(子行受约束) 被外键引用时直接报错不可用
表空间 删除后页复用,文件不收缩 直接回收(独立表空间下 .ibd 变小)
权限 DELETE 权限 DROP 权限
MyBatis 返回值 affected rows 恒为 0

两个常见误区

误区一:"TRUNCATE 没有日志,所以更快"。

不准确。TRUNCATE 依然会写 binlog(一条 DDL),否则主从复制和基于 binlog 的恢复就废了。它快是因为不做逐行 undo、直接回收数据页,复杂度 O(1)------这个优势在十万行以内基本无感 (几千行的全表 DELETE 也就几十到几百毫秒),到十万行以上才拉开差距。

误区二:"全表 DELETE 都应该换成 TRUNCATE"。

换之前先过四个检查:

  1. 事务性:这个删除是否需要和后续插入在同一个事务里回滚?TRUNCATE 是隐式提交,会把"删了之后插失败还能回滚"的保护直接干掉;
  2. 权限:业务账号通常只授了 DML,没有 DROP 权限,TRUNCATE 会直接报错;
  3. 自增语义:有其他表/业务依赖自增 id 不归零吗?
  4. MyBatis 返回值TRUNCATE 的 affected rows 恒为 0,int deleteCount = mapper.truncateXxx() 拿到的永远是 0,日志和监控会误导排查。

顺带:delete from xxx where id is not null 是什么操作

经常能看到这种"恒真条件"的全表删除。它不是业务逻辑,而是为了绕过 SQL 防火墙对"无 WHERE 的 DELETE"的拦截(典型如 Druid 连接池的 wallfilter 默认拦截无条件删除)。如果项目用的是 HikariCP、没有这类拦截器,这个条件写不写效果一样------但读代码的人会多疑惑一分钟,建议要么删掉条件,要么加注释说明来历。


六、清表重灌的顺序问题(比性能更值得改)

"清表 + 重灌"的同步任务里,我见过比 DELETE vs TRUNCATE 重要得多的缺陷------顺序

java 复制代码
// 反例:先删后查
public boolean batchInsert() {
    mapper.deleteAll();                       // ① 先清空表(已自动提交)
    List<Source> list = sourceService.query(); // ② 再查上游
    if (CollUtil.isEmpty(list)) {
        return false;                          // ③ 上游没数据 → 直接返回
    }                                          //    此时表已经空了,且不再回填!
    return mapper.batchInsert(list);
}

上游一旦没出数(依赖的算法没跑、远端查询失败),表被物理清空且不回填,所有依赖这张表的下游查询立刻全空。而且这种同步链路常常跨多个数据源 (从 A 库查、写 B 库),本地 @Transactional 根本包不住,出事了既回滚不了也补救不了。

java 复制代码
// 正例:先查后删,空则不动旧数据
public boolean batchInsert() {
    List<Source> list = sourceService.query(); // ① 先查上游
    if (CollUtil.isEmpty(list)) {
        log.warn("上游无数据,保留旧表数据不动");
        return false;                          // ② 空 → 老数据原封不动
    }
    mapper.deleteAll();
    return mapper.batchInsert(list);
}

如果对"删除到重灌之间的空窗"也零容忍(大表、高并发读),进阶做法是影子表 :写一张临时表,灌完后 RENAME TABLE t TO t_old, t_new TO T,对读方是原子切换------不过对几百行的配置类表属于杀鸡用牛刀。


七、总结速查

场景 结论
saveBatch 传几百上千条 不用手动分批,MP 内部每 1000 条一批
想让批量写入提速 改 JDBC URL 加 rewriteBatchedStatements=true(5~10 倍,零代码)
加了 rewrite 之后 确认 max_allowed_packet ≥ 单批体积(宽表千行 ≈ 4MB)
单批上万行 才需要应用层 500~1000 条/事务(防大事务,不是防慢)
几千行的表清空 DELETE 全表亚秒级,换 TRUNCATE 收益≈0,还可能踩 DROP 权限/返回值恒 0
十万行以上的表清空 TRUNCATE 值得换,过四项检查(事务/权限/自增/返回值)
清表重灌任务 先查源、判空提前返回,再删再插------这个顺序比任何性能优化都重要

一句话:批量写入的优化杠杆在 JDBC 配置层(rewrite),不在 Java 代码层(手动分批);清表的正确性杠杆在执行顺序,不在 DELETE 还是 TRUNCATE。

相关推荐
高级程序源44 分钟前
django大学生创新创业项目管理系统94923-计算机课程设计、毕业设计
javascript·vue.js·spring boot·后端·python·django·课程设计
木井巳1 小时前
【记忆化搜索】不同路径
java·算法·leetcode·深度优先·剪枝·推荐算法
Sam_Deep_Thinking1 小时前
new Thread()之后发生了什么?
java·后端·面试·程序员
泡海椒1 小时前
规则引擎对比:JQuick-Java vs Drools 轻量场景选型对比
java·开发语言
wno7041 小时前
Spring Security短信验证码登录
java·python·spring
步行cgn1 小时前
Spring util 命名空间详解
java·后端·spring
古法安卓2 小时前
Android-休眠唤醒后onLocationChanged没有数据问题排查
android·java·android studio
周GZ2 小时前
简单讲解线程池的4种拒绝策略
java
达梦数据2 小时前
DMDRS空间数据类型说明:Oracle、DM8、MySQL空间数据类型映射
数据库·mysql·oracle