MyBatis批量插入优化:从foreach踩坑到BATCH实践

发布时间:2026/10/15 5:44:25
MyBatis批量插入优化:从foreach踩坑到BATCH实践
MyBatis 批量插入大概是很多后端同学最常用的一个“偷懒”操作依赖一个foreach在 VALUES 后面拼一长串括号代码看着简洁逻辑也不复杂数据量小的时候确实也能跑。但如果你把它当成万能方案数据量稍微上来一点线上就很容易炸。我见过某后台系统的导入接口往一张表一次性插 3 万条开发环境跑得欢一上生产稳定报错SQL 太长、数据包超限。排查到最后问题跟数据量其实没关系就出在这个“看起来没毛病”的 foreach 拼接方式上。这篇文章想聊的就是 MyBatis 批量插入到底该怎么写。我会把 foreach 拼接为什么会有隐患、有哪些替代方案、每个方案怎么落地、以及我在实际项目里踩过的坑一次性讲清楚。无论你是刚接触 MyBatis 不久的新手还是已经写了多年 CRUD 的老手这套对比思路应该都能用得上。1. 为什么说 foreach 批量插入是“滥用”1.1 先看一个最常见的写法大部分项目里的批量插入长这样insert idinsertBatch parameterTypelist INSERT INTO user_info (name, age, phone, dept_id) VALUES foreach collectionlist itemitem separator, (#{item.name}, #{item.age}, #{item.phone}, #{item.deptId}) /foreach /insert对应的 Mapper 接口大概是int insertBatch(Param(list) ListUserInfo list);这条 SQL 最终会变成INSERT INTO user_info (name, age, phone, dept_id) VALUES (?,?,?,?),(?,?,?,?),(?,?,?,?),...也就是说你传给方法的列表有多大VALUES 后面的括号就有多少组。这个写法本身没有错小数据量下也足够简单直接。但问题在于很多人写完之后就把“批量插入 foreach 拼接”这个等式记住了从来没有想过当列表从 10 条变成 1 万条的时候这条 SQL 已经膨胀成了什么样子。拿生活打个比方去超市采购一周的日用品装一个购物车没问题。但如果你想把一年的东西一次性买齐还没等结账购物车已经先散架了。foreach 批量插入就是这种逻辑——它把“批量”两个字从“分多次减少往返”理解成了“一次全塞进去”。1.2 首要隐患一条 SQL 的长度会迅速失控很多人对 SQL 长度没概念觉得数据库能处理很大的包。我们按最简单的模型算一下假设一行 INSERT 元组是(?,?,?,?)加上字段名、括号、逗号、换行大概要 80120 字节。1 万条数据拼出来的 SQL 大约在 0.8MB1.2MB 之间5 万条就是 4MB6MB。这个长度到底能不能跑取决于你连接的那台数据库以及中间层。MySQL 8.0 环境里max_allowed_packet默认是 64MB看起来很宽松对吧但生产环境里很多 DBA 会把参数收敛到 16MB 甚至更小数据库中间件、网关、代理层又会在此基础上再收紧一道。不少团队用的中间件对单条 SQL 的限制是 1MB4MB超过直接拒绝。也就是说1 万条数据的 foreach 拼接在某些环境下就已经站在了悬崖边上。除了长度还有一个容易忽略的是参数占位符数量。假设一条记录 4 个字段2 万条记录就有 8 万个?。MySQL 客户端和服务端对占位符数量、预编译语句资源都有自己的上限和开销虽然不同版本表现不一样但很多实践里到几万参数之后会出现各种莫名其妙的报错。我们曾经排查过一个案例报错信息非常隐晦定位到最后就是参数数量把服务端预编译资源打满了。1.3 除了长还要注意几笔隐性开销SQL 太长只是最直观的风险其实背后还有三笔隐性开销平时不容易察觉等出问题的时候已经是线上事故了。第一笔是网络传输。一条 1MB 的 SQL 包在 TCP 层会经历反复的分段、重传在慢速网络或者跨机房调用时延迟会被成倍放大。第二笔是数据库端的解析开销。一条超长 SQL 会让优化器花更多时间构建语法树、做表分析和权限校验而且这种超长 SQL 几乎不可能命中预编译语句缓存每次执行都是从零开始编译。第三笔是业务层的回滚代价。foreach 拼接是把所有数据放进同一条 SQL一旦中间任何一条记录违反约束或者触发异常整个语句全部失败日志里只能看到一条超长 SQL你根本定位不到具体是哪一行数据出的问题。主键回填也是一个很现实的痛点。useGeneratedKeys在单条多 VALUES 场景下MyBatis 很难把生成的自增主键回填到列表里的每个对象上。你明明执行了 insert回头查对象的id字段大概率是一排 null。所以这里的“滥用”不是指 foreach 不能用而是指很多人在不清楚风险和边界的情况下把它当成了批量插入的唯一解。数据量小的时候没事数据量一上来连环翻车。2. 批量插入的几种主流方案从简单到彻底2.1 方案一foreach 手动分片小批量场景的兜底如果想继续用 foreach但又不希望一条 SQL 无限膨胀最常见的补救办法是手动分片。代码挺简单public T ListListT partition(ListT list, int size) { if (list null || list.isEmpty()) { return Collections.emptyList(); } int total list.size(); int pageCount (total size - 1) / size; ListListT result new ArrayList(pageCount); for (int i 0; i pageCount; i) { int fromIndex i * size; int toIndex Math.min(fromIndex size, total); result.add(list.subList(fromIndex, toIndex)); } return result; }Service 里就变成ListListUserInfo pages partition(userList, 500); for (ListUserInfo page : pages) { userMapper.insertBatch(page); }这个方案的核心思想是把“一次性塞进一条 SQL”变成“多批、每批一条中等长度的 SQL”。每批 500 条的时候单次 SQL 大小通常只有几十 KB不会触碰中间件和数据库的限制而且每批失败时可以单独感知到。但这个方案也谈不上高效因为每一批 foreach 仍然会生成一条较大的 SQL并且每一批都是一次独立的网络往返。数据量到几万条时即便每批 500 条也需要几十次往返。网络延迟高一点整体耗时依然难看。我把它定位成“没有更好环境时的兜底”适合数据量几千以内、不想引入新工具的团队。提示subList返回的是原列表的视图不是深拷贝。如果中间有线程并发修改原列表会抛出ConcurrentModificationException。分片后建议立即执行不要长期持有。2.2 方案二ExecutorType.BATCH走真正的 JDBC 批处理MyBatis 本身提供了批处理执行器只是很多项目从来没主动用过。获取方式很简单SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH);区别在于默认的SIMPLE执行器每调用一次mapper.insert()就会立刻执行一条 SQL而BATCH执行器会把多次调用攒起来等sqlSession.flushStatements()的时候才真正通过 JDBC 的executeBatch()一次性发给数据库。这里容易误解的是BATCH模式不是让你把 SQL 写得更复杂而是让你用单条 INSERT 语句反复执行insert idinsert parameterTypeUserInfo useGeneratedKeysfalse INSERT INTO user_info (name, age, phone, dept_id) VALUES (#{name}, #{age}, #{phone}, #{deptId}) /insert然后在业务代码里循环调用try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { UserInfoMapper mapper sqlSession.getMapper(UserInfoMapper.class); for (int i 0; i userList.size(); i) { mapper.insert(userList.get(i)); if ((i 1) % 500 0 || i userList.size() - 1) { sqlSession.flushStatements(); } } sqlSession.commit(); } catch (Exception e) { // 根据实际业务决定是否需要回滚 throw e; }为什么这样更快道理很简单JDBC 批处理把网络往返从“每行一次”压缩成了“每 500 行一次”同时数据库端可以复用同一个预编译语句减少反复编译的损耗。相比 foreach 拼接SQL 总长度没有爆炸式增长每批的语句结构完全一致。我建议封装一个工具类避免每次都在业务代码里处理这种 try-with-resources 细节。下面这个工具类是我项目里常用的形态public final class MybatisBatchUtils { private final SqlSessionFactory sqlSessionFactory; public MybatisBatchUtils(SqlSessionFactory sqlSessionFactory) { this.sqlSessionFactory sqlSessionFactory; } public M, T void batchExecute(ClassM mapperClass, ListT dataList, int batchSize, BiConsumerM, T doInsert) { if (dataList null || dataList.isEmpty()) { return; } try (SqlSession sqlSession sqlSessionFactory.openSession(ExecutorType.BATCH)) { M mapper sqlSession.getMapper(mapperClass); int size dataList.size(); for (int i 0; i size; i) { doInsert.accept(mapper, dataList.get(i)); if ((i 1) % batchSize 0 || i size - 1) { sqlSession.flushStatements(); } } sqlSession.commit(); } } }调用的时候MybatisBatchUtils utils new MybatisBatchUtils(sqlSessionFactory); utils.batchExecute(UserInfoMapper.class, userList, 500, (mapper, user) - mapper.insert(user));这个工具类有几个地方是踩过坑之后才加上的batchSize不能设得太小否则批处理等于没批flush 一定要放在循环内按条件触发而不是每行都触发最后一行也要判断一次否则剩余不足一批的数据会一直待在内存里等close()时可能被默默处理掉时机不可控。2.3 方案三rewriteBatchedStatementstrue给批处理加速的隐藏开关ExecutorType.BATCH已经不错了但如果你用的是 MySQL还有一个很容易被忽略的参数能让它更快rewriteBatchedStatementstrue。这个参数要加在 JDBC 连接串上jdbc:mysql://192.168.1.10:3306/mydb?rewriteBatchedStatementstrueuseSSLfalseserverTimezoneAsia/Shanghai它的原理是MySQL JDBC 驱动默认处理executeBatch()时仍然会把批里的每条 INSERT 单独发送给数据库。虽然减少了应用层到驱动的解析损耗但网络往返并没有减少多少。开启rewriteBatchedStatementstrue之后驱动会在客户端把一批单行 INSERT 自动改写成一条多 VALUES 的大 INSERT再一次发送给数据库。看到这里你可能有个疑问这不又回到 foreach 拼接的路子上去了吗区别在于foreach 模式是在应用层手动拼接 SQLSQL 里直接写死了一大串参数而rewriteBatchedStatements是驱动层在批处理框架内自动改写它发生在 JDBC 标准接口内部开发者写的是干净的单条 INSERT 语句数据库收到后是标准的多 VALUES 语句。配合ExecutorType.BATCH使用既保留了代码的简洁又拿到了多 VALUES 的网络效率。驱动版本要注意。MySQL Connector/J 5.1.13 之前没有这个参数5.1.13 之后才逐步引入。8.0 的驱动仍然支持但大部分连接池配置默认并没有打开。新项目我建议直接把这个参数加上老项目需要确认驱动版本后再说。注意这个参数只对 INSERT、UPDATE、DELETE 的批处理有意义对 SELECT 无效。如果你的 SQL 已经是多 VALUES 形式比如 foreach 拼接的批量插入驱动不会再做额外改写。另外如果批处理语句里夹杂了不同结构的 SQL驱动会退回逐条发送所以尽量让同一批的 SQL 保持一致。2.4 三个方案应该怎么选一张表讲明白数据量推荐方案原因1000 以内foreach 直接拼接或者单条循环SQL 不会太长简单直接改造成本不划算1000 5000foreach 手动分片单批控制长度代码改动小风险可控5000 以上ExecutorType.BATCH rewriteBatchedStatements网络往返少SQL 不长数据库解析开销低强依赖主键回填单条 insert 循环或插入后统一查询回填多 VALUES 和 BATCH 下 MyBatis 回填主键不可靠这个表格只是经验值实际项目和项目之间差异很大。判断标准不是“第几条”而是单条 SQL 是否逼近了中间件限制网络往返是否成了瓶颈是否需要精确拿到自增主键回答完这三个问题选哪个方案基本就清楚了。3. 实操实录把一个导入接口从 foreach 改成 BATCH3.1 改造前的代码长什么样我之前接手过一个后台系统的员工导入接口Excel 里动辄一到两万条数据原实现就是典型的 foreach 一次性拼接。接口核心代码大概是Transactional(rollbackFor Exception.class) public int importUsers(ListUserInfo userList) { return userMapper.insertBatch(userList); }XML 里就是本章开头那个foreach写法。开发环境数据少测试也只用两三百条根本暴露不出问题。上线后每次导入超过五千条日志里就开始出现PacketTooBigException之类的报错偶尔还有几个莫名其妙的 timeout。一开始我以为是数据库参数太大意了查了一圈发现max_allowed_packet配置没问题卡点其实在数据库中间件那一层——单条 SQL 超过 2MB 直接拒绝。而一万条数据的 foreach SQL 早就超出这个数值了。3.2 改造后的核心代码改造思路很明确放弃“一条 SQL 装完所有数据”的想法改成ExecutorType.BATCH逐条插入、分批 flush。Mapper 接口保持一个最简单的单条插入方法int insert(UserInfo user);XML 不需要 foreachinsert idinsert parameterTypeUserInfo INSERT INTO user_info (name, age, phone, dept_id) VALUES (#{name}, #{age}, #{phone}, #{deptId}) /insertService 里不再直接调用insertBatch而是通过前面那个MybatisBatchUtils工具类执行public int importUsers(ListUserInfo userList) { if (userList null || userList.isEmpty()) { return 0; } int before userList.size(); mybatisBatchUtils.batchExecute(UserInfoMapper.class, userList, 500, (mapper, user) - mapper.insert(user)); return before; }这里我故意没有加Transactional因为如果用 Spring 事务管理器包住一个手动打开的 BATCH SqlSession很容易出现事务边界混乱。具体坑我在下一节会展开。目前这个工具类自己控制commit()和close()事务边界简单明了适合大部分导入场景。改造前后的差异总结下来其实是三点Mapper 的 XML 从复杂拼接变成了单条插入业务层不再直接感知批量细节批量大小和 flush 时机被集中到工具类里统一管理。3.3 一次对比压测的真实记录改造完成后我在自己的模拟环境里做了一次横向对比压测。说明一下这不是生产环境的精确压测只是为了验证量级差异数据仅供参考。数据量foreach 一次拼foreach 分片 500BATCH rewriteBatchedStatements1 万条约 1300msSQL 大小约 1MB有超限风险约 420ms约 180ms5 万条直接报错SQL 超过中间件限制约 1900ms约 620ms10 万条失败约 3900ms约 1280ms三组数据看完就很直观foreach 一次拼在 1 万条时就已经很勉强5 万条直接不可用foreach 分片能勉强支撑但耗时明显随数据量线性增长BATCH 方案在数据量变大后优势越来越明显。如果连接串上没加rewriteBatchedStatementstrueBATCH 的耗时大概还会往上涨 30% 到 50%加了这个参数之后才能看到上面这个水平。还有一点值得注意BATCH 方案的耗时不是单纯由“SQL 生成”和“网络”决定的。因为它会把同一条 INSERT 语句反复预编译数据库端可以很好地复用执行计划CPU 占用也稳定。而 foreach 分片每次都要生成一段新的 SQL预编译缓存基本帮不上忙。4. 批量插入路上的常见坑每一个都踩出过事故4.1 主键回填拿不到id 全是 null很多业务在插入之后要立刻使用主键比如维护部门层级关系、写入关联表。使用 foreach 多 VALUES 方式或者 BATCH 模式时MyBatis 的useGeneratedKeys基本上帮不上忙插入之后对象里的 id 还是 null。如果强依赖主键有几个现实做法。第一个是不走批处理用单条 insert 循环每条都靠数据库生成主键并回填第二个是插入完成后统一查一次第三个是在应用层先生成分布式 ID作为主键写进去彻底绕开数据库自增回填。每种都有代价没有银弹但至少你要提前知道“批量插入回填主键不可靠”这个事实不要在线上眼睁睁看着 null。4.2 BATCH 模式的 Session 和 Spring 事务纠缠不清这是新手最容易翻车的地方。你在 Service 方法上加了Transactional然后在方法内部又调sqlSessionFactory.openSession(ExecutorType.BATCH)自己 commit、close。看起来好像都在同一个事务里实际上开发者手工打开的这条 SqlSession 跟 Spring 管理的事务连接并不一定共享事务隔离的语义很可能被打破。我建议在引入 BATCH 工具类的时候明确约定批量导入这类场景不要叠加 Spring 声明式事务。如果业务上确实需要“多表同时成功或失败”有一个相对稳妥的办法是在同一个事务模板里控制批量导入和后续操作但仍然要把工具类里的 commit 逻辑去掉改由外部事务统一提交。这个方案细节不少最简单的落地建议是先让批量导入独立提交再做后续业务操作并接受“导入完成但后续失败”的概率用对账任务兜底。4.3 日志拦截器和插件被 BATCH 模式干扰很多团队会给 MyBatis 加自定义拦截器用来打印慢 SQL 或者统计执行时间。在默认 SIMPLE 执行器下拦截器能很好地拿到 MappedStatement 和参数切到 BATCH 执行器之后Executor.update()被调用的时机不等同于真正执行 SQL 的时机。你会在日志里看到同一批数据的一条语句被“反复执行”很多次但实际上只是被加入了批处理队列真正的执行发生在flushStatements()。更麻烦的是一些参数搜集插件在 BATCH 模式下会拿到空参数或者不完整的参数列表严重情况下直接抛类型转换异常。如果项目里已经接了这类拦截器切到 BATCH 之前先测试一下日志输出。我的处理方式是在拦截器里判断执行器类型如果是 BatchExecutor就不做逐条参数打印只统计批量执行的总耗时和总条数。4.4 批量大小的选择与分片边界批量大小不是一个拍脑袋的数字。设得太小比如 50 条一批网络往返优化效果有限性能跟普通循环差不多设得太大比如 5000 条一批又会重新逼近 SQL 长度和客户端内存上限。基于我用 MySQL 的经验5001000 是一个相对稳妥的档位既能让预编译语句充分复用又不至于把客户端内存堆得太高。分片边界问题是 foreach 拆批方案里最常见的 bug。新手用subList分片时经常在循环里动态修改原列表或者在分片后继续往原列表里 add 数据结果ConcurrentModificationException直接教做人。另一个边界问题是最后一组不足批次大小如果循环只按i % batchSize 0触发 flush最后那几条数据可能一直攒在批处理队列里。我在工具类代码里加了i size - 1的判断就是为了处理这个边界。4.5 批量插入问题速查表症状最常见原因处理方式报错 PacketTooBigException单条 SQL 超过 max_allowed_packet 或中间件限制改 BATCH或缩小 foreach 分片插入后对象的 id 全是 null多 VALUES 或 BATCH 下主键回填不可靠单条插入回填或应用层生成 ID并行调用时连接池被占满手工 openSession 未 close用 try-with-resources或统一工具类管理批量插入性能提升不明显flush 太频繁检查批量大小不要每条都 flush日志打印出大量重复 SQL拦截器不理解 BatchExecutor拦截器增加执行器类型判断数据一部分成功一部分失败批处理执行中途报错没有清晰回滚边界用事务控制或对账任务兜底5. 我的最终建议5.1 动手写之前先回答三个问题第一个问题这条批量插入最多会有多少条数据几百条和几万条的处理方式完全不一样。第二个问题插入之后立刻需要主键吗如果需要批量回填基本别指望。第三个问题这个操作是不是必须跟其他数据库写操作在同一个事务里是的话BATCH 方案要谨慎设计。这三个问题问完方案基本已经定了。如果是简单的数据导入我的默认选择是ExecutorType.BATCHrewriteBatchedStatementstrue。如果只是临时脚本一次性灌数据用foreach 分片也能跑跑完即弃不追求极致性能。5.2 守住不能让单条 SQL 无限膨胀这条底线最后想分享一个非常朴素的判断标准再重要的批量插入也不要让单条 SQL 的长度在心里没底。数据量在涨SQL 就在涨中间件限制不会等你。我现在做新项目连接串上会习惯性把rewriteBatchedStatementstrue加上批量导入走工具类统一处理foreach 分片只留作备选。这套思路我用了很久很少再被批量插入的性能问题半夜叫醒。如果你已经栽过跟头不妨也按这个路径梳理一遍自己的代码说不定问题就藏在那个用了很久的 foreach 里。