☰
MyBatis-Plus批量插入性能优化:从saveBatch到自研通用组件
2026/10/18 10:39:19 网站建设 项目流程

你在项目里用 MyBatis-Plus 做过 Excel 导入或者数据迁移的话,大概率会碰到 saveBatch 性能上不去的尴尬。默认的 saveBatch 表面看是批量,实际上很多环境下是“每条记录都走一次 JDBC 执行”,几万行数据导进去页面能转圈转到让人怀疑人生。我之前负责的一个数据导入需求,十万行数据在测试环境跑了将近五分钟,后来自己实现了通用批量插入,愣是把耗时干到了几秒级别。这篇文章就把我自己实现 MyBatis-Plus 批量插入的完整思路、掉过的坑、以及最终沉淀下来的通用组件完整拆给你看,适合那些用 MyBatis-Plus 但觉得默认批量不够用、想彻底搞懂批量插入原理的开发者。

1. 为什么默认的 saveBatch 不够用:先扒一下底层逻辑

1.1 saveBatch 到底是不是“真批量”

MyBatis-Plus 的saveBatch从 API 层面看确实一次传了一个 List,很多人就默认它是一条 SQL 插进去的。实际上点开源码就能发现,saveBatch最终是循环调用了sqlSession.insert,底层走的是 JDBC 的PreparedStatement.executeBatch()。

这里有个关键点:JDBC 的 batch 模式和 MySQL 服务端的“一次解析一条大 SQL”是两回事。executeBatch()只是把多条 SQL 攒在一起发给服务端,服务端依然逐条解析、逐条执行。如果 MySQL JDBC 驱动没有开启rewriteBatchedStatements,那驱动甚至都不会真正用 batch 协议发送,而是退化成了一条一条的execute调用。说白了,大多数情况下 MyBatis-Plus 默认的 saveBatch 是“幌子批量”,真正的执行路径和 for 循环单插没有本质区别。

1.2 批量失效的元凶:JDBC 连接参数没配好

MySQL JDBC 驱动从 5.1.13 开始支持一个参数叫rewriteBatchedStatements,这个参数的作用是把executeBatch()里的多条 INSERT 语句在驱动层重写成一条多 VALUES 的大 INSERT,然后一次性发给 MySQL 服务端。只有当这个参数为 true 的时候,JDBC 层面的 batch 才是真正意义上的一次网络往返、一次解析。

你可以在数据库连接 URL 后面这样加:

jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true

加了之后,同样的saveBatch性能往往会有一个数量级的提升。但这里有个隐藏问题:rewriteBatchedStatements只对驱动层面的executeBatch生效,而 MyBatis-Plus 的saveBatch里还有大量日志、TypeHandler 转换、字段填充的开销,这部分节省不了。更重要的问题是,这个参数属于 JDBC 驱动的行为,换了数据源、换了连接池、或者某个环境下配置被覆盖,性能波动会非常大。所以自己做一版最淳朴的多 VALUES 拼接批量插入,才是可以预期、可以掌控的优化。

1.3 自己实现的路子有哪些收益

自己实现批量插入,本质上是绕开框架默认行为,用“一次插入多行”的 SQL 语法替换逐条插入。收益主要有三个:第一,SQL 次数从 N 次降到 N/批大小 次,网络往返开销大幅下降;第二,MySQL 服务端对大 INSERT 的解析代价比解析 N 条小 INSERT 要低很多,binlog 写入也更紧凑;第三,SQL 可以由你完全掌控,字段裁剪、主键回填、分批策略都能自定义。

需要注意的是,自己实现也意味着自己要承担边界控制:SQL 长度上限、占位符数量上限、事务大小、主键策略都要自己管。这也是为什么需要深入理解底层限制,而不是简单把数据一股脑拼进一条 SQL。

2. 方案选型:三种实现方式怎么选

2.1 XML foreach 拼接大 INSERT

最简单直接的方式是自己在 Mapper XML 里写一个 foreach 拼接的多行插入 SQL:

