MyBatis-Plus批量插入与清表的正确姿势
-
- [一、先明确一点:`saveBatch` 内部本来就是"分批"的](#一、先明确一点:
saveBatch内部本来就是"分批"的) - [二、真正决定批量插入速度的:JDBC URL 里的 `rewriteBatchedStatements`](#二、真正决定批量插入速度的:JDBC URL 里的
rewriteBatchedStatements) -
- [量级估算(内网、近百列的宽表,单行 INSERT 约 3~4KB)](#量级估算(内网、近百列的宽表,单行 INSERT 约 3~4KB))
- 一个配套注意项:`max_allowed_packet`
- 三、顺带分清:项目里常见的三种"批量插入"
-
- [多值 INSERT(写法②)的拆分粒度怎么算](#多值 INSERT(写法②)的拆分粒度怎么算)
- 四、什么时候才真的需要应用层分批?------事务体积
- [五、批量删除:全表 DELETE vs TRUNCATE](#五、批量删除:全表 DELETE vs TRUNCATE)
-
- 两个常见误区
- [顺带:`delete from xxx where id is not null` 是什么操作](#顺带:
delete from xxx where id is not null是什么操作)
- 六、清表重灌的顺序问题(比性能更值得改)
- 七、总结速查
- [一、先明确一点:`saveBatch` 内部本来就是"分批"的](#一、先明确一点:
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 时默认 1000 。executeBatch 底层会打开一个 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"。
换之前先过四个检查:
- 事务性:这个删除是否需要和后续插入在同一个事务里回滚?TRUNCATE 是隐式提交,会把"删了之后插失败还能回滚"的保护直接干掉;
- 权限:业务账号通常只授了 DML,没有 DROP 权限,TRUNCATE 会直接报错;
- 自增语义:有其他表/业务依赖自增 id 不归零吗?
- 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。