☰
MyBatis-Plus批量新增实战:从saveBatch源码原理到性能调优与踩坑指南
2026/9/30 8:43:43 网站建设 项目流程

手工拼接foreach或者直接调saveBatch,但很少有人真的把这玩意的原理吃透。今天我就把自己折腾MyBatis-Plus批量新增的过程、踩坑记录、性能测试结果一次说清楚,尤其适合刚接手数据导入类需求、正在为几千几万条数据插入发愁的同学。

先说结论:MyBatis-Plus的saveBatch确实是目前最省事的批量新增方案,但它远不是"调用一下就行"那么简单。背后的ExecutorType.BATCH、JDBC连接参数rewriteBatchedStatements、批次大小调优、事务边界控制,每一项都直接影响最终效果。这篇文章会从源码逻辑讲到实操配置,再给出一套实战下来最稳妥的入库方案。

1. 为什么"真正的批量新增"是个值得较真的问题

1.1 从一次性能事故说起

之前维护一个订单导入系统,Excel里动辄十几万行数据。最初接手时代码长这样:

public void importOrders(List<Order> orders) { for (Order order : orders) { orderMapper.insert(order); } }

10万条数据插了一个多小时,数据库连接频繁建立和释放,时不时还把连接池打满。那会儿第一反应是"加个线程池并发呗",结果并发一开,数据库直接死锁,比单线程还惨。后来才意识到,问题不是并发不够,而是每条insert都走了一次完整的SQL执行链路,这种开销在数据量上来之后是灾难级的。

也是从那时候开始,我认真研究了MyBatis-Plus的批量新增能力。它表面上是替我们省了几行代码,实际上是把MyBatis框架底层的批处理Executor、JDBC驱动的批量提交、数据库服务端的解析优化全都串起来了。光知道调saveBatch皮毛,远远不够。

1.2 MyBatis-Plus批量新增到底解决了什么

MyBatis-Plus的批量新增,本质上是解决"大量单条insert导致数据库交互次数过多"的问题。它把原本需要N次网络往返的插入操作,合并成有限次数的批量执行请求,从而显著降低IO开销和数据库解析压力。

但网上一谈到这个话题,最常见的回答就是一句"用saveBatch啊",仿佛这就是银弹。实际项目里我见过太多用了saveBatch依然慢得离谱、甚至报错的情况。原因不外乎几个:JDBC连接串缺少关键参数、批次大小设置不合理、没有理解底层ExecutorType.BATCH真正的执行机制、或者事务用错了导致批量根本没生效。这些坑如果不踩一遍,很难理解为什么同样调用saveBatch,有人快有人慢。

1.3 这篇文章适合谁

如果你满足下面任一情况,这篇文章应该能帮你省不少时间:

  • 正在做数据导入功能,数据量在几万到上百万级别,想知道用什么方案插入最快。
  • 已经在用saveBatch,但性能不理想,想排查是不是漏了关键配置。
  • 面试聊到MyBatis-Plus批量插入时被问到底层原理,想彻底搞清楚ExecutorType.BATCH和普通执行的差别。
  • 想看一份包含参数配置、代码示例、压测数据和避坑清单的完整参考。

2. 吃透saveBatch的底层原理,才能用好它

2.1 核心机制:ExecutorType.BATCH加flushStatements

很多人以为saveBatch是把1000条数据拼成一条超级长的多VALUES SQL一次性执行,这其实是个误解。去看MyBatis-Plus源码,核心逻辑在SqlHelper.executeBatch这个方法里,它做的事情是:

// MyBatis-Plus源码逻辑简化 public static <E> boolean executeBatch(Class<?> entityClass, Log log, Collection<E> list, int batchSize, BiConsumer<BaseMapper, E> consumer) { SqlSessionFactory sqlSessionFactory = getSqlSessionFactory(); try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) { E entity; int index = 0; for (Iterator<E> it = list.iterator(); it.hasNext(); index++) { entity = it.next(); consumer.accept(mapper, entity); if ((index + 1) % batchSize == 0) { sqlSession.flushStatements(); } } sqlSession.flushStatements(); return true; } }

关键点有两个。第一,它打开的是一个ExecutorType.BATCH类型的SqlSession,在这个模式下,每次mapper.insert()并不会立刻执行SQL,而是把语句缓存在Executor内部。第二,积累到batchSize条之后,通过flushStatements()一次性推送到数据库执行。

所以saveBatch本质上跟手写SqlSessionFactory.openSession(ExecutorType.BATCH)是同一回事,只是框架帮我们管理了分批、提交、关闭等细节。理解了这一点,就能明白为什么saveBatch的性能远好于循环单插——它把大量网络往返压成了有限批次。

