ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MyBatis批量操作全解:插入、更新、删除的写法与性能调优

MyBatis批量操作全解:插入、更新、删除的写法与性能调优

前阵子在做一个数据迁移,需要把几十万条历史数据导到新表里。一开始图省事直接写了个for循环,每跑一条就调一次Mapper方法,结果跑了十几分钟还没完,旁边同事随口问了一句:“你这是在‘批量插入’?”我才意识到,MyBatis里贴着“批量”标签的写法有完全不同的底层执行路径,性能差距能到一个数量级。这篇文章就把MyBatis的批量插入、批量修改、批量删除完整梳理一遍,从写法到原理,从参数调到踩坑,全是我实际用过的方案和数据。

1. 先搞清一个关键区别:拼大SQL不等于JDBC批量提交

很多刚接触MyBatis的人以为,把多条INSERT拼成一条SQL就是“批量”,其实这只是把N次单条提交变成了1次大SQL提交,和真正JDBC里的executeBatch是两条完全不同的路线。

1.1 单条循环为什么慢:耗时大头在网络往返和事务开销

单条循环插入时,每一次Mapper方法调用都会走一遍:解析SQL、发送到数据库、数据库执行、返回结果。对于10000条数据,就是10000次完整的请求-响应往返,网络延迟哪怕只有0.5ms,光往返就5秒起步,这还没算SQL解析和事务提交的时间。

更隐蔽的问题是事务。如果外层没有用@Transactional包住,每个insert默认走autocommit,每次调用都会自动提交一次。提交不是免费的,它涉及写日志、释放锁、同步数据文件等一系列操作,次数一多整个数据库的负担会明显上来。

1.2 JDBC batch到底做了什么

JDBC的PreparedStatement提供了addBatch和executeBatch两个方法。addBatch是把SQL模板对应的参数分组放进内存,executeBatch一次性把攒好的参数发送给数据库。数据库拿到后有优化空间,比如把多条带参数的INSERT合并处理,减少解析次数,甚至在某些驱动下直接改写成一条多VALUES的INSERT。

所以真正意义上的批量,指的是这种方式,而不是在XML里手动拼接一个超长SQL。MyBatis里对应的能力是ExecutorType.BATCH。

1.3 MyBatis的两条批量路线

MyBatis执行SQL时有三种Executor:SIMPLE、REUSE、BATCH。默认是SIMPLE,每条语句都要创建PreparedStatement;REUSE则缓存PreparedStatement;BATCH模式下会把语句加入批处理,由驱动统一发送。

但是注意一点:哪怕你用ExecutorType.BATCH,在Mapper XML里写的还是普通的单条INSERT,MyBatis只是把它加入了批处理批次,底层仍然是PreparedStatement.addBatch。而很多人用的 拼VALUES,其实还在SIMPLE Executor里,最终执行的是一条巨型INSERT语句,数据库把它当成一次普通SQL处理。

这两条路线都能提升性能,但适用的数据量、能接受的SQL长度、以及调优参数完全不一样。下面分场景说。

2. 批量插入:三种实际写法和性能差异

批量插入是我日常写的最多的操作,见过不少人在这上面踩坑。先看三种写法,再给对比数据。

2.1 写法一:foreach拼接VALUES,适合几千条以内的常用方案

Mapper接口不用特殊处理,XML里直接写:

