手工拼接foreach或者直接调saveBatch但很少有人真的把这玩意的原理吃透。今天我就把自己折腾MyBatis-Plus批量新增的过程、踩坑记录、性能测试结果一次说清楚尤其适合刚接手数据导入类需求、正在为几千几万条数据插入发愁的同学。先说结论MyBatis-Plus的saveBatch确实是目前最省事的批量新增方案但它远不是调用一下就行那么简单。背后的ExecutorType.BATCH、JDBC连接参数rewriteBatchedStatements、批次大小调优、事务边界控制每一项都直接影响最终效果。这篇文章会从源码逻辑讲到实操配置再给出一套实战下来最稳妥的入库方案。1. 为什么真正的批量新增是个值得较真的问题1.1 从一次性能事故说起之前维护一个订单导入系统Excel里动辄十几万行数据。最初接手时代码长这样public void importOrders(ListOrder 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, CollectionE list, int batchSize, BiConsumerBaseMapper, E consumer) { SqlSessionFactory sqlSessionFactory getSqlSessionFactory(); try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { E entity; int index 0; for (IteratorE 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?useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrueuseServerPrepStmtsfalserewriteBatchedStatementstrue会让驱动把一批同构的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自带的saveBatchAutowired private UserService userService; public void importUsers(ListUser users) { userService.saveBatch(users); }这是当前最均衡的方案。代码量最少底层自动分批默认批次1000内部使用ExecutorType.BATCH。因为MyBatis-Plus已经封装完整事务、连接关闭、异常处理这些细节都有框架兜底出问题概率低。绝大多数业务系统的批量插入需求用saveBatch就够了。如果对批次有要求也可以调用带批次数量的重载方法userService.saveBatch(users, 500);3.3 方案三手动SqlSession批量模式Autowired private SqlSessionFactory sqlSessionFactory; public void batchInsert(ListUser 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 SQLvoid insertBatch(Param(list) ListUser list);insert idinsertBatch insert into user (name, age, email) values foreach collectionlist itemitem 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;然后循环生成了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 ServiceImplUserMapper, User implements UserService { Override public void batchInsertWithSaveBatch(ListUser users) { saveBatch(users); } }然后看手动SqlSession版本这里特意演示了多表混合操作的场景public void batchInsertWithManualSession(ListUser users, ListOrder 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) ListUser list);XML映射insert idinsertBatch insert into user (name, age, email, create_time) values foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.email}, #{item.createTime}) /foreach /insert注意create_time如果依赖自动填充foreach SQL里不会自动加值需要提前在实体里set好否则就是null。这是很多人从saveBatch切换到foreach时踩到的坑。4.3 实测数据与耗时对比测试环境是单机MySQL 8.04核8GJDK 1.8MyBatis-Plus 3.4.x。插入10万条数据先清空表再分别用三种主流方式跑方案连接串关键参数耗时循环单条插入默认约95秒saveBatch默认1000rewriteBatchedStatementstrue约8秒自定义foreach批量1000rewriteBatchedStatementstrue约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 AnalysisEventListenerUser { private static final int BATCH_COUNT 3000; private ListUser 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?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghairewriteBatchedStatementstrueuseServerPrepStmtsfalseallowMultiQueriesfalse其中allowMultiQueries不建议开除非你确实需要多条SQL一起执行否则它会让很多异常变得更难排查。max_allowed_packet也要提前检查SHOW VARIABLES LIKE max_allowed_packet;如果值是4M而你的foreach批量SQL比较大分分钟爆掉。建议至少调整到64M以上云数据库控制台一般都能直接修改参数。5.2 事务边界怎么定才不会锁死人很多人批量插入喜欢在最外层加一个大Transactional几万条数据一个事务。看着没毛病实际上事务太长会导致锁持有时间过长、并发插入互相阻塞、回滚成本极高、binlog和undo log压力巨大。我的经验是按批次切分事务。一批数据一个事务每处理完一批就提交。这样中途出错只需要回滚当前批次前序批次已经安全入库重跑成本很低。代价是如果业务要求全量原子性就不能这么干只能用大事务硬扛。但导入类场景通常允许部分成功分批提交是更现实的选择。public void importInBatches(ListListUser batchList) { for (ListUser 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_packetDeadlock found when trying to get lock大批量插入锁竞争拆分批次、按主键排序、缩短事务OutOfMemoryError一次性加载数据过多流式读取配合分批处理Invalid bound statement (not found)Mapper方法或XML没配置好检查namespace、方法名、参数注解Cant 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 几种看起来对但实际不生效的情况网上的批量教程很多我补充几个常见的伪优化情况大家留意连接串加了rewriteBatchedStatementstrue但用的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等除了saveBatchMyBatis-Plus还提供了insertBatchSomeColumn这类方法允许按字段选择性插入null字段自动忽略。这种方法的SQL是动态拼接的不是传统的全字段insert在某些表字段多、大部分字段可空的场景下很有用生成的SQL更紧凑。但要注意这个方法在MyBatis-Plus里的使用门槛比saveBatch高一些需要自定义Injector注入而且对字段策略有要求。我建议普通项目先用好saveBatch等确实遇到字段太多、null太多、生成SQL太长这类痛点时再考虑引入insertBatchSomeColumn。提前引入会增加理解成本除非团队里有人已经踩过坑、能稳定维护。7.3 批量插入领域我最想分享的三条经验文章写到这里想把自己的体会说透一点。第一性能优化永远先看配置再看代码。很多人一提到批量插入慢就扎进代码找循环问题但往往忽略连接串参数、驱动版本、数据库配置这些前置环节。rewriteBatchedStatements没开代码写得再花哨也是白搭。第二稳定的可靠性比极限性能更重要。我们系统里有个导入场景把saveBatch的批次从1000调到5000后单次是快了一点点但数据库锁等待和死锁概率明显上升最后还是调回1000。追求那点微乎其乎的耗时差换来的是上线的忐忑不划算。第三数据量大的导入场景一定要设计好失败重试机制。批量插入越高效一次性受影响的数据越多出问题时排查越麻烦。我现在的做法是按业务批次拆分、每批独立事务、失败记录日志并落表后续通过定时任务或者手工脚本重跑失败批次。这套机制上线后几乎没再因为批量导入出过查都查不清楚的事故。7.4 一个简单的压测自检清单最后分享一份我每次写批量插入功能都要过的自检清单你也可以直接抄走[ ] JDBC连接串已配置rewriteBatchedStatementstrue[ ] 数据库max_allowed_packet已确认足够大[ ] 单批数据量控制在合理范围1000到2000[ ] 事务按批次切分不追求一个大事务包揽所有数据[ ] 批量SqlSession在try-with-resources中管理[ ] 逻辑删除字段、自动填充字段都已初始化[ ] 批量操作与分页查询不在同一个SqlSession中[ ] 线上坏境被测过1万、10万、100万三个量级如果你照着这些点逐项排查完批量新增基本不会再有让你半夜爬起来处理的问题。整个MyBatis-Plus的批量新增机制说穿了并不神秘就是把单条执行变成批量执行把SQL网络往返次数压到最低但真正拉开性能差距的永远是哪些容易被忽略的周边配置和细节取舍。