2.2 容易被忽略的JDBC参数:rewriteBatchedStatements

这是我最想强调的一个参数。ExecutorType.BATCH模式下,MyBatis确实会批量发送语句,但如果MySQL驱动没有开启rewriteBatchedStatements,驱动仍然会逐条把语句发送给服务器端执行。也就是说,虽然减少了应用与驱动之间的来回,但数据库解析的次数没有本质变化。

官方建议在JDBC连接串里加这么一段:

jdbc:mysql://localhost:3306/order_db?useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&useServerPrepStmts=false

rewriteBatchedStatements=true会让驱动把一批同构的INSERT重写成一条多VALUES的SQL,比如5条insert合并成insert into t (a,b) values (?,?),(?,?),(?,?)这种形式。这个优化对插入性能的提升是数量级的,尤其在数据量大、字段不算很多的场景下。

我自己的测试数据里,同样10万条数据,开了这个参数和不开,耗时差距大概在3倍左右。很多网上教程只教你调batchSize,却漏了这一步,等于车装好了没踩油门。

注意:useServerPrepStmts建议配合关闭,否则批量模式下的预处理语句缓存可能占用大量内存,在某些MySQL版本下还会引发奇怪的报错。

2.3 batchSize不是越大越好

saveBatch默认批次是1000,我看很多人喜欢改成5000甚至10000,觉得一批塞得越多越快。实测下来并非如此。当单批数据量太大时,SQL解析时间变长、内存占用升高、数据库锁持有时间增加,反而拖慢整体执行,并发高的时候还会大幅度增加死锁概率。

我自己压测过10万条数据在不同batchSize下的表现:

batchSize耗时(约)备注
500中等网络往返较多,适合低内存环境
1000推荐综合性能与稳定性最好
2000略快提升有限,内存占用略高
5000接近2000锁竞争和内存压力明显增加
10000不稳定出问题概率大增,不推荐

所以我的建议是:常规场景保持1000,如果单次数据量非常大且表结构简单,最多调到2000。为了那点性能增量去冒锁超时、内存溢出的风险,完全不值得。

3. 五种批量新增方案横评与选型逻辑

3.1 方案一:循环单条插入

for (User user : userList) { userMapper.insert(user); }

优点只有一个:简单。缺点到处都是:每条数据都生成一次完整的SQL执行链路,数据库连接来回次数等于数据行数,性能最差。我在一个生产系统里见过用这种方式插5000条数据耗时一分多钟的。它的存在意义基本只限于验证Mapper配置,或者单次插入几十条以内的小场景。

3.2 方案二:IService自带的saveBatch

@Autowired private UserService userService; public void importUsers(List<User> users) { userService.saveBatch(users); }

这是当前最均衡的方案。代码量最少,底层自动分批,默认批次1000,内部使用ExecutorType.BATCH。因为MyBatis-Plus已经封装完整,事务、连接关闭、异常处理这些细节都有框架兜底,出问题概率低。绝大多数业务系统的批量插入需求,用saveBatch就够了。

如果对批次有要求,也可以调用带批次数量的重载方法:

userService.saveBatch(users, 500);

3.3 方案三:手动SqlSession批量模式

@Autowired private SqlSessionFactory sqlSessionFactory; public void batchInsert(List<User> users) { try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { UserMapper mapper = sqlSession.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); } sqlSession.commit(); } }

这种方式更底层,灵活性最高。如果同一个批量流程里需要穿插操作多个Mapper、执行不同类型的DML,或者需要精确控制事务提交时机,手动开一个批量SqlSession是更合适的选择。但要注意,它要求开发者自己对资源释放和事务边界负责,稍不注意就会连接泄漏。

我用try-with-resources就是为了保证SqlSession一定被关闭,这是手写批量模式最容易翻车的地方。

3.4 方案四:自定义foreach拼接多VALUES SQL

void insertBatch(@Param("list") List<User> list);
<insert id="insertBatch"> insert into user (name, age, email) values <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}) </foreach> </insert>

这种方式是纯手工拼SQL,性能上限最高,因为多条数据就是一条SQL、一次网络请求、一次解析执行。但缺点同样明显:SQL长度和参数数量随数据量线性增长,很容易触发数据库max_allowed_packet限制。如果某条数据里有个超长文本字段,几百条record就能把SQL撑爆。

我的经验是,用foreach拼接时单批控制在1000到2000条之间,并且提前确认线上数据库的max_allowed_packet配置。如果数据行数太多,仍然要手动分批循环调用。

