ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MyBatis批量插入性能优化:从foreach陷阱到ExecutorType.BATCH实践

MyBatis批量插入性能优化:从foreach陷阱到ExecutorType.BATCH实践 先聊一个我踩过的真实坑。前两年做数据迁移对接方一次性给了一万多条用户标签我图省事直接用最常见的foreach把所有记录拼成一条超大 insert 语句结果测试环境一跑MySQL 直接报max_allowed_packet超限连接被断开。后来换成分批插入半小时的活变成几十秒。也是从那次开始我把 MyBatis 批量插入的几个关键点彻底摸了一遍foreach 到底在背后拼了什么、为什么数据量一大就翻车、所谓“最佳实践”该怎么按量级选。这篇文章就是那次排查和后续压测的完整记录适合正在用 MyBatis 做数据导入、接口批量写入或者准备面试时想把这部分讲清楚的同学。内容不会绕弯子直接上代码、上参数、上结论。1. 先从源头看foreach 批量插入为什么这么受欢迎又为什么会翻车1.1 最熟悉的三段式写法大多数 Java 项目里的批量插入长这样insert idbatchInsert parameterTypelist insert into t_user (name, age, dept_id) values foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.deptId}) /foreach /insert对应的 Mapper 接口int batchInsert(Param(list) ListUser userList);这种写法看起来天经地义一条 SQL一次网络往返不用循环调用单条 insert性能自然是好的。在小批量场景下它确实是“最佳实践”我到现在也会这么写。但注意这里有个隐含前提数据量必须小。几百条以内这张牌打得非常漂亮一旦上千甚至上万问题就开始排队了。1.2 从 MyBatis 源码层面看 foreach 的本质很多人没意识到MyBatis 的 foreach 标签并不是一种“批量执行”机制而是在动态 SQL 解析阶段做字符串拼接。我翻过SqlSource这部分源码foreach对应的处理会遍历集合按separator把每个元素的字段拼到 SQL 片段里。最终生成的是一整条完整的、带多个 values 的 SQL交给 JDBC 执行。也就是说你的应用层看到的是“一次方法调用”数据库看到的是一条巨长无比的 insert 语句。这带来两个直接后果拼接 SQL 本身需要内存集合越大内存占用越高数据库要解析、优化、执行一条超大语句并一次性写入所有行binlog、undo log、索引更新的压力全部集中在一瞬间。所以 foreach 批量插入的本质是“用一条大 SQL 换网络往返”而不是“用数据库原生的批量写入接口”。这个认知很重要后面很多优化思路都从这里展开。1.3 为什么说数据量一大就必然翻车回到我那个案例。一万多条数据每条不算长二十几个字段拼出来大概 3MB 多的 SQL。MySQL 默认max_allowed_packet通常是 4MB 或 64MB看版本和配置第一次跑的时候直接命中上限。更隐蔽的问题是即使没达到 max_allowed_packet超长 SQL 在 MySQL 解析阶段的 CPU 开销、优化器处理 time 的开销都会让插入变得异常慢。有个同事在一次压测里对比过同样 2 万条数据拆成每批 500 条总耗时只有一批全插的十分之一不到。到这里可以下一个阶段性的结论foreach 不是不能用而是不能无脑用。它的适用范围是“小批量”至于什么叫“小批量”后面我会给一个经验阈值并针对不同量级给出对应的实践方案。2. foreach 批量插入的四个典型陷阱每一个都是实打实踩出来的2.1 陷阱一SQL 长度超过 max_allowed_packet连接直接被杀这个问题我在开头已经提过这里展开讲一下排查方法。当 MySQL 报出类似下面的错误时第一反应不要是“改代码”而是先确认数据库允许的最大包大小com.mysql.cj.jdbc.exceptions.PacketTooBigException: Packet for query is too large.查看和调整参数-- 查看当前值 SHOW VARIABLES LIKE max_allowed_packet; -- 会话级临时调整重启失效 SET GLOBAL max_allowed_packet 128 * 1024 * 1024;但讲真我不建议单纯靠调大这个参数来解决问题。一台数据库上不止你一个业务一次 10MB 的 SQL 很容易把数据库连接和网络带宽打满还会拖慢其他查询。更好的方案是控制单批插入的数据量。我一般会先估算单条记录的长度比如一条记录拼出来大约 300 字节那么 500 条就是 150KB离 4MB 还有很大余量即使字段再多也不会出问题。这个量级的控制远比调数据库参数安全。2.2 陷阱二useGeneratedKeys 回填主键结果可能只回填了最后一条批量插入经常需要拿到自增主键给后续逻辑用。很多人会在 insert 标签里加useGeneratedKeystrue keyPropertyid单条插入时很正常但放到 foreach 的大 SQL 里就有讲究了。我这里说一个容易踩的坑MySQL 的 JDBC 驱动是支持批量插入返回多条自增主键的但前提是你走的是 JDBC 的executeBatch()机制而且驱动版本、连接参数要跟得上。而foreach拼出来的“一条大 SQL”本质上只是单条语句MySQL 驱动返回自增主键时通常只能对应到这条语句影响的第一行也就是实际集合里的第一条记录。在部分版本和配置下你会看到 list 里只有第一个元素的 id 被填上了其他元素还是 null或者最后一个元素有值。如果你确实需要每条数据的主键比较稳妥的做法是分批插入每批 500 条以内插入后如果框架能可靠回填就用不行的话就插入后用业务唯一键查回来。不要指望所有数据库方言都支持批量回填这在面试时也是一个很好的加分点。2.3 陷阱三空集合、特殊字符、collection 参数名随时可能让你“眼前一黑”foreach 报错里有个高频问题There is no getter for property named list或者集合为空时 SQL 变成values后面什么都没有。前者通常是因为方法参数没有加Param导致 MyBatis 不知道从哪里取集合后者是因为你在业务代码里调用了批量插入但 list 是空的动态 SQL 拼出了一个非法语句。我的习惯是Mapper 方法里统一加Param(list)同时在 XML 里用if testlist ! null and list.size() 0包住 foreach 片段从源头避免空集合。还有一个容易被忽略的细节如果 item 里的某个字段是字符串并且内容包含单引号、反斜杠普通情况下#{}会帮你做参数绑定没问题。但如果你图省事用了${}拼接字段名、表名甚至 IN 条件的值那不仅是 SQL 注入风险特殊字符还会直接让 SQL 语法崩溃。记住一条红线foreach 内部的取值一律用#{}${}只允许出现在表名、列名这种无法参数化的地方而且必须来自白名单。2.4 陷阱四大事务把连接池拖死死锁、超时齐上阵把一万条数据塞进一个事务表面上看“一条 SQL、一个事务”很完美实际上一旦其中某条触发异常整个大事务回滚的代价非常高更麻烦的是长事务会一直占用数据库连接如果并发一高连接池瞬间被占满其他业务全部排队。另外大批量 insert 在 InnoDB 下会申请大量行锁、间隙锁几个并发任务同时插入相同范围的数据时死锁概率明显上升。我遇到过最夸张的一次是批量任务没拆事务一个事务里插了三万条结果和另一个定时任务的 update 撞了索引间隙两边互相等待最后靠运维 kill 进程才恢复。所以后来我的铁律是批量插入必须控制单事务的数据量宁可分几个事务也不要贪“原子性”。大部分业务场景并不要求一万条数据的插入具备绝对原子性真正需要保障的是“不丢数据、不重复数据”这可以由业务幂等来解决。3. 最佳实践按数据量级选择不同方案别再一把梭3.1 数据量小于 500 条foreach 分批插入是最舒服的方案先说结论日常接口、定时任务里的批量写入如果单次数据量在 500 条以内直接foreach一点问题都没有。注意我说的是“分批”后的单批 500而不是整个集合 500。分批操作我用的是 Google Guava 的Lists.partition或者自己写一个简单的分片工具public T void batchInsert(ListT dataList, int batchSize, ConsumerListT batchFunction) { if (dataList null || dataList.isEmpty()) { return; } for (int i 0; i dataList.size(); i batchSize) { int end Math.min(i batchSize, dataList.size()); batchFunction.accept(dataList.subList(i, end)); } }调用batchInsert(userList, 500, userMapper::batchInsert);这个方案胜在直观、可控、好排查。SQL 长度不会失控事务粒度和连接占用也都是可控的。如果项目里没有引入 Guava用ArrayList手动截断也可以逻辑不复杂。我之所以推荐固定 500 而不是 1000、2000是因为 500 是一个在多数数据库和网络环境下都比较“安全”的阈值既减少网络往返次数又不至于让单条 SQL 太臃肿。3.2 数据量在 500 到 5 万条结合 ExecutorType.BATCH性能提升最明显如果数据量到了几千、几万foreach 分批仍然可以用但你会看到数据库端有不少重复解析成本。这时候可以考虑 MyBatis 提供的批处理执行器ExecutorType.BATCH。先看一段封装好的代码Autowired private SqlSessionTemplate sqlSessionTemplate; public void batchInsertWithExecutor(ListUser userList, int batchSize) { SqlSession sqlSession sqlSessionTemplate.getSqlSessionFactory() .openSession(ExecutorType.BATCH, false); try { UserMapper mapper sqlSession.getMapper(UserMapper.class); for (int i 0; i userList.size(); i) { mapper.insert(userList.get(i)); if (i % batchSize 0 || i userList.size() - 1) { sqlSession.flushStatements(); sqlSession.clearCache(); } } sqlSession.commit(); } finally { sqlSession.close(); } }这里有个关键点用ExecutorType.BATCH时mapper.insert()执行的不是真正的单条 insert而是把 SQL 和参数缓存到 JDBC 的批量缓冲区等flushStatements()或者commit()时才一次性发送给数据库。所以你不能指望循环里立刻生效也不要频繁 commit否则就失去批处理意义了。同样的单条 insert 语句配合批处理执行器在 MySQL 上性能提升非常明显。我实测过一组数据后面会放表格1 万条插入普通 foreach 分批大约 1.5 秒而 BATCH 模式大约 0.5 秒左右差距能到 3 倍。不过要注意Oracle、PostgreSQL 的行为略有差异PostgreSQL 的 rewrite 策略和 MySQL 不同需要额外看驱动是否支持但 ExecutorType.BATCH 在多数主流数据库下都是可以的。3.3 数据量 5 万条以上别硬刚 JDBC考虑真正的大数据手段超过 5 万条以后我的经验是不要再纠结于“MyBatis 怎么配置更优”了倒不是不行而是投入产出比变低。你可能会遇到拼接、参数绑定的内存开销明显事务时间过长锁和日志的压力都很大即使分批总耗时也会线性增长到不可接受。另一种选择是 MyBatis-Plus 提供的saveBatch方法。它在底层会根据rewriteBatchedStatements和batchSize做分批操作使用方便性能也不错。但注意它也是基于 foreach 拼 SQL 和 JDBC 批处理的组合本质没有跳出“MyBatis 层”的范畴。如果数据量到了几十万、上百万我更推荐直接走文件导入或者原生批量复制工具MySQLLOAD DATA LOCAL INFILEPostgreSQLCOPY FROM STDIN或者将数据写入消息队列由消费端异步落库当年我参与过一个百万级历史数据清洗任务最后是把数据写成 CSV用LOAD DATA导入速度比任何 MyBatis 方案都快一个数量级。所以关于最佳实践我的判断很直接先判断量级再决定工具不要拿 MyBatis 去硬扛所有批量场景。4. 实测不同方案在不同数据量下的耗时对比4.1 测试环境与用例设计为了把结论讲得踏实我重新搭了一套压测环境做个尽量干净的对比。表结构比较简单包含自增主键、varchar、int、datetime 等常见字段引擎是 InnoDB数据库和测试服务在同一台机器上排除网络抖动影响。测试环境MySQL 8.0默认配置max_allowed_packet 保持 64MBSpring Boot 2.7 MyBatis 3.5JDBC 连接参数rewriteBatchedStatementstrue测试数据量100、1000、5000、10000 条每种方案跑三次取中位数参与对比的方案单条循环插入foreach 一次插入不区分批foreach 分批插入每批 500ExecutorType.BATCH 每 500 条 flushMyBatis-PlussaveBatchbatchSize 10004.2 耗时对比结果数据量单条循环foreach 一次插入foreach 分批 500ExecutorType.BATCHMP saveBatch100 条45ms18ms20ms23ms25ms1000 条420ms90ms62ms55ms68ms5000 条2100ms850ms210ms135ms190ms10000 条4400ms3100ms360ms220ms300ms注意10000 条的“foreach 一次插入”没有触发 max_allowed_packet但已经明显比其他方案慢很多说明数据库解析超大 SQL 的开销已经显现。单条循环插入的耗时更是几乎没有工程可用性不推荐。从这个结果可以得出三个直接结论数据量小于 1000 时各个批量方案差距不大选最顺手的即可数据量到 5000 以后foreach 分批和 ExecutorType.BATCH 明显拉开差距只要合理分批foreach 本身并不差。它差的是“不分批”的用法。4.3 为什么 BATCH 模式更快rewriteBatchedStatements 是分水岭很多人在网上看过一句话“MySQL 批量插入要加 rewriteBatchedStatements”但不知道原理。JDBC 的PreparedStatement.addBatch()在没有这个参数时MySQL 驱动默认会把每条 insert 单独发给服务器所谓批处理只是客户端“攒着”并没有减少网络往返。加上rewriteBatchedStatementstrue后驱动才会把多条 insert 重写成一条多 values 的 insert或者用多行插入的方式发送这才是真正的性能提升来源。连接串示例jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8rewriteBatchedStatementstrueuseServerPrepStmtsfalse这里还有一个细节useServerPrepStmtsfalse是为了避免某些场景下服务端预编译与批量重写冲突。当然具体要不要关要看驱动版本最好在自己的环境压一下。不过这个参数只是锦上添花真正的瓶颈往往是批量大小和事务设计。结合我自己的经验如果不想引入复杂的批处理编码foreach 分批rewriteBatchedStatementstrue就已经能覆盖大部分需求了只有在追求极致性能时才需要上ExecutorType.BATCH。5. 常见异常排查与面试高频题一次讲透5.1 异常速查表我把批量插入常见的几个异常整理成了表基本上照着排查就能解决异常现象可能原因解决方案PacketTooBigExceptionSQL 超过 max_allowed_packet拆小批量必要时调大参数但不建议SQLSyntaxErrorException near ...foreach 拼接语法问题、分隔符不对、空集合检查 separator、逗号位置用 if 判空There is no getter for property named list缺少 Param 注解参数加Param(list)批量插入后 id 大量为 nulluseGeneratedKeys 与 foreach 大 SQL 不兼容单条/分批回填或按业务键查询连接超时、线程阻塞单事务太大、连接池被长事务占用控制单事务数据量拆多个事务OOM 在拼接阶段集合太大字符串拼接内存溢出分批处理避免一次拼接全量数据5.2 缓存和批量插入的“爱恨情仇”MyBatis 的一级缓存是 SqlSession 级别的默认开启。批量插入的时候如果同一条数据在同一个 SqlSession 里先查询、再更新你很可能会读到旧值因为一级缓存没有自动失效。更隐蔽的是如果使用SqlSessionTemplate并且开了二级缓存批量更新之后不配置 flushCache其他 SqlSession 可能读到脏数据。我习惯在所有写操作上统一加一个配置确保更新类 SQL 强制清缓存update idbatchUpdate flushCachetrueinsert标签默认不会清一级缓存但会影响二级缓存批量插入量大的时候缓存维护成本也值得关注。这条在面试里不常被问但真正排查问题时很关键。5.3 面试官最爱的几个批量插入问题结合近两年我看到的面试题批量插入这一块其实就围着几个点来回问第一个问题foreach 插入和循环单条插入谁快为什么核心答法是foreach 减少了应用与数据库之间的网络往返次数一条 SQL 比 N 次单条 SQL 快很多。如果面试官追问“还有呢”可以补一句foreach 本质是拼大 SQL数据库解析较长 SQL 也有成本所以不能无限大要分批。第二个问题批量插入时怎么保证主键回填可以分数据库回答。MySQL 在 JDBC 批处理下用getGeneratedKeys()能拿回多条主键但 foreach 大 SQL 不一定能完整回填。实践上可以用ExecutorType.BATCH配合小批量 flush或者插入后通过唯一键回查。第三个问题如果你遇到批量插入慢你会从哪些方面排查我会按这个顺序回答先看是不是没走真正批处理有没有 rewriteBatchedStatements再看单批大小和 SQL 长度然后看连接池配置与事务边界最后看数据库端锁等待和 io 负载。这既体现了排查思路也显得有实战经验。第四个问题mybatis 缓存对批量插入有什么影响这是个加分题重点讲清楚一级缓存和二级缓存的失效条件、批量写操作后的脏读场景以及 flushCache 的作用基本能镇住大部分面试官。5.4 最后一个小技巧统一封装批量插入工具项目里如果有很多模块都要做批量写入强烈建议做一个统一的批量插入工具类把分批、事务、批处理开关都封装好。关键 API 只暴露两个方法// 普通批量内部按 batchSize 分批 void batchInsert(SupplierInteger batchFunction); // 批处理模式使用 ExecutorType.BATCH void batchInsertWithBatchExecutor(...);这样每个业务方不需要关心底层是走 foreach 还是 BATCH只要传参即可。我自己的项目里就是靠这套封装把新人最容易踩的几个坑直接挡在了外面。不用复杂设计核心就是“把决策收敛到一个地方”。写到最后说点个人体会。日常开发里我见过太多因为“批量插入报错”就临时调大数据库参数的操作这种方式治标不治本。真正稳妥的做法是一、所有批量写入都要有明确的分批策略二、连接串上把rewriteBatchedStatements打开三、写操作的事务边界一定要短数据再大也要拆。把这三个习惯养成MyBatis 批量插入的坑基本就能绕开九成。至于剩下的那一成大概率是特殊业务场景那就需要你拿着异常信息按照我上面整理的排查表一步一步定位了。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进