☰
Mybatis批量插入性能实测:foreach、BATCH与SQL拼接的选型指南
2026/10/12 6:52:02 网站建设 项目流程

大概一年多前,我在负责一个数据初始化模块,要往MySQL里灌300万行左右的业务数据。最开始图省事,直接在Mapper里写了一个foreach拼接的INSERT,结果一跑就报max_allowed_packet相关错误。换成SqlSession的BATCH模式后,情况好了一阵,但数据量再上去又冒出了主键拿不到、日志打印导致内存暴涨这些幺蛾子。

我当时特别想知道,大家天天说的Mybatis批量插入,foreach、SqlSession批量、还有直接拼SQL文本,这三种方式在真实场景下到底差多少、各自适合什么量级。正好最近又做了一次完整测评,把过程和结论整理出来,给同样被批量插入折磨过的朋友一个参考。下文涉及具体秒数的地方,都在同一台4C8G云主机、MySQL 8.0.33、JDK 17环境下实测得到,不同机器会有浮动,但三者的相对差距和结论基本是稳定的。

1. 三种插入方式的技术形态:先搞清楚它们到底在做什么

很多文章把批量插入混为一谈,其实这三种方式从代码形态到底层执行链路差异很大。不先把形态定义清楚,后面讨论效率都是空谈。

1.1 foreach插入的实际SQL形态与连接参数依赖

这是Mybatis里最常见的写法,在Mapper XML里这样写:

