资讯动态

批量插入性能优化实战:MyBatis-Plus saveBatch的替代方案

发布时间:2026/10/21 22:23:12 来源:尧图企业网站定制
批量插入这事不同阶段的人处理方式完全不一样。刚用 MyBatis-Plus 的时候我遇到批量写库一律saveBatch一把梭几千条数据也没觉得有什么问题。直到有一次做数据迁移要在模拟项目X里给一张单表灌几十万行历史记录saveBatch跑起来后整个服务都像被按了暂停键我才认真去研究MyBatis-Plus 的批量插入到底是怎么实现的我又该怎么自己写一套真正能打的批量插入。这篇文章不打算讲 MyBatis-Plus 的基础 CRUD也不去逐行翻译源码就围绕“自己实现批量插入”这件事把底层原理、三条落地写法、性能实测数据和踩坑全过程都整理出来。适合被大数据量插入性能卡住的 Java 后端开发者参考尤其是做数据迁移、Excel 导入、日志入库这类批量写需求的同学。1. 先搞清楚一个反直觉的现象saveBatch怎么反而不快1.1 saveBatch的内部实现里藏着一个容易被忽视的点我最早天真地以为saveBatch会把多条 SQL 合并成一条INSERT INTO ... VALUES (...),(...)后来拆了源码才发现并不是这么简单。MyBatis-Plus 的批量插入实现本质上是在框架层面开启了 MyBatis 的ExecutorType.BATCH执行模式然后遍历集合反复调用单条insert攒够一定数量后执行flushStatements()。默认批大小是 1000也就是说默认每 1000 条单行 insert 才真正往数据库写一次。看到这你可能觉得这没问题啊BATCH 模式不就是批处理吗问题恰恰出在更底层的一环——MySQL 的 JDBC 驱动有没有真正把这一批单行 insert 合并成一条多 values 语句。如果不加rewriteBatchedStatementstrue驱动默认会把 batch 里的每条 insert 单独发送到 MySQL 服务端执行只是把结果当成一个批次返回而已。也就是说SQL 解析次数没减少网络往返次数没减少MyBatis 层面攒了半天的批到 MySQL 那边基本等于逐条执行。这就解释了为什么大批量场景下saveBatch效率一点都不好看。框架确实用了 BATCH 执行器但驱动没有做语句重写性能提升非常有限。我当时第一次跑迁移任务20 万条数据saveBatch跑了快二十分钟还没结束当时就决定必须自己动手了。1.2 JDBC的executeBatch与MySQL驱动重写机制要自己实现批量插入必须先理解 JDBC 的批处理链路。PreparedStatement支持addBatch()和executeBatch()这是 JDBC 规范层面的批处理接口。按规范executeBatch()应该把一批 SQL 一次性发给数据库执行减少客户端和服务端的交互次数。但 MySQL Connector/J 驱动有一个很关键的开关rewriteBatchedStatements。这个参数默认是 false。关闭状态下驱动虽然会使用批处理接口但它确实无法把多条INSERT INTO t(a,b,c) VALUES(?,?,?)合并成一条多 values 语句还是会逐条发送。只有把这个参数打开驱动才会在服务端把多条单行 insert 重写成一条INSERT INTO ... VALUES (...),(...),(...)网络往返和 SQL 解析次数瞬间降到原来的几十分之一。这也是为什么很多文章强调 MySQL 连接 URL 要加rewriteBatchedStatementstrue。但这里有个容易误解的地方这个参数只对真正走executeBatch()的代码有用。如果你用的是 MyBatis 默认的SIMPLE执行器在循环里调用单条insert那每条 SQL 都是即时执行的根本没有进入 batch 流程加了参数也白搭。MyBatis-Plus 的saveBatch确实走的是 batch 流程所以加这个参数对它是有效的能提升一些性能但依然不如自己针对场景定制来得彻底。2. 自己实现批量插入的三条路线2.1 路线一foreach拼SQL最小改动见效最快第一种做法是直接在 Mapper XML 里写一个批量 insert 语句把 List 参数用foreach展开成多 values。这是网上最常见、改动最小的方案。insert idbatchInsertUsers parameterTypelist useGeneratedKeystrue keyPropertyid INSERT INTO t_user (name, phone, create_time) VALUES foreach collectionlist itemuser separator, (#{user.name}, #{user.phone}, #{user.createTime}) /foreach /insert调用侧代码也很简单自己控制一下分批逻辑不要一次性把几万条全丢进去public void batchInsert(ListUser users, int batchSize) { for (int i 0; i users.size(); i batchSize) { ListUser subList users.subList(i, Math.min(i batchSize, users.size())); userMapper.batchInsertUsers(subList); } }这个方案快的原因很直接SQL 在应用层就已经合并成一条多 values 语句MySQL 拿到手就是一条大 SQL解析一次提交一次不需要依赖驱动重写。实测下来比saveBatch默认行为快了一个数量级以上。不过要注意max_allowed_packet的限制。一次拼接太多条SQL 文本超过 MySQL 允许的最大数据包大小会直接报PacketTooBigException。我习惯默认每批 500 条如果单行字段特别长或者有 TEXT 字段会进一步降到 200 条。按行字节数估算更靠谱不要只按条数拍脑袋。2.2 路线二手动持有BatchExecutor沿用Mapper的映射能力如果你不想写一堆 XML还想继续用 Mapper 接口里的单条 insert 方法可以手动从 SqlSessionFactory 打开一个ExecutorType.BATCH的 SqlSession自己控制 flush 和 commit。这是比较“正统”的 MyBatis 批量写法。public void batchInsertByExecutor(ListUser users, int batchSize) { SqlSessionFactory sqlSessionFactory sqlSessionTemplate.getSqlSessionFactory(); SqlSession batchSession sqlSessionFactory.openSession(ExecutorType.BATCH, false); try { UserMapper mapper batchSession.getMapper(UserMapper.class); for (int i 0; i users.size(); i) { mapper.insert(users.get(i)); if ((i 1) % batchSize 0) { batchSession.flushStatements(); batchSession.commit(); } } batchSession.flushStatements(); batchSession.commit(); } finally { batchSession.close(); } }这里有几个关键点值得展开说。首先不要用 Spring 管理的sqlSessionTemplate直接开 batch因为它在事务上下文里绑定了当前连接执行器类型不一定能按你期望的方式切换。手动openSession(ExecutorType.BATCH, false)拿到的这个 session 不参与外部 Spring 事务完全由你控制 commit 时机用完必须在 finally 里 close否则连接会泄漏。其次flushStatements()才会真正触发executeBatch()如果你只 insert 不 flush数据不会真正写进去。这个方案的性能表现很大程度上取决于前面说的rewriteBatchedStatements参数。开了之后驱动会把 batch 里累积的多条单行 insert 重写成多 values 大 SQL效果和 route 一接近。不开的话虽然 MyBatis 在执行器层面做了一些 PreparedStatement 复用和批处理缓冲但性能提升相对有限。所以这个方案是一套组合拳ExecutorType.BATCH负责攒批驱动参数负责合并发送。2.3 路线三原生JDBC的addBatch绕开MyBatis拿回全部控制权第三种路线是完全绕开 MyBatis直接用 DataSource 拿连接走原生 JDBC 批处理。适合在独立的迁移工具、定时任务模块里用不依赖 MyBatis 的 Mapper 映射。public void batchInsertByJdbc(ListUser users, int batchSize) { Connection conn dataSource.getConnection(); try { conn.setAutoCommit(false); String sql INSERT INTO t_user (name, phone, create_time) VALUES (?, ?, ?); try (PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i users.size(); i) { User user users.get(i); ps.setString(1, user.getName()); ps.setString(2, user.getPhone()); ps.setTimestamp(3, Timestamp.valueOf(user.getCreateTime())); ps.addBatch(); if ((i 1) % batchSize 0) { ps.executeBatch(); conn.commit(); } } ps.executeBatch(); conn.commit(); } } finally { conn.setAutoCommit(true); conn.close(); } }很多人以为原生 JDBC 就一定比 MyBatis 快这是一个误区。真正拉开差距的不是框架而是是否有效利用了驱动重写和批处理。上面这段代码同样强依赖rewriteBatchedStatementstrue否则executeBatch()也是逐条发给 MySQL。它的优势在于没有框架的中间层开销拿到连接后想怎么控制事务就怎么控制特别适合需要精细控制批量和异常恢复的迁移场景。代价是表结构和实体类映射要自己写没有 MyBatis 帮你处理结果集和参数映射主键回填也得自己处理getGeneratedKeys()。如果项目本身已经是 MyBatis 体系我一般只在单独部署的迁移模块里用这个方案。2.4 三条路线怎么选我根据自己的项目经验整理了一张对比表方便按场景快速决策对比项foreach拼SQLBatchExecutor手动批处理原生JDBC addBatchSQL形式应用层多values大SQL单行insert靠驱动改写单行insert靠驱动改写是否依赖rewriteBatchedStatements不依赖强依赖强依赖主键回填可用useGeneratedKeys批处理下不稳定需谨慎需自行getGeneratedKeys迁移/批量场景适用度高高高侵入性需要写XML可复用Mapper接口完全绕开MyBatis性能量级快快最快如果你的需求是“今天就要把导入接口优化掉”直接选路线一改动最小收益最直观。如果你想把 MyBatis 的映射能力保留住同时还想精确控制批量和事务路线二最合适。如果你在做独立的数据迁移任务不想让批量逻辑干扰主业务路线三最干净。3. 实测数据与关键参数批量大小、驱动配置和事务边界3.1 测试环境与原始数据我在本地搭了一套测试环境跑这几条路线8 核 16G 的开发机JDK 17Spring Boot 2.7MySQL 8.0MySQL Connector/J 8.0.33。表结构很简单就id、name、phone、create_time四个字段。数据量取 2 万条每条记录 30 字节左右跑三遍取中位数尽量排除 JVM 预热和随机波动干扰。最开始的对照组是老老实实在 Service 层循环调用单条insert也就是 MyBatis 默认SIMPLE执行器的行为2 万条耗时大概在 25 秒上下这在预期之内。对比组包括saveBatch默认行为、foreach 分批、手动 BatchExecutor、原生 JDBC并且分别在rewriteBatchedStatements关闭和开启两种配置下测试。3.2 实测结果一个参数拉开十倍差距测试结果给我最直观的感受是一个 URL 参数直接把批量写入从“不可用”拉回“正常水平”。方案rewrite关闭rewrite开启循环单条insertSIMPLE执行器约25秒约25秒MyBatis-Plus saveBatch批大小1000约18秒约1.8秒foreach拼SQL每批500条约1.3秒约1.2秒手动BatchExecutor每批500条约12秒约1.0秒原生JDBC addBatch每批500条约12秒约0.8秒需要说明的是绝对数值在不同机器、不同表结构下会有差异但相对倍数关系是稳定的。循环单条 insert 无论如何都是 20 秒级别foreach 拼 SQL 因为本身就是一条大 SQL所以开不开重写参数影响不大。saveBatch和 BatchExecutor 这两个走 JDBC batch 接口的方案开不开rewriteBatchedStatements基本就是十倍差距。这个现象也反过来验证了第一章的判断框架层面的批处理只是第一步驱动层面的语句重写才是批量插入的性能核心。3.3 批大小怎么定500是一个很稳的经验值我测试过 100、500、1000、2000、5000 几档批大小。100 太小网络往返次数还是偏多性能明显不如 5002000 以上虽然单批耗时少了一点但 JVM 内存和 GC 压力随之上升如果赶上线上服务垃圾回收带来的停顿会比 SQL 执行时间更容易出问题5000 甚至更大的批次除了max_allowed_packet风险MySQL 端的大事务和锁持有时间也会变长。综合下来500 是我在各种项目里用得最稳的默认值。如果单行数据较大我会按“单批 SQL 预估不超过 1MB”来动态调整而不是死守条数。3.4 事务边界比SQL优化更容易被忽略还有一个很容易翻车的地方事务边界。很多人优化完 SQL 执行时间发现整个任务还是慢慢在哪慢在大事务提交。如果外部方法加了Transactional20 万条数据在一个事务里执行执行阶段可能只花几秒但事务提交阶段要处理大量的 undo log、binlog还会长时间持有行锁整体耗时不降反升。遇到这种情况我的做法是不用Transactional改成手动按批提交每 flush 一批就 commit 一次让每个批次都独立成为一个短事务。但要承认一个取舍按批提交意味着失去整体原子性如果第 37 批失败前 36 批已经入库了。对于数据导入、日志回放、历史迁移这类场景这个代价完全可接受因为可以按业务主键或批次维度做断点重试。如果业务要求必须“要么全部成功要么全部失败”那就只能老老实实保留大事务同时控制单次总量比如限制在 5 万以内避免事务提交阶段拖垮服务。4. 真实项目里踩过的三个坑批量插入排查全过程4.1 坑一批量任务跑起来线上业务接口全超时第一次把迁移任务上线到测试环境的时候批量任务刚启动没多久所有后台接口都开始超时。监控面板里连接池活跃连接直接打满等了很久的线程数不断上涨。排查时我先看了jstackdump发现大量业务线程阻塞在getConnection()说明连接池资源被批量任务占光了。再细化看批量任务的代码问题非常直接我用了sqlSessionFactory.openSession(ExecutorType.BATCH)但在异常路径上没有调用close()导致一批批 session 打开的连接全部泄漏最终撑爆连接池。这个问题的修复分两层。第一层是在 finally 里正确关闭 session这是基本要求。第二层更稳妥的做法是给批量任务单独配置一套数据源限制最大连接数比如 5 到 10 个同时把批量任务的线程数限制在连接数以内。这样就算迁移任务再疯狂也不会影响主业务的数据源。搞完这两步之后批量任务和线上接口才算真正“隔离”开。4.2 坑二批量插入成功了业务却拿不到主键ID第二个坑出现在数据入库之后的业务链路。当时我天真地在原生 JDBC 批量插入后调用getGeneratedKeys()去回填自增主键结果发现拿回来的主键集合顺序和插入顺序对不上后续的业务逻辑按对象里的 ID 去做关联更新数据全部错位。这个问题的根因是多行 insert 由 MySQL 驱动重写后返回的 generated key 列表和参数列表中记录的对应关系并不一定是一一对应的。即便 MyBatis 的 XML 里写了useGeneratedKeystrue keyPropertyid在多行 insert 场景下也依赖驱动实现踩过几次之后我就不再赌这个行为了。最终方案是彻底放弃数据库自增主键回填在应用层先给每条记录生成分布式 ID插入时把 ID 字段直接写进 SQL。这样既不需要回填也不怕批量重写时主键返回异常。对于数据迁移场景还有一个额外好处同样的业务主键在重试时天然幂等。4.3 坑三batch执行中途报错数据只进去一半第三个坑最隐蔽。当时一个导入任务在第 N 批执行时报了字段长度超限的错抛出来的是BatchUpdateException。我最初把整个任务包在一个大事务里一报错就全部回滚损失倒是不大但排查成本很高每次失败都要重跑整批数据非常浪费时间。后来改成按批提交再把每批的失败单独记录日志报错后直接看是哪一批、哪些记录定位就快多了。不过要注意JDBC 在批量执行中遇到错误时本批内先执行的几条可能已经生效具体行为因数据库和驱动版本而异。所以我在设计导入任务时做了一层预校验在真正批量写之前先检查字段长度、非空约束和唯一键冲突把绝大多数错误挡在批量执行之前。剩下极少数运行期异常靠按批事务和业务主键重试来解决。这个过程走下来我对批量插入的理解有了本质变化性能问题往往不是 SQL 写得多花哨而是执行器类型、驱动参数、事务边界这些底层链路没有对齐。5. 工程落地最终选型策略与我的个人经验5.1 按数据量和场景选方案根据实际项目经验我总结了一套比较实用的选型策略供参考。数据量在 1 万以下字段也不复杂直接用 MyBatis-Plus 的saveBatch就能接受但至少要把rewriteBatchedStatementstrue打开不然收益确实有限。1 万到 50 万的数据导入任务优先选 foreach 分批拼 SQL改动少、不依赖驱动重写如果项目里不方便写 XML 就选手动 BatchExecutor。50 万以上我会拆成多线程分批处理每个线程独立跑一个 BatchExecutor 或原生 JDBC 批处理线程数控制在连接池最大值以内每批 500 条。如果业务必须整体原子性比如资金流转这类需求不要简单地按批提交应该保留一个大事务但同时严格控制单次写入量或者考虑先写临时表、校验完成后再切换主表的方案。这个方案实现复杂度高但确实能兼顾大吞吐和原子性。5.2 最小配置清单和操作要点把这些经验浓缩成一份清单方便直接对着做。MySQL 连接 URL 必须加rewriteBatchedStatementstrue这是走 JDBC batch 方案的前提条件。批量 SQL 优先写成 foreach 多 values或者走ExecutorType.BATCH千万不要在循环里调单条 insert。批大小默认 500单行字段较大时按预估 SQL 大小调整。事务边界根据“可重试性”决定可断点续传则按批提交必须原子则控制总量保留大事务。批量任务使用独立数据源或独立连接池限制最大连接数和主业务线程隔离。主键尽量在应用层生成并显式写入不要依赖批量场景下的自增主键回填。5.3 最后再多说两句实操心得我现在接新项目时看到批量插入需求会先确认两个问题数据量级是多少失败时能不能重试。这两个问题决定了整个方案走向。自己实现批量插入并不是为了绕开框架而是为了把 SQL 合并、事务粒度、连接管理这些真正影响性能的点握在自己手里。如果上面这些配置你都调完了还是觉得慢我建议直接在 MySQL 的通用日志里看实际到达服务端的 SQL 语句。确认驱动有没有把多条 insert 重写成多 values是排查批量插入性能问题最有效的一招。很多参数争论看一眼真实 SQL 就全明白了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