MyBatis 批量插入大概是很多后端同学最常用的一个“偷懒”操作:依赖一个<foreach>,在 VALUES 后面拼一长串括号,代码看着简洁,逻辑也不复杂,数据量小的时候确实也能跑。但如果你把它当成万能方案,数据量稍微上来一点,线上就很容易炸。我见过某后台系统的导入接口,往一张表一次性插 3 万条,开发环境跑得欢,一上生产稳定报错:SQL 太长、数据包超限。排查到最后,问题跟数据量其实没关系,就出在这个“看起来没毛病”的 foreach 拼接方式上。
这篇文章想聊的就是 MyBatis 批量插入到底该怎么写。我会把 foreach 拼接为什么会有隐患、有哪些替代方案、每个方案怎么落地、以及我在实际项目里踩过的坑,一次性讲清楚。无论你是刚接触 MyBatis 不久的新手,还是已经写了多年 CRUD 的老手,这套对比思路应该都能用得上。
1. 为什么说 foreach 批量插入是“滥用”
1.1 先看一个最常见的写法
大部分项目里的批量插入长这样:
<insert id="insertBatch" parameterType="list"> INSERT INTO user_info (name, age, phone, dept_id) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.age}, #{item.phone}, #{item.deptId}) </foreach> </insert>对应的 Mapper 接口大概是:
int insertBatch(@Param("list") List<UserInfo> list);这条 SQL 最终会变成:
INSERT INTO user_info (name, age, phone, dept_id) VALUES (?,?,?,?),(?,?,?,?),(?,?,?,?),...也就是说,你传给方法的列表有多大,VALUES 后面的括号就有多少组。这个写法本身没有错,小数据量下也足够简单直接。但问题在于,很多人写完之后就把“批量插入 = foreach 拼接”这个等式记住了,从来没有想过,当列表从 10 条变成 1 万条的时候,这条 SQL 已经膨胀成了什么样子。
拿生活打个比方:去超市采购,一周的日用品装一个购物车没问题。但如果你想把一年的东西一次性买齐,还没等结账,购物车已经先散架了。foreach 批量插入就是这种逻辑——它把“批量”两个字,从“分多次减少往返”理解成了“一次全塞进去”。
1.2 首要隐患:一条 SQL 的长度会迅速失控
很多人对 SQL 长度没概念,觉得数据库能处理很大的包。我们按最简单的模型算一下:假设一行 INSERT 元组是(?,?,?,?),加上字段名、括号、逗号、换行,大概要 80~120 字节。1 万条数据,拼出来的 SQL 大约在 0.8MB~1.2MB 之间;5 万条就是 4MB~6MB。
这个长度到底能不能跑,取决于你连接的那台数据库以及中间层。MySQL 8.0 环境里max_allowed_packet默认是 64MB,看起来很宽松对吧?但生产环境里很多 DBA 会把参数收敛到 16MB 甚至更小,数据库中间件、网关、代理层又会在此基础上再收紧一道。不少团队用的中间件对单条 SQL 的限制是 1MB~4MB,超过直接拒绝。也就是说,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> List<List<T>> partition(List<T> list, int size) { if (list == null || list.isEmpty()) { return Collections.emptyList(); } int total = list.size(); int pageCount = (total + size - 1) / size; List<List<T>> 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 里就变成:
List<List<UserInfo>> pages = partition(userList, 500); for (List<UserInfo> 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 id="insert" parameterType="UserInfo" useGeneratedKeys="false"> 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(Class<M> mapperClass, List<T> dataList, int batchSize, BiConsumer<M, 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 方案三:rewriteBatchedStatements=true,给批处理加速的隐藏开关
ExecutorType.BATCH已经不错了,但如果你用的是 MySQL,还有一个很容易被忽略的参数能让它更快:rewriteBatchedStatements=true。
这个参数要加在 JDBC 连接串上:
jdbc:mysql://192.168.1.10:3306/mydb?rewriteBatchedStatements=true&useSSL=false&serverTimezone=Asia/Shanghai它的原理是:MySQL JDBC 驱动默认处理executeBatch()时,仍然会把批里的每条 INSERT 单独发送给数据库。虽然减少了应用层到驱动的解析损耗,但网络往返并没有减少多少。开启rewriteBatchedStatements=true之后,驱动会在客户端把一批单行 INSERT 自动改写成一条多 VALUES 的大 INSERT,再一次发送给数据库。
看到这里你可能有个疑问:这不又回到 foreach 拼接的路子上去了吗?区别在于,foreach 模式是在应用层手动拼接 SQL,SQL 里直接写死了一大串参数;而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 ~ 5000 | foreach + 手动分片 | 单批控制长度,代码改动小,风险可控 |
| 5000 以上 | ExecutorType.BATCH + rewriteBatchedStatements | 网络往返少,SQL 不长,数据库解析开销低 |
| 强依赖主键回填 | 单条 insert 循环,或插入后统一查询回填 | 多 VALUES 和 BATCH 下 MyBatis 回填主键不可靠 |
这个表格只是经验值,实际项目和项目之间差异很大。判断标准不是“第几条”,而是:单条 SQL 是否逼近了中间件限制?网络往返是否成了瓶颈?是否需要精确拿到自增主键?回答完这三个问题,选哪个方案基本就清楚了。
3. 实操实录:把一个导入接口从 foreach 改成 BATCH
3.1 改造前的代码长什么样
我之前接手过一个后台系统的员工导入接口,Excel 里动辄一到两万条数据,原实现就是典型的 foreach 一次性拼接。接口核心代码大概是:
@Transactional(rollbackFor = Exception.class) public int importUsers(List<UserInfo> 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 不需要 foreach:
<insert id="insert" parameterType="UserInfo"> INSERT INTO user_info (name, age, phone, dept_id) VALUES (#{name}, #{age}, #{phone}, #{deptId}) </insert>Service 里不再直接调用insertBatch,而是通过前面那个MybatisBatchUtils工具类执行:
public int importUsers(List<UserInfo> 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 + 分片 500 | BATCH + rewriteBatchedStatements |
|---|---|---|---|
| 1 万条 | 约 1300ms,SQL 大小约 1MB,有超限风险 | 约 420ms | 约 180ms |
| 5 万条 | 直接报错,SQL 超过中间件限制 | 约 1900ms | 约 620ms |
| 10 万条 | 失败 | 约 3900ms | 约 1280ms |
三组数据看完就很直观:foreach 一次拼在 1 万条时就已经很勉强,5 万条直接不可用;foreach 分片能勉强支撑,但耗时明显随数据量线性增长;BATCH 方案在数据量变大后优势越来越明显。如果连接串上没加rewriteBatchedStatements=true,BATCH 的耗时大概还会往上涨 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 的经验,500~1000 是一个相对稳妥的档位,既能让预编译语句充分复用,又不至于把客户端内存堆得太高。
分片边界问题是 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.BATCH+rewriteBatchedStatements=true。如果只是临时脚本一次性灌数据,用foreach + 分片也能跑,跑完即弃,不追求极致性能。
5.2 守住不能让单条 SQL 无限膨胀这条底线
最后想分享一个非常朴素的判断标准:再重要的批量插入,也不要让单条 SQL 的长度在心里没底。数据量在涨,SQL 就在涨,中间件限制不会等你。
我现在做新项目,连接串上会习惯性把rewriteBatchedStatements=true加上,批量导入走工具类统一处理,foreach 分片只留作备选。这套思路我用了很久,很少再被批量插入的性能问题半夜叫醒。如果你已经栽过跟头,不妨也按这个路径梳理一遍自己的代码,说不定问题就藏在那个用了很久的 foreach 里。