<insert id="batchInsert" parameterType="list"> INSERT INTO t_user (name, age, dept_id) VALUES <foreach collection="list" item="item" separator="," open="" close=""> (#{item.name}, #{item.age}, #{item.deptId}) </foreach> </insert>

接口定义:

int batchInsert(List<User> userList);

这种写法很直观,把一条INSERT变成INSERT INTO t_user (...) VALUES (...),(...),(...)。数据库收到后,解析一次SQL模板,执行N行数据插入,性能远好于循环单条。

它的最大限制是SQL总长度。MySQL服务端有max_allowed_packet限制,默认常见配置是4MB或16MB,如果你一次塞进去的数据超过这个限制,数据库会直接报错或者断开连接。所以这个方案只适合单批数据可控的场景,一般控制在几百到两三千条以内相对安全。

2.2 写法二:ExecutorType.BATCH,适合大批量数据

如果你想插入几万甚至几十万条,就别再追求“一条大SQL”了,改用真正JDBC批处理更稳。

在Spring项目里,可以单独配置一个使用BATCH模式执行器的SqlSessionTemplate:

@Bean public SqlSessionTemplate batchSqlSessionTemplate(SqlSessionFactory factory) { return new SqlSessionTemplate(factory, ExecutorType.BATCH); }

然后注入这个Bean做插入:

private final SqlSessionTemplate batchSqlSessionTemplate; public void batchInsert(List<User> users) { UserMapper mapper = batchSqlSessionTemplate.getMapper(UserMapper.class); for (User user : users) { mapper.insert(user); // 这里的insert就是普通单条INSERT } batchSqlSessionTemplate.flushStatements(); }

注意几个细节:

  • 方法必须跑在事务里。BATCH模式下,MyBatis会把语句积压起来,只有commit时才会真正把批处理发给数据库,如果没开事务,第一条语句就可能被自动提交,后面的批量效果直接没了。
  • 大批量数据要分批flush,不要万条数据攒到最后一次flush。
  • 这种写法和foreach拼接看起来完全不同,但实际上Mapper里的insert语句就是单条INSERT的写法,不需要特殊XML。

2.3 三种方式的实际耗时对比

我之前在一个项目里做过简单压测,普通表的INSERT,数据量分别取500条、5000条、20000条,结果大概如下:

数据量单条循环foreach拼接VALUESBATCH模式(未开rewriteBatchedStatements)BATCH模式(开启rewriteBatchedStatements)
500条约800ms约90ms约280ms约55ms
5000条约8s约700ms约2.4s约300ms
20000条约35s约3s(容易超SQL长度限制)约9s约1.2s

这三个数字很有代表性:foreach拼接在小批量数据上表现惊艳,因为只发一次网络包;BATCH模式在小批量时反而有额外的批处理组装开销;但数据量一旦上来,BATCH模式配合驱动参数后性能最稳。

具体怎么选,我的标准是:少于1000条用foreach拼接;1000到10000条拆成批,每批用foreach;超过1万条建议走BATCH模式,每批500到1000条提交一次。当然这个阈值不是定死的,得看单行数据宽度和数据库配置。

3. 批量修改:CASE WHEN一次更新多行的写法和取舍

批量修改比批量插入难优化。INSERT可以用多VALUES,UPDATE没有“多SET”这种等价写法,所以最常见的做法就是逐条UPDATE,但逐条更新的性能瓶颈和单条插入一样明显。

3.1 用CASE WHEN在一条UPDATE里改多行

MyBatis的困境是:要改100条数据,逐条UPDATE就是100次执行,性能差;但UPDATE没有多VALUES,只能采用CASE WHEN + IN的方式。

单字段场景这么写:

<update id="batchUpdateNameById"> UPDATE t_user SET name = <foreach collection="list" item="item" open="CASE id" close="END"> WHEN #{item.id} THEN #{item.name} </foreach> WHERE id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>

生成的SQL等价于:

UPDATE t_user SET name = CASE id WHEN 1 THEN '张三' WHEN 2 THEN '李四' WHEN 3 THEN '王五' END WHERE id IN (1,2,3)

多字段场景也类似,每个字段单独一个CASE:

<update id="batchUpdateUser"> UPDATE t_user SET name = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.name} </foreach> END, age = CASE id <foreach collection="list" item="item"> WHEN #{item.id} THEN #{item.age} </foreach> END WHERE id IN <foreach collection="list" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>

这里有个特别容易踩的坑:如果CASE里没有覆盖列表里的所有id,或者某些行的字段不需要更新,其他行的字段就会被置成NULL。所以要么保证每条记录的所有字段都有值,要么在Java代码里先处理好空值逻辑,不要让空值参与CASE WHEN。

3.2 为什么大批量UPDATE仍然不建议硬拼

CASE WHEN方案把N次UPDATE合成1次,确实能大幅减少网络往返,但它本质上仍是一条超大UPDATE语句。数据库执行时,会对WHERE里的所有目标行加锁,如果目标行数持续很大,锁范围扩大,容易阻塞其他事务,主从同步的延迟也会变高。

我在一个真实项目里遇到过:一次CASE WHEN更新了2万行,主从延迟瞬间飙升到十几秒。业务上其实只改了其中几十个人的数据,但因为列表是一次性全量传来的,导致所有行都被更新了一遍。这种情况的根因不是MyBatis,而是业务侧没有做“只传变更行”的裁剪。

对于几百到几千行的小批量更新,CASE WHEN是能明显提升效率的。超过这个量级,我更建议走临时表方案,或者干脆拆成每小时同步任务,别硬塞到一条UPDATE里。

3.3 MySQL里更猛的做法:临时表JOIN UPDATE

如果单批更新量到了上万行,常规SQL都不太推荐了。MySQL里有个性能更好的替代方案:先把要更新的数据批量插入一张临时表,然后通过JOIN一次UPDATE目标表。

CREATE TEMPORARY TABLE tmp_user_update ( id BIGINT PRIMARY KEY, name VARCHAR(50), age INT ); INSERT INTO tmp_user_update VALUES (1, '张三', 20), (2, '李四', 22), ...; UPDATE t_user u JOIN tmp_user_update tmp ON u.id = tmp.id SET u.name = tmp.name, u.age = tmp.age;

MyBatis里可以用两条Mapper方法:一个人往临时表批量插入,一个人执行JOIN UPDATE。这样做的优势是避免超大CASE WHEN,批量插入本身可以走上一节说的foreach或BATCH执行器,数据写入临时表的效率远高于一条巨型UPDATE。

这种做法的问题是临时表会话隔离,必须确保两条SQL在同一个数据库连接里执行;一旦走连接池随手换连接,临时表可能就“不见了”。同时多一个建表和写临时表的环节,代码复杂度和维护成本会高一些,所以我只在数据量真的很大的场景才换这套方案。

4. 批量删除:稳写法和两个高频坑

批量删除看起来最简单,也最容易翻车。最常见的就是IN列表那套写法,但两个坑一个接一个。

4.1 基于主键IN集合删除的标准写法

<delete id="batchDeleteByIds"> DELETE FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </delete>

接口长这样:

int batchDeleteByIds(List<Long> ids);

这个写法本身没问题,但你必须明白,它生成的是一条包含N个参数的DELETE语句,效果是把N行一次锁住并删除。对几千条以内很合适,太多就有下面要说的坑。

4.2 第一个坑:集合为空直接生成语法错误的SQL

如果ids是空集合,foreach生成出来的SQL会变成:

DELETE FROM t_user WHERE id IN ()

MySQL直接报语法错误。很多人会考虑在Java层调用前判断集合是否为空,可一旦代码里有别的调用方忘记判断,线上就炸了。保险的办法是把判断写进XML:

<delete id="batchDeleteByIds"> <if test="ids != null and ids.size() > 0"> DELETE FROM t_user WHERE id IN <foreach collection="ids" item="id" open="(" separator="," close=")"> #{id} </foreach> </if> </delete>

注意这里不能只判非空,必须判断ids.size() > 0。只判断ids != null时,空list一样会导致IN ()。

4.3 第二个坑:IN列表太长和SQL超限

IN列表里的参数个数是没有硬性语法限制的,但实际上受两件事限制:一条SQL的总长度,以及数据库对执行计划的处理开销。

当你批量删除一万条数据时,SQL长度已经很大,而且MySQL要先解析一万个id,生成一个非常大的IN集合,效率会肉眼可见地下降。更麻烦的是,DELETE操作是逐行加锁的,一次性删一万行会长时间占用大量锁,直接影响其他并发业务。

我实际生产环境里的做法是:在Java层把ids拆成每批500到1000的小批次循环删除,每批调用一次batchDeleteByIds,中间不需要额外的commit控制,层次清晰,锁的持有时间也短得多。

4.4 联合条件删除和逻辑删除

如果删除场景不只是按主键,而是按多个字段组成的业务键,比如删除某个用户在某部门下的所有角色关联,可以这样写:

<delete id="batchDeleteByUserAndRole"> DELETE FROM t_user_role WHERE <foreach collection="list" item="item" separator=" OR "> (user_id = #{item.userId} AND role_id = #{item.roleId}) </foreach> </delete>

生成的是WHERE (user_id = 1 AND role_id = 1) OR (user_id = 2 AND role_id = 2),逻辑没问题,但OR条件写大了之后同样面临SQL长度和性能问题,尽量控制批次大小。

另外插一句:能逻辑删除的场合就别走物理删除。加一个deleted字段,批量修改deleted=1,既避免大范围锁行,又保留数据可追溯。我在项目里默认都是软删除,物理删除只留给真正需要清数据的运维场景。

5. 决定性能的隐藏参数:两个能让你批处理脱胎换骨的开关

很多人把ExecutorType.BATCH打开后跑了一遍测试,发现性能并没有想象中提升,然后就开始怀疑MyBatis是不是没用对。其实问题多半落在JDBC驱动参数上。

5.1 rewriteBatchedStatements=true

MySQL的JDBC驱动默认处理executeBatch时,会一条一条地把SQL发给数据库。也就是说,你明明在BATCH模式下攒了一批数据,驱动层面却还是一条一次发送,自然没什么明显的性能提升。

只要在连接URL上加一个参数:

jdbc:mysql://localhost:3306/my_db?rewriteBatchedStatements=true

驱动就会在发送前把同一SQL模板的批处理参数改写成一条多VALUES的INSERT,或者批量语句一次性发给数据库。这一步才是BATCH模式真正提速的关键。我在前面的测试数据里,BATCH模式开不开这个参数,性能差了将近一个数量级。

改完记得看一下事务边界。很多项目里BATCH执行器配对了,但调用Mapper方法的外层没有加事务,MyBatis会在每一次insert时就把语句直接发送,批量效果自然归零。这是最常见的一处“配置正确但没生效”的原因。

5.2 max_allowed_packet与SQL长度限制

foreach拼接大SQL时,错误信息往往很直接:PacketTooBigException或者干脆连接被断开。这时候要查的是MySQL服务端的max_allowed_packet。

它有服务端和客户端两层概念。服务端是my.cnf里的配置,控制单次通信包的最大大小;客户端是JDBC连接URL里的maxAllowedPacket参数。两者必须匹配,通常建议服务端设成16MB到64MB,客户端URL加上maxAllowedPacket=67108864(64MB)。不要只改一边,两边不一致会引发莫名其妙的截断或断连。

对于大批量数据,我更建议在代码层限制单批大小,而不是依赖调大这个参数去扛。原因很简单:参数调大只能解决“不报错”,但一条超大SQL在执行计划优化、内存分配和数据页锁定时,代价都跟在后面。

5.3 allowMultiQueries是个容易误用的参数

还有一个参数很常见但我不推荐用于批量写操作:allowMultiQueries=true。它允许一条连接上发送多条用分号分隔的SQL。

看起来很方便,可以在一个XML方法里直接拼接多条DELETE,甚至多条UPDATE,把一次网络往返做了。但问题在于:多条SQL无法共享一个事务边界,除非你手动控制;而且MyBatis的动态SQL拼出来的分号语句在出错时定位困难,日志打印出来也特别难读。更重要的是,数据库执行多条语句时,锁的释放和错误回滚行为都变得不可控,线上排查成本很高。

日常项目里,能用IN、能用batch模式解决的问题,就别把多条SQL用分号拼接。这个参数我基本只在初始化脚本里会用到,业务代码里是禁用的。

6. 选型建议和一份实战避坑清单

把前面几章的内容落到具体项目里,我日常的选型规则大致如下。

插入数据:单批几千条以内优先foreach拼接VALUES;上万条数据改用ExecutorType.BATCH,并且一定加上rewriteBatchedStatements参数;推送数据时按每批500到1000条拆分,不追求一条SQL搞定全部。

批量修改:目标行数少(几十行以内)直接逐条UPDATE,代码清晰、锁范围小;几百到几千行用CASE WHEN方案;量再大就换临时表JOIN UPDATE;如果连临时表都吃力,就要从业务上考虑分页或异步批量,不要硬扛。

批量删除:主键IN删除加空集合判断,每批500到1000条;能软删除就软删除,物理删除只留给维护场景。

再送一份问题排查表,至少覆盖我遇到过的绝大多数情况:

现象可能原因解决办法
INSERT耗时长,接口调用慢for循环单条提交,事务频繁提交改为foreach拼接或BATCH模式
BATCH模式没有性能提升缺少rewriteBatchedStatements参数连接URL加rewriteBatchedStatements=true
DELETE/UPDATE报语法错误集合为空,生成IN ()XML里加ids.size() > 0判断
大批量SQL报PacketTooBig单条SQL超过max_allowed_packet分批执行,或调大max_allowed_packet
BATCH下数据没有提交,接口却不报错没有外层事务,执行器提前提交确认包在@Transactional里
大批量UPDATE后主从延迟高单条SQL更新行数过大改分批或临时表JOIN UPDATE
多条件造成的OR条件性能下降OR组合过多,SQL模板过于复杂改双字段元组IN,或换临时表方案

最后说一点个人体会。有一次我处理某项目的数据初始化,第一批用foreach拼了一万条INSERT,直接撞上max_allowed_packet,数据库连接断了一地。后来改成每批800条foreach,稳定跑完后我又试了一遍BATCH模式,发现连CPU占用都低了一截。那之后我就养成了个习惯:做批量任务前,先看单行数据宽度、数据库参数、以及目标表有没有触发器或外键约束,宁可多花十分钟评估,也不上线后再处理故障。MyBatis给批量操作留了很多可能性,但最终跑得稳不稳,往往取决于你对底层执行路径的理解,而不是单纯换个标签的事。

RELATED READING

延伸阅读

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