3.5 方案五:Executor层自定义拦截器

这种方案属于高级玩法,在MyBatis的Executor或StatementHandler层面做拦截,实现更底层的批量控制。适合框架级封装、公司内部公共组件开发,普通业务项目完全没必要。了解存在即可,真到了需要写拦截器那一步,说明业务复杂度已经远超常规了。

3.6 我的选型建议

简单总结一个选择逻辑:

  • 常规业务批量插入,优先saveBatch,省心可靠。
  • 插入量巨大且表结构简单,又不介意维护SQL的人,选自定义foreach,性能最猛。
  • 批量过程需要混合更新、插入多个Mapper,或者精确把控事务边界,选手动SqlSession批量模式。
  • 单次几十条的小数据量,循环插入也没啥问题,别杀鸡用牛刀。

4. 实战:从万级到百万级数据入库的完整过程

4.1 场景设定与初始化准备

为了把几种方案的真实差异测出来,我准备了一个测试表user,结构大概是这样:

CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `name` varchar(64) NOT NULL, `age` int(11) DEFAULT NULL, `email` varchar(128) DEFAULT NULL, `create_time` datetime DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

然后循环生成了10万条测试数据。实体类上加了MyBatis-Plus注解,create_time字段配置了自动填充:

@Data @TableName("user") public class User { @TableId(type = IdType.AUTO) private Long id; private String name; private Integer age; private String email; @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; }

自动填充处理器也写好了,这样插入时create_time会自动赋当前时间,省得每条数据手工set。

4.2 关键步骤:核心实现代码

先看IService.saveBatch的调用示例:

@Service public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService { @Override public void batchInsertWithSaveBatch(List<User> users) { saveBatch(users); } }

然后看手动SqlSession版本,这里特意演示了多表混合操作的场景:

public void batchInsertWithManualSession(List<User> users, List<Order> orders) { try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH, false)) { UserMapper userMapper = sqlSession.getMapper(UserMapper.class); OrderMapper orderMapper = sqlSession.getMapper(OrderMapper.class); for (User user : users) { userMapper.insert(user); } for (Order order : orders) { orderMapper.insert(order); } sqlSession.commit(); } }

再来看自定义foreach SQL怎么落地。Mapper接口方法:

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

XML映射:

<insert id="insertBatch"> insert into user (name, age, email, create_time) values <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.email}, #{item.createTime}) </foreach> </insert>

注意create_time如果依赖自动填充,foreach SQL里不会自动加值,需要提前在实体里set好,否则就是null。这是很多人从saveBatch切换到foreach时踩到的坑。

4.3 实测数据与耗时对比

测试环境是单机MySQL 8.0,4核8G,JDK 1.8,MyBatis-Plus 3.4.x。插入10万条数据,先清空表,再分别用三种主流方式跑:

方案连接串关键参数耗时
循环单条插入默认约95秒
saveBatch默认1000rewriteBatchedStatements=true约8秒
自定义foreach批量1000rewriteBatchedStatements=true约3.5秒

这个对比足够直观了。saveBatch比循环快了十倍以上,而foreach因为省去了批量Executor内部的分批与flush开销,又快了一倍多。但要注意,foreach方案在字段变多、数据行变大的情况下,SQL长度膨胀得很快,选它之前先确认表的max_allowed_packet和单条数据大小。

4.4 自动填充和逻辑删除的兼容性问题

这里单独拎出来说,是因为太容易踩了。

先说自动填充。saveBatch走的是BaseMapper.insert的完整插入流程,所以MetaObjectHandler的insertFill是生效的,前提是实体字段标了@TableField(fill = FieldFill.INSERT)。我遇到过一次线上批量导入后create_time全是null,排查了半天,最后发现是同事新加的字段漏了fill注解。

再说逻辑删除。如果实体字段上有@TableLogic,批量插入时这个字段的默认值并不会被框架自动处理。比如逻辑删除字段deleted默认应该是0,你必须在实体初始化时给它set好,或者数据库列直接设置默认值0。否则每次都担心插进去的数据是不是被"逻辑删掉了"。

// 正确做法:实体初始化时给逻辑删除字段默认值 public class User { @TableLogic private Integer deleted = 0; }

4.5 流式读取配合分批插入,内存稳稳的

数据量到百万级时,一次性把所有数据加载到内存再批量插入,很容易OOM。我在做Excel导入时用过这样一个组合:用EasyExcel监听器逐行读取,每凑够一批数据就立即批量插入,然后清空列表。这样内存里始终只有一小批数据,GC压力小得多。