<insert id="insertBatch"> INSERT INTO t_user(user_name, age, email) VALUES <foreach collection="list" item="item" separator=","> (#{item.userName}, #{item.age}, #{item.email}) </foreach> </insert>

Mapper 接口对应方法写法:

int insertBatch(@Param("list") List<User> list);

这种方式优点是不引入额外代码,SQL 清晰可见,useGeneratedKeys也能正常回填主键。缺点也很明显:只针对当前这一张表,如果项目里十几张表都要批量插,就要复制十几份几乎一样的 XML;遇到字段特别多的表,SQL 会非常难维护。这个方式适合“我就一次性导入,就这一张表”的场景,不适合做通用能力。

2.2 基于 MyBatis-Plus 扩展机制注入通用方法

MyBatis-Plus 本身其实留了扩展点:自定义SqlInjector,配合InsertBatchSomeColumn这个 AbstractMethod,可以把批量插入方法注入到所有继承自定义 BaseMapper 的 Mapper 里。这个方法不是 BaseMapper 自带的,所以用的人不算多,但它是官方扩展包里提供的工具方法,不需要手写任何 XML,并且能自动利用TableInfo里解析好的字段映射、表名、主键信息,真正做到“一次注入,全表通用”。

2.3 原生 JDBC 批量

第三个方案是直接绕过 MyBatis-Plus,用 Spring 的JdbcTemplate或者原生 JDBC 来做批量,比如用NamedParameterJdbcTemplate拼参数。这种方式性能上限很高,但对于已经有 MyBatis-Plus 实体、注解、字段转义规则的项目来说,等于把框架的映射机制废掉了,还得自己处理下划线转驼峰、类型转换、逻辑删除字段等一堆细节。除非你是极度追求性能压测分数,否则不建议在业务代码里这么干。

2.4 选型判断表

方案通用性主键回填性能推荐场景
XML foreach低,每个表写一套支持高单表单场景、一次性迁移
MP SqlInjector 注入高,一套全通用需额外处理高项目内多实体通用批量入口
原生 JDBC低,需自己接管映射一般最高压测/工具型项目

我最终在项目里选的是第二个方案,也就是 SqlInjector 注入,因为它和 MyBatis-Plus 的实体模型契合度最高,一个方法全项目所有表都能用,代码量也最少。接下来详细讲实现过程。

3. 实操:从 0 到 1 搭一个通用批量插入组件

3.1 继承 BaseMapper,定义统一批量接口

首先要定义一个项目内统一的 Mapper 接口,继承 MyBatis-Plus 的BaseMapper,在接口里加上一个批量插入方法。注意这个方法的 SQL 不是靠代码写死的,而是靠后面的 SqlInjector 把 SQL 片段动态注入进去。

public interface BatchBaseMapper<T> extends BaseMapper<T> { int insertBatchSomeColumn(@Param("list") List<T> list); }

所有业务 Mapper 都改继承这个BatchBaseMapper,比如:

@Mapper public interface UserMapper extends BatchBaseMapper<User> { // 业务方法 }

这里的命名我沿用了官方扩展里InsertBatchSomeColumn的方法名,不加自定义前缀,好处是以后团队里看到这个方法名就知道是批量插入,不用额外解释。一个小细节:方法返回int,是成功插入的行数;如果你还需要回填主键,这个方法在后面有专门的处理策略,先卖个关子。

3.2 自定义 SqlInjector,把批量 SQL 注入到所有 Mapper

MyBatis-Plus 的 Mapper 方法最终都会对应到一个个AbstractMethod,默认注入器里装配了selectById、insert、updateById这些方法。我们要做的,是自定义一个注入器,把批量插入方法也加进去。

@Component public class EasySqlInjector extends DefaultSqlInjector { @Override public List<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) { List<AbstractMethod> methods = super.getMethodList(mapperClass, tableInfo); // 排除逻辑删除字段,避免批量 SQL 拼进 is_deleted methods.add(new InsertBatchSomeColumn(t -> !t.isLogicDelete())); return methods; } }

这段代码核心就一行:通过InsertBatchSomeColumn把批量插入逻辑绑定到我们之前定义的insertBatchSomeColumn方法上。这里的谓词参数t -> !t.isLogicDelete()是为了过滤掉逻辑删除字段,因为批量插入不应该把逻辑删除标记字段拼进去,否则会影响插入语义。

当前的这个实现还有一层重要的适配:如果你的 MyBatis-Plus 版本比较老,TableFieldInfo.isLogicDelete()这个方法可能不存在或者叫其他名字,但主流的 3.5.x 版本都支持。如果项目里所有表都没有逻辑删除字段,可以直接无参构造new InsertBatchSomeColumn()。我建议还是写上谓词,避免后续谁给表加了逻辑删除字段导致整条 SQL 崩掉。

3.3 单批多少条最合适:两个硬上限的计算方法

这是自己实现批量插入最关键的问题,很多人一头扎进去就踩爆了 MySQL 的两个限制。

第一个硬限制是 MySQL 预处理语句占位符数量上限:65535。也就是说,一条 INSERT 里所有参数的问号总数不能超过 65535。假设表有 20 个字段,那么一批最多插入 65535 / 20 = 3276 条。如果你一次性 insertBatchSomeColumn 传了 5000 条数据,即使 MySQL 不报错,驱动层也会因为参数数量超限抛异常。

第二个硬限制是单条 SQL 的包体大小,受 MySQLmax_allowed_packet控制。默认值在不同版本里不一样,5.7 默认 4MB,8.0 默认 64MB。单条 SQL 的总长度等于表名、字段名、VALUES 关键字、所有值依次排列的总体积。如果数据里有一些比较长的文本字段,哪怕条数不多,也完全可能超过max_allowed_packet,导致PacketTooBigException。

我给一个实践中的估算方法。先测出一条实体记录平均在 SQL 里占多少字节,然后按下面公式算:

单批上限 = min( 65535 / 每行字段数 , max_allowed_packet * 0.8 / 每行字节数 )

举例:某表 20 个字段,每行平均字节数大约 300B,MySQLmax_allowed_packet是 4MB 前提下,允许的最大行数是 min(3276, 4 * 1024 * 1024 * 0.8 / 300 ≈ 11184),取小值就是 3000 条左右。再往大了传,凭感觉翻车概率极高。

实际编码时,我会在常量里把批大小设定为一个保守值,比如 500 或者 1000,然后通过参数运算动态计算:

public static final int BATCH_SIZE = 500;

为什么保守?因为批量插入的收益并不是线性增长的。从 100 涨到 1000,性能提升非常明显;从 1000 涨到 5000,提升就很小了,反而会因为单条 SQL 太大增加 MySQL 解析耗时和内存压力。结合我自己实测,单批 500~800 是一个很舒服的区间,安全性高,性能也接近峰值。

3.4 主键回填和字段自动填充的处理

很多人会忽略一个点:insertBatchSomeColumn这个注入方法不负责把数据库自增主键回填到实体对象里。为什么?因为它是通过自定义 SQL 注入的,没有像普通insert那样配置useGeneratedKeys。如果你的业务在插入后需要立刻拿 ID 继续做关联数据操作,直接用这个方法会拿到一堆 null。

主键回填有两个选择:

第一个选择是回到 XML foreach 方案,给<insert>加上useGeneratedKeys="true" keyProperty="id"。这样批量插入后每条实体 ID 就能自动填上。代价是按表维护 XML。

第二个选择是吸收InsertBatchSomeColumn的通用性,但在插入前业务侧已经准备好 ID。现在主流做法是使用雪花 ID 或者自定义 ID 生成器,在调用批量插入前就把每一条数据的 ID 设置好,自增主键反而不需要回填。如果你的项目已经是这种 ID 策略,那insertBatchSomeColumn直接就能用,最省心。

字段自动填充方面,比如create_time、update_time这种配置了@TableField(fill = FieldFill.INSERT)的字段,批量插入时 MyBatis-Plus 会自动触发MetaObjectHandler,所以不需要在 SQL 里操心时间字段。唯一要注意的是,如果你的填充器依赖主键生成逻辑,注意调用顺序。我的习惯是:先统一生成 ID 和填充字段,再进入批量插入,彻底避免隐式依赖。

4. 性能实测与连接参数调优

4.1 三组实测数据:不带参数、带参数、手写批量

我拿一张 16 个字段的业务表,导了 10 万行数据做对比,环境是 MySQL 8.0、单表单事务。结果大概是这样的:

实现方式10万行耗时需要配置
默认 saveBatch46 秒无
saveBatch + rewriteBatchedStatements13 秒连接 URL 加参数
自定义 insertBatchSomeColumn 分批5006 秒无特殊连接配置

括号里写清楚,这是某个特定环境、特定表结构下的数字,换一张字段更大的表,绝对值会有变化,但相对差距是稳定的。默认saveBatch最慢的原因就是每条记录都是单独执行;加了驱动参数后,驱动把多条 SQL 重写成多 VALUES,性能提升显著;再自实现批量插入,去掉了 MyBatis-Plus 逐条插入的额外损耗,SQL 也精简到只有纯 INSERT 语句,所以最快。

所以结论很朴素:Java 层拼多 VALUES 的自研批量,是比“指望驱动帮你 rewrite”更可控的方案。就算以后连接池参数被别的人改了、配置被环境变量覆盖了,你自研的这版批量插入依然能打。

4.2 数据库连接 URL 的正确姿势

哪怕你用了自研批量插入,我也建议顺手把连接 URL 里的这些参数加上:

rewriteBatchedStatements=true useServerPrepStmts=false useAffectedRows=true

useServerPrepStmts=false的意思是让驱动走客户端的预编译逻辑,不要把预编译步骤放到服务端,对批量插入来说可以省掉服务端反复 prepare 的开销。useAffectedRows=true是让 JDBC 驱动返回被修改的行数而不是匹配的行数,这个在你做批量插入返回值判断时语义更准确,MySQL 默认连接模式下executeBatch返回的是匹配行数,容易让人产生“明明插了 100 条怎么返回 1000 条”的困惑。

这三个参数配合自己实现的批量插入,可以做到无论框架走不走executeBatch,性能都不会退化到默认那种可怕程度。

4.3 动态分批策略怎么写

有些同学在调用批量插入时会把整个大 List 直接丢给insertBatchSomeColumn,想着框架会帮忙拆分。这个假设是错的,MyBatis-Plus 不会帮你拆,一次传 10 万条就是 10 万条挤一条 SQL。所以我们在业务侧必须做好分批。

我写了一个最简单的分批工具方法:

public <T> int batchInsert(List<T> list, BatchBaseMapper<T> mapper) { if (list == null || list.isEmpty()) { return 0; } int total = 0; int batchSize = 500; int loopTimes = (list.size() + batchSize - 1) / batchSize; for (int i = 0; i < loopTimes; i++) { List<T> subList = list.subList(i * batchSize, Math.min((i + 1) * batchSize, list.size())); total += mapper.insertBatchSomeColumn(subList); } return total; }

这里的batchSize可以做成动态的,比如通过配置中心下发,或者启动时根据max_allowed_packet自动计算。我项目里最终没有做那么复杂,因为 500 是个很稳的值,再大性能提升不超过 10%,带来的风险却会增加。分页切分逻辑要注意最后一个批次的数据量不足整批时,不能越界。

5. 常见问题与避坑实录

5.1 常见报错速查表

报错或现象根本原因处理方式
PacketTooBigException,或 MySQL server has gone away单条 SQL 超过 max_allowed_packet调小单批条数,或者调大 max_allowed_packet
占位符超过 65535 导致 SQL 执行失败一条 SQL 里参数问号太多根据字段数计算最大条数,按计算结果分批
插入成功后实体主键为 nullinsertBatchSomeColumn 不配置 useGeneratedKeys插入前自行生成 ID,或改用 foreach XML 方案
批量插入后逻辑删除字段变成空谓词没排除逻辑删除字段注入器里加t -> !t.isLogicDelete()
事务里一次性插 10 万条,提交很慢甚至超时单事务过大、undo log 膨胀拆成多个事务,或者每批之间主动提交
提示 null 字段不能插入实体字段里有 null,且数据库列非空检查默认值策略,或在实体层面做好默认值填充

5.2 我踩过的三个坑

第一个坑:16 万行数据一口气传进批量插入,MySQL 直接连接断开。原因是这条大 SQL 太大了,服务端一边接收一边写 binlog,内存和网络双双被占满,最后连接被系统判定异常杀掉。那次之后我学乖了,所有批量插入入口都强制走分批工具方法,谁也不准直接传大 List。

第二个坑:默认saveBatch在没加rewriteBatchedStatements的情况下,每执行一条就发一次网络包。当时测试同学告诉我接口响应时间不稳定,时快时慢,后来抓包才发现问题出在驱动配置上。这让我意识到,任何批量性能问题都要先怀疑是不是 JDBC 驱动参数在作怪,而不是立刻改业务代码。

第三个坑:用insertBatchSomeColumn插入一张表,表里有一个逻辑删除字段,结果批量插入后所有数据的逻辑删除字段都是空。原因就是注入器没有排除这个字段,SQL 里根本没有这个列,数据库默认值也没生效。那次被线上数据坑了一把之后,我才认真去看InsertBatchSomeColumn的谓词构造逻辑,才有了上面那个过滤写法。

实际使用的小建议

如果你问我最终推荐哪一个方案,我会说:项目里先加一个EasySqlInjector,把insertBatchSomeColumn注入进所有 Mapper,然后在上层封装一个分批插入的工具类,把批大小默认设置成 500 放在常量里,全项目统一走这个入口。字段解析、表名转义、逻辑删除字段过滤这些全部由 MyBatis-Plus 的元数据机制兜底,自己不用碰任何 XML。只有遇到插入后必须回填自增主键的场景,才考虑退回 foreach XML 方案,并且把这种特例收敛到极少数表,不要让它扩散成全项目到处都是手写 SQL。这套思路在我负责的多个数据导入和同步模块里已经稳定运行了很久,性能和数据一致性都经得起检验。批量插入本身并不是个高深难题,难的是你愿不愿意在默认方案不好用的时候,把底层逻辑扒开看一遍,自己动手修好它。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询