<insert id="batchInsert" parameterType="java.util.List"> INSERT INTO t_user (name, phone, register_time) VALUES <foreach collection="list" item="item" separator=","> (#{item.name}, #{item.phone}, #{item.registerTime}) </foreach> </insert>

这段XML最终生成的是一条SQL,形如:

INSERT INTO t_user (name, phone, register_time) VALUES ('a','1',...), ('b','2',...), ...

注意,网上还有一种写法是把多条INSERT语句用分号拼在一起:

<foreach collection="list" item="item" separator=";"> INSERT INTO t_user (name, phone, register_time) VALUES (#{item.name}, ...) </foreach>

这种写法依赖MySQL连接URL中的allowMultiQueries=true参数,因为MySQL服务器默认不允许一次请求携带多条语句。它有明显安全隐患,也会让SQL日志变得很难看,不推荐。本文后面提到的foreach方式,默认指第一种单条多VALUES的写法。

foreach方式最大的特点:应用层不需要改Java代码,和普通单条插入调用同一个Mapper方法,只是传入的List变大了。Mybatis会把这个List解析成一个巨大的参数集合,再拼到SQL里。

1.2 ExecutorType.BATCH到底批量在哪一层

SqlSession批量插入的代码形态是这样的:

SqlSession batchSession = sqlSessionFactory.openSession(ExecutorType.BATCH); try { UserMapper mapper = batchSession.getMapper(UserMapper.class); for (int i = 0; i < totalCount; i++) { User user = buildUser(i); mapper.insert(user); if (i > 0 && i % batchSize == 0) { batchSession.flushStatements(); } } batchSession.commit(); } finally { batchSession.close(); }

这里的循环仍然是逐条调用mapper.insert(user),但关键在于ExecutorType.BATCH。Mybatis拿到这个执行器后,不会把每条INSERT立刻发送给数据库,而是先缓存到JDBC的batch队列里,直到调用flushStatements()或commit()才真正执行。

很多人误以为BATCH模式就是Mybatis帮你把SQL拼成一条大的,其实不是。Mybatis只是把每条INSERT交给JDBC驱动的addBatch(),真正的批量发送是MySQL驱动层做的。而驱动层的表现,又取决于一个容易被忽略的参数:rewriteBatchedStatements。

1.3 这里说的"sql插入"是指什么

标题里的sql插入,在不同文章里指代不同。有人拿它指JDBC原生拼接SQL执行,也有人指Mybatis的<sql>标签。我这里先做个澄清:Mybatis的<sql>标签本质是公共SQL片段复用,比如把一段重复的列名抽出来:

<sql id="userColumns">name, phone, register_time</sql> <insert>INSERT INTO t_user (<include refid="userColumns"/>) VALUES ...</insert>

它和本文讨论的“拼大SQL批量插入”完全是两回事。本文后面提到的sql插入,是指绕开Mybatis的参数映射,直接在应用层用StringBuilder把完整VALUES拼好,再通过JDBC的Statement.execute()整段执行:

StringBuilder sb = new StringBuilder("INSERT INTO t_user (name, phone, register_time) VALUES "); for (int i = 0; i < batchSize; i++) { if (i > 0) { sb.append(","); } sb.append("('").append(escape(buildName(i))).append("','") .append(escape(buildPhone(i))).append("','2024-01-01 00:00:00')"); } statement.execute(sb.toString());

为什么要单独测这种写法?因为有时候Mybatis的XML解析和参数绑定会成为瓶颈,有人会上极端方案:完全绕开Mapper,直接拼SQL推给数据库。这种做法在效率上确实有可取之处,但代价也非常明显,后面会专门讲。

2. 测试准备:数据、环境与那些容易被忽略的配置项

在做效率对比之前,必须先搭一个尽量公平的测试环境。否则改了一个参数,结论就完全反过来,那就没有参考价值了。

2.1 测试环境与数据口径

我用的测试环境如下:

项目配置
操作系统Linux 云主机
配置4C8G
数据库MySQL 8.0.33
JDK17
连接池HikariCP 默认配置
测试表t_user,5个字段,加4个索引

这里有个很关键的决策:表结构不能太简单。很多人测试批量插入用一张只有两个字段的无索引表,测出来每秒几十万行,真实业务中毫无参考价值。我把表建模成接近实际业务的形态,字段包含用户名、手机号、注册时间等,还叠加了4个索引,插入时索引维护的开销无法回避。

数据口径上,每条记录的字段长度保持一致,避免某批数据刚好特别短导致结果失真。每次执行前先TRUNCATE TABLE清空数据,每种方式连跑3次取最好成绩,这样可以尽量排除冷缓存、GC抖动带来的偶然误差。

2.2 MySQL连接URL与驱动参数对结果的影响

测试中所有方式都用同一个连接池,连接URL是:

jdbc:mysql://localhost:3306/test?useUnicode=true&characterEncoding=utf8&rewriteBatchedStatements=true

重点说下rewriteBatchedStatements。这个参数在MySQL Connector/J中默认是false。在false状态下,你通过JDBC调executeBatch(),驱动仍然会把batch里的SQL逐条发给服务器,只是少了应用层逐条调用的网络往返。也就是说,BATCH模式的很大一部分优势,在没有这个参数时根本发挥不出来。

实测中我把这个参数开关各测了一组,差距相当惊人,数据在下一节里给出。另外还有一个参数useServerPrepStmts也值得关注,它决定PreparedStatement在服务端还是客户端做预编译。高并发批量插入场景下,客户端预编译反而更稳,这个属于进阶调优,后文会展开。

2.3 测试脚本设计:防止缓存与并发干扰

测试脚本本身也有几个坑。第一,一定要做预热。JVM有JIT,接口第一次调用通常偏慢,我会先插入5000行作为预热,不计入结果。第二,日志必须关闭。Mybatis的SQL日志如果开着,会把整条大批量SQL文本都打印出来,10万行的VALUES拼起来可能几百MB,日志刷盘时间甚至超过插入本身,严重干扰测试。第三,测试期间数据库不能有其他任务在跑,binlog和复制链路的干扰也要考虑,我直接把复制从库停掉了,只测主库单机写入。

准备工作做完,下面才是重头戏。

3. 实测数据:不同数据量下的耗时差异与直观结论

3.1 10万行场景下的对比结果

先看10万行数据,这是很多业务系统日常导入的常见量级。我拿5种方式做了对比:

插入方式10万行耗时备注
逐条插入(SIMPLE执行器,基线)约25秒网络往返开销巨大
foreach拼接,每批5000行约2.8秒代码最简洁
BATCH模式,rewriteBatchedStatements=false约8秒驱动逐条发送SQL
BATCH模式,rewriteBatchedStatements=true约2秒驱动重写为多VALUES
sql拼接,每批5000行约2.5秒内存占用偏高

这个结果有几个值得注意的点。第一,逐条插入和批量插入之间是两个数量级的差距,15万到25秒对比明显,根本原因不在数据库服务端,而在应用与数据库之间的网络交互次数。第二,BATCH模式不开启rewriteBatchedStatements时,性能连foreach都不如,这解释了很多团队“我用了BATCH但没变快”的困惑。第三,sql拼接在这个量级确实不比foreach差,但提升也很有限,后面会分析它的真实代价。

3.2 50万行与100万行场景:各自开始暴露问题

数据量涨上去之后,几种方式的差距进一步拉开,同时各自的问题也开始现形。我直接看50万和100万两个量级:

插入方式50万行耗时100万行耗时主要问题暴露点
逐条插入约130秒约260秒以上完全不可接受
foreach,每批5000约15秒约32秒批次稍微调大就开始接近SQL长度极限
BATCH,rewrite=true约7秒约14秒一切正常,最稳
BATCH,rewrite=false约38秒约80秒驱动层退化为逐条发送
sql拼接,每批5000约12秒约26秒拼接过程GC压力大,内存峰值高

100万行时,BATCH开启rewrite后大约14秒,接近每秒7万行的写入速度,对于一个带索引的业务表来说已经是很不错的值。foreach虽然也能跑完,但每批5000行意味着要发200个请求,XML解析和参数绑定的开销累积起来就很可观。sql拼接方式在100万行时出现明显的内存抖动,StringBuilder在拼接过程中频繁分配大对象,GC日志里能看到明显的停顿。

3.3 数据背后的第一个结论

测试做下来,第一个直观结论是:BATCH模式 + rewriteBatchedStatements=true 在10万到100万这个区间内,是稳定最优解。foreach在数据量较小时和BATCH差距不大,但到百万级别会被拉开;sql拼接除了内存风险外,速度也很难超越BATCH。

第二个结论是:连接参数对结果的影响,可能比插入方式本身还大。同一个BATCH模式,开不开rewriteBatchedStatements,性能差接近5倍。这就意味着,网上那些脱离参数环境得出的“BATCH模式很慢”的结论,很可能就是踩了这个隐藏开关的坑。

4. 执行链路的底层拆解:为什么快、为什么慢、为什么会报错

只知道快慢不够,还得知道快在哪、慢在哪。这一节从执行链路层面把三种方式的本质差异讲清楚。

4.1 foreach方式的开销:XML解析、网络包与max_allowed_packet

foreach方式的核心问题是“一条SQL的长度”。MySQL服务端对单次请求的SQL文本长度有硬限制,由参数max_allowed_packet控制,默认通常是64MB,但在不少云数据库实例上会被调成4MB或16MB。你可以用下面的SQL查看:

SHOW VARIABLES LIKE 'max_allowed_packet%';

假设每条VALUES约80字节,批5000行就是400KB,批1万行就是800KB。听起来不大,但别忘了Binlog、undo log、redo log都会因为这条大SQL产生额外存储,主从复制时从库也要执行这条长SQL,网络传输和从库解析成本都会成倍增加。

很多人在实战中遇到PacketTooBigException,就是单批行数乘上每行长度超过了max_allowed_packet。解决办法不是无限调大这个参数,而是控制单批大小。我实测下来,foreach单批控制在3000到5000行之间比较合适,超过1万行很容易出问题,而且即使不报错,MySQL解析超长SQL的CPU消耗也会显著上升。

4.2 BATCH模式依赖的JDBC批处理机制:rewriteBatchedStatements的影响

BATCH模式的链路要仔细捋一下。应用层循环调用mapper.insert(),Mybatis BATCH执行器把这些INSERT都收集起来,最后通过JDBC的PreparedStatement.addBatch()加入批量队列,再executeBatch()一次性提交给MySQL驱动。

关键就在这里:MySQL的JDBC驱动拿到一个batch后,如果rewriteBatchedStatements=false,就会老老实实把每一条INSERT拆开,逐条发给服务器。这对应用层来说确实是一次网络调用,但对服务器来说还是收到了几十上百条独立INSERT,服务端的执行开销和逐条插入没本质区别。

当rewriteBatchedStatements=true时,驱动会在客户端把一批同构的INSERT重写成一条带多个VALUES的大SQL,比如你addBatch了1000条,驱动就组装成一条1000个VALUES的INSERT发过去。这一步其实就是我们在foreach方式里做的事情,但省掉了Mybatis的XML解析、参数列表构建这一层,而且流程发生在驱动内部,比在Mybatis层拼SQL更接近数据库协议底层,所以性能是最好的。

4.3 sql拼接方式的OOM隐患与SQL长度边界

sql拼接方式表面上就是“手动做了foreach”,它比foreach少了解析XML和参数映射的开销,但多了一个必须自己处理的坑:转义。

VALUES里的字符串如果有单引号、反斜杠,直接拼进SQL就会语法错误,甚至形成注入点。必须对每个字符串做转义处理,这就无形中增加了CPU和内存开销。更麻烦的是,拼接过程中StringBuilder会不断扩容,一批5000行的SQL文本就有400KB到800KB,如果业务字段更多,一两千万的数据文件在内存里反复拷贝,GC压力非常大。

我之前在一次测试中观察到,sql拼接方式插入100万行时,老年代占用比BATCH方式高出一倍多,Full GC次数明显增多。这就是它的真实代价:代码层面看似绕开了框架开销,实际上把内存和安全的成本转移给了应用自身。数据量小的时候无所谓,一旦上量,随时可能在你毫无准备的时候把应用拖垮。

5. 踩坑记录:批量插入实战中的典型问题与调优手段

这一节全是实战中踩过的坑,一个个说。

5.1 坑一:foreach里拼多条INSERT时,没配allowMultiQueries

曾有同事A在某项目里用foreach拼了多条INSERT,自测时报语法错误。原因是把SQL写成了:

<foreach collection="list" item="item" separator=";"> INSERT INTO t_user (name) VALUES (#{item.name}) </foreach>

生成出来的SQL长这样:

INSERT INTO t_user (name) VALUES ('a'); INSERT INTO t_user (name) VALUES ('b'); ...

MySQL默认不允许在一次请求里发送多条语句,必须连接URL上加allowMultiQueries=true。这个参数一开,等于把多语句执行能力开放给所有请求,一旦应用里存在SQL拼接漏洞,攻击面会明显扩大。我的建议是:能不用就不用,改成单条多VALUES的写法,顺带还能省一个连接参数。

5.2 坑二:BATCH模式下拿不到自增主键

这是最让人头疼的坑。在BATCH模式下,useGeneratedKeys的生效情况和普通插入不一样。我在百万行测试里需要拿到每行的自增主键去做关联数据,结果发现flush之后,批量插入返回的主键值要么为空,要么无法一一对应。

排查下来的结论:BATCH模式下,一次executeBatch()会执行多条INSERT,驱动能返回的auto_increment信息并不保证能精确映射到每条记录上。解决方式有几种:如果业务必须拿主键,就在每条INSERT之后单独执行一次SELECT LAST_INSERT_ID(),但这等于又退回了逐条模式;或者改造表结构,插入前在应用层生成业务主键,不依赖数据库自增;再或者用foreach方式插入,这种方式的useGeneratedKeys是能正确回填的。我的建议是,先确认业务是否真的需要在插入过程中立即拿到主键,很多时候这个需求可以用事后批量查询替代。

5.3 坑三:日志中的SQL打印让应用直接OOM

Mybatis开启SQL日志后,如果执行了一万行的批量INSERT,日志框架会把这条SQL文本完整打印出来。一条包含1万行VALUES的SQL,文本轻松超过1MB,如果日志同时输出到文件和控制台,IO开销和内存开销都是灾难级的。

我之前在测试时开着DEBUG日志跑50万行数据,还没等插入完成,应用内存就涨到了几个GB,最后OOM。排查时第一反应是数据量太大导致的,后来才发现是日志刷屏惹的祸。批量插入场景下,要么关闭Mapper的日志输出,要么对日志做截断处理。这是一条很多人忽略但实际上影响巨大的经验。

5.4 把max_allowed_packet和事务粒度调对的顺序

调优顺序也很重要。一次批量插入的常见链路是:先怀疑单批大小,再怀疑SQL长度,最后才想到连接参数。我的建议是先确认rewriteBatchedStatements已经开启,再根据max_allowed_packet来定单批大小,最后确认事务边界。

关于事务粒度,很多人喜欢把100万行包在一个事务里,要么全成功要么全回滚。这种方式在数据量大时有两个问题:一是undo log占用巨大会拖慢数据库,二是万一中间失败,回滚时间可能比插入时间还长。我的实践是每2到5万行提交一个事务,这样既能保证失败重试的粒度可控,也不会让数据库事务压力过大。

6. 选型建议:不同业务场景下我最终采用的方案

结合实测结果和踩坑经验,我在不同场景下有不同的选型。

6.1 万行以内:无脑foreach

数据量在1万以内,foreach是最优选择。代码简洁,阅读成本低,不需要处理SqlSession的获取和关闭,也不涉及额外的连接参数。有人可能会说BATCH性能更好,但在万行量级,foreach耗时可能就是1秒,BATCH是0.3秒,这点差距在真实业务里根本感知不到,而复杂度和维护成本却实打实增加了。

6.2 十万量级:BATCH模式是标准答案

10万量级,尤其是需要稳定写入带索引的业务表时,我优先选择ExecutorType.BATCH+rewriteBatchedStatements=true,每次flush 1000到2000条。这里的flush频次要说明一下:不是越大越好,因为每次flush的batch过大,驱动重写生成的SQL会很长,又回到了SQL长度问题。实测中1000到2000条是稳定性很好的区间。

如果你用的是Spring,还需要注意一个细节:Spring的SqlSessionTemplate默认执行器是ExecutorType.SIMPLE,即使你的Mapper方法里写了batch逻辑,也可能不生效。要在配置里指定执行器类型:

mybatis: configuration: default-executor-type: batch

或者在Java代码里手动获取BATCH类型的SqlSession。

6.3 百万量级:分片加批次的组合拳

100万行以上,我会用分片加批次的组合。整体思路是:把100万行切分成若干个5万行的大片,每个大片内再用BATCH模式以1000行为一个批次flush,每个大片完成后提交一次事务。这样数据库不会在同一时间接收到过大的网络包和SQL文本,应用内存也一直维持在一个稳定水位。

表中有大量索引时,也可以考虑先评估索引总量,如果插入时间完全不可接受,业务上又允许,可以临时删除非必要索引,插完再重建。这个操作代价不小,但有时候是唯一可行的方案。我实际用过一次,在5个索引的表中插入200万行,删除3个非必要索引后,总耗时从十几分钟降到几十秒,重建索引的时间也都回来了。

6.4 最后再分享几个写代码时的小技巧

代码层面的几个细节,顺手写在这里。批量插入前,一定要确认连接池配置的自动提交状态,用BATCH模式时最好在事务内部操作,否则每批flush都会隐式提交,事务边界完全失控。插入完成后,在低峰期执行一次ANALYZE TABLE更新统计信息,对后续查询计划有帮助。最后,建表时的行格式和字符集也会影响批量插入速度,utf8mb4和utf8的差别在超大批量时会放大,能用utf8就别盲目用utf8mb4,前提是业务确实不需要生僻字或Emoji。

测完这一轮之后,我对批量插入的整体认识更清晰了。最核心的一点是,性能和代码形态的关系没有想象中那么大,真正决定上限的是数据库参数、驱动参数、分片策略和事务边界的整体配合。以后再有人问Mybatis批量插入怎么选,我第一句会先问:你的rewriteBatchedStatements开了吗?如果没开,先把参数调对,再回头讨论用哪种方式。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询