核心逻辑大致是:

public class UserDataListener extends AnalysisEventListener<User> { private static final int BATCH_COUNT = 3000; private List<User> cacheList = new ArrayList<>(BATCH_COUNT); @Override public void invoke(User user, AnalysisContext context) { cacheList.add(user); if (cacheList.size() >= BATCH_COUNT) { saveBatch(cacheList); cacheList.clear(); } } @Override public void doAfterAllAnalysed(AnalysisContext context) { if (!cacheList.isEmpty()) { saveBatch(cacheList); cacheList.clear(); } } }

这样10万条Excel数据读入内存时,峰值也就3000条对象的开销,比一次性加载全部再处理舒服太多了。如果直接用EasyExcel.syncRead一次性读取,再厉害的内存也扛不住超大文件。

5. 连接、事务、并发这些周边配置,才是性能天花板

5.1 MySQL连接串的完整配置参考

我前面反复强调了rewriteBatchedStatements,这里给出一个生产常用的完整配置:

jdbc:mysql://127.0.0.1:3306/test_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true&useServerPrepStmts=false&allowMultiQueries=false

其中allowMultiQueries不建议开,除非你确实需要多条SQL一起执行,否则它会让很多异常变得更难排查。

max_allowed_packet也要提前检查:

SHOW VARIABLES LIKE 'max_allowed_packet';

如果值是4M,而你的foreach批量SQL比较大,分分钟爆掉。建议至少调整到64M以上,云数据库控制台一般都能直接修改参数。

5.2 事务边界怎么定,才不会锁死人

很多人批量插入喜欢在最外层加一个大@Transactional,几万条数据一个事务。看着没毛病,实际上事务太长会导致:锁持有时间过长、并发插入互相阻塞、回滚成本极高、binlog和undo log压力巨大。

我的经验是:按批次切分事务。一批数据一个事务,每处理完一批就提交。这样中途出错只需要回滚当前批次,前序批次已经安全入库,重跑成本很低。代价是如果业务要求全量原子性,就不能这么干,只能用大事务硬扛。但导入类场景通常允许部分成功,分批提交是更现实的选择。

public void importInBatches(List<List<User>> batchList) { for (List<User> batch : batchList) { try { userService.saveBatch(batch); } catch (Exception e) { log.error("batch import failed, size={}", batch.size(), e); // 记录失败批次,后续重试 } } }

5.3 并行批量插入的正确姿势

前面说了并发不好控制,那到底能不能并发?能,但要有前提。

我常用的做法是先把全量数据按主键或业务维度分片,比如按用户ID哈希取模分成4个分片,每个分片一个线程调用saveBatch。线程数不盲目开大,一般就是数据库连接池可用连接数的一半左右,留出冗余给其他查询。

并发维度的连接池参数也要配合调整。我习惯把HikariCP的maximum-pool-size设置为CPU核心数的两倍附近,而不是默认的10。minimum-idle设为1,避免空闲连接占用太多。

spring: datasource: hikari: maximum-pool-size: 16 minimum-idle: 4 connection-timeout: 30000

分片加并发的组合,在数据量百万级别的导入场景,实测可以把总耗时压到理想水平。但前提是数据库服务器本身别太弱,否则并发一上去先倒下的可能是数据库。

6. 常见问题与排查技巧速查

6.1 高频报错对照表

我把这些年批量插入遇到的高频问题整理成一张表,建议直接收藏:

报错信息常见原因解决方案
PacketTooBigException单条SQL超过max_allowed_packet限制调小批次,或增大max_allowed_packet
Deadlock found when trying to get lock大批量插入锁竞争拆分批次、按主键排序、缩短事务
OutOfMemoryError一次性加载数据过多流式读取配合分批处理
Invalid bound statement (not found)Mapper方法或XML没配置好检查namespace、方法名、参数注解
Can't found MS (分页插件相关)批量Executor混用分页查询分离SqlSession,异常兜底

6.2 排查思路与日志技巧

遇到批量插入慢或者报错,别急着改代码,先确认三件事:第一,连接串里rewriteBatchedStatements开没开;第二,批次大小设置得合理不合理;第三,是不是把分页查询塞进了批量SqlSession。

排查环境里,我建议打开MySQL的general_log观察真实执行的SQL语句,或者借助云数据库的审计日志。看日志时重点确认批量模式下的多条insert是否真的被合并成了一条多VALUES SQL。如果日志里还是逐条insert,那说明rewriteBatchedStatements没生效,或者驱动版本不支持。

Java侧排查可以用Arthas,线上环境很实用。我一般会用trace命令看一下saveBatch内部各方法的耗时占比,确认瓶颈是在flushStatements还是在实际的SQL执行上。这一步能帮你判断问题出在框架层还是数据库层。

6.3 几种"看起来对但实际不生效"的情况

网上的批量教程很多,我补充几个常见的"伪优化"情况,大家留意:

  • 连接串加了rewriteBatchedStatements=true,但用的MySQL驱动版本过老,参数被忽略。升级驱动版本再测。
  • 循环里自己手动调flushStatements,但事务没提交,数据一直不可见,造成"好像没插进去"的错觉。
  • saveBatch在小数据量下看起来和循环差不多,就断定它没用。其实数据量上千之后差距才会拉开。
  • 把分页查询写在批量方法里,分页插件直接失效,查出的数据不全,还不好排查。批量会话和分页查询务必分开。

7. 版本迭代、升级策略与最后的心得

7.1 MyBatis-Plus版本演进对批量能力的影响

MyBatis-Plus从3.x早期版本到现在,saveBatch的核心实现逻辑大方向没变,但细节一直在优化。比如3.4.0之后对批量操作在异常处理、消息转换等方面修补了不少问题,3.5.x之后又在BaseMapper层新增了一些更方便的批量方法。我升级版本时有个习惯:先看更新日志里有没有涉及executeBatch、IService.saveBatch、SqlHelper的改动,再去翻源码确认行为变化。

针对不同版本,代码写法上差别不大,但底层行为可能有差异。比如某些旧版本在saveBatch时不做自动提交,需要外部事务配合;某些版本在事务环境下会有额外的嵌套判断。这些差异通常不容易发现,最稳的做法是升级后跑一轮批量插入的回归测试,数据量覆盖1万到10万。

7.2 新版本特性观察:insertBatchSomeColumn等

除了saveBatch,MyBatis-Plus还提供了insertBatchSomeColumn这类方法,允许按字段选择性插入,null字段自动忽略。这种方法的SQL是动态拼接的,不是传统的全字段insert,在某些表字段多、大部分字段可空的场景下很有用,生成的SQL更紧凑。

但要注意,这个方法在MyBatis-Plus里的使用门槛比saveBatch高一些,需要自定义Injector注入,而且对字段策略有要求。我建议普通项目先用好saveBatch,等确实遇到"字段太多、null太多、生成SQL太长"这类痛点时,再考虑引入insertBatchSomeColumn。提前引入会增加理解成本,除非团队里有人已经踩过坑、能稳定维护。

7.3 批量插入领域,我最想分享的三条经验

文章写到这里,想把自己的体会说透一点。

第一,性能优化永远先看配置,再看代码。很多人一提到批量插入慢,就扎进代码找循环问题,但往往忽略连接串参数、驱动版本、数据库配置这些前置环节。rewriteBatchedStatements没开,代码写得再花哨也是白搭。

第二,稳定的可靠性比极限性能更重要。我们系统里有个导入场景,把saveBatch的批次从1000调到5000后,单次是快了一点点,但数据库锁等待和死锁概率明显上升,最后还是调回1000。追求那点微乎其乎的耗时差,换来的是上线的忐忑,不划算。

第三,数据量大的导入场景,一定要设计好失败重试机制。批量插入越高效,一次性受影响的数据越多,出问题时排查越麻烦。我现在的做法是:按业务批次拆分、每批独立事务、失败记录日志并落表,后续通过定时任务或者手工脚本重跑失败批次。这套机制上线后,几乎没再因为批量导入出过"查都查不清楚"的事故。

7.4 一个简单的压测自检清单

最后分享一份我每次写批量插入功能都要过的自检清单,你也可以直接抄走:

  • [ ] JDBC连接串已配置rewriteBatchedStatements=true
  • [ ] 数据库max_allowed_packet已确认足够大
  • [ ] 单批数据量控制在合理范围(1000到2000)
  • [ ] 事务按批次切分,不追求一个大事务包揽所有数据
  • [ ] 批量SqlSession在try-with-resources中管理
  • [ ] 逻辑删除字段、自动填充字段都已初始化
  • [ ] 批量操作与分页查询不在同一个SqlSession中
  • [ ] 线上坏境被测过1万、10万、100万三个量级

如果你照着这些点逐项排查完,批量新增基本不会再有让你半夜爬起来处理的问题。整个MyBatis-Plus的批量新增机制说穿了并不神秘,就是把单条执行变成批量执行,把SQL网络往返次数压到最低,但真正拉开性能差距的,永远是哪些容易被忽略的周边配置和细节取舍。

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

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

立即咨询