news 2026/10/14 18:28:24

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MyBatis批量操作:插入、更新、删除的写法与性能调优实践

批量操作这种东西,平时写业务代码的人基本都绕不开。尤其是 MyBatis,它本身没有提供像insertBatch这种现成的 API,所有批量逻辑都得靠我们自己在 XML 里用动态 SQL 拼,或者在代码里换ExecutorType实现。很多新手一上来就写for循环里调单条insert,结果几百条数据插了几十秒,直接被领导喊去谈话。这篇文章就把 MyBatis 批量插入、批量修改、批量删除的几种常见写法、底层原理、性能差异和那些只有踩过坑才知道的细节全讲透,适合刚接手维护老项目的人,也适合正在设计新模块数据层的同学参考。

1. 批量操作的整体思路与方案选型

1.1 三种主流方案速览

先说结论,MyBatis 里做批量操作,翻来覆去就三种路子:循环单条执行、foreach 拼接大 SQL、BatchExecutor 批量执行。

循环单条最简单,代码看起来也干净,Service 里一个for循环,每轮调一次 Mapper 的单条方法。但从数据库连接角度看,每调一次 Mapper 就是一次完整的网络往返、一次 SQL 解析、一次事务交互。插入 1000 条数据就是 1000 次往返,性能瓶颈非常明显。

foreach 拼接是大家最熟悉的姿势。在 XML 里写<foreach>,把集合拼成一条INSERT INTO ... VALUES (...),(...),(...)或者多段UPDATE。这种方式把 N 次网络往返压缩成 1 次,SQL 体量变大,但整体执行时间大幅下降。MySQL 里配合rewriteBatchedStatements还能进一步优化,后面细说。

BatchExecutor 是 MyBatis 提供的一种执行模式,用ExecutorType.BATCH打开 SqlSession,然后把单条 SQL 一条一条addBatch()进 JDBC 的批量缓冲区,最后统一flushStatements()发送。这种模式更接近 JDBC 层PreparedStatement.addBatch()的本质,数据量大时内存占用更可控,处理服务器端预编译也更友好。

1.2 方案取舍:没有银弹,只有组合拳

实际项目中,这三种方案不是三选一,更多时候是组合使用。我的习惯是:MySQL 且单批数据量在几百到上千条,优先用 foreach 拼 INSERT;数据量上万或者需要逐条执行业务校验、动态拼接,用 BatchExecutor;数据量很小(几条到几十条),循环单条完全够用,别为了批量而批量。

为什么 MySQL 下 foreach 拼接表现好?因为 MySQL 对多值 INSERT 的优化很到位,单条大 SQL 在服务端解析一次、执行一次,比同样内容的 N 条小 SQL 快得多。但 Oracle 就不同了,Oracle 不支持INSERT INTO ... VALUES (...),(...)这种多值写法,也有IN列表 1000 条的上限限制,所以针对不同数据库,方案必须调整,不能一套 SQL 通吃。

另外一个容易忽略的问题是事务边界。批量操作用到后面你会发现,很多"零散批次慢"的问题,本质上是没把批量 SQL 放进同一个事务里,或者 Spring 事务没生效导致每个 Mapper 调用各自提交。这一点我会在最后一章专门讲。

2. 批量插入:最常用也最容易踩坑的写法

2.1 foreach 拼多条 VALUES 的标准写法

这是批量插入最主流的 XML 写法:

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

对应的 Mapper 接口方法:

int batchInsert(@Param("list") List<User> userList);

这里有个隐式陷阱:collection到底写list还是xxx,取决于@Param的注解值。如果不加@Param("list"),MyBatis 会把参数表里唯一的集合包装成list、collection、array这几个可用名字。但如果方法里除了集合还有其他参数,就必须用@Param指定名字,否则会抛There is no getter for property named 'list'。

拼出来的 SQL 长这样(假设三个字段):

INSERT INTO t_user (name, age, email) VALUES (?, ?, ?),(?, ?, ?),(?, ?, ?)

占位符直接用?代替,PreparedStatement 参数通过 MyBatis 自动绑定。这有个好处:多次执行同样的 SQL 模板时,数据库可以复用执行计划。但你也要知道,这条 SQL 如果字段特别多、数据量特别大,拼接后的字符串会非常长,容易撞上数据库限制。

2.2 批量插入的批次控制与参数计算

很多人写批量插入,不控制单批数量,把整个List直接丢进去,几千条也许能跑,几万条就等着报错吧。

MySQL 有一个关键参数叫max_allowed_packet,表示单个 SQL 包的最大值,默认通常是 4M 或 64M(取决于版本和初始化配置)。举个例子,一条 INSERT 语句如果包含 10000 条记录,每条记录 200 字节,再加上 SQL 文本开销,很容易超过 4M。这时候 MySQL 会直接拒绝掉,报错信息类似:

Packet for query is too large (4,000,000 > 4,194,304)

所以批次数量必须控制。我个人的经验值是:单批 500 到 1000 条比较稳。字段越多、字段值越长,单批数量就越要向 500 靠拢;字段又少值又短,可以放到 1000 条甚至更多。

批次切分很简单,写一个通用的分片方法:

public static <T> List<List<T>> partition(List<T> list, int batchSize) { List<List<T>> result = new ArrayList<>(); for (int i = 0; i < list.size(); i += batchSize) { result.add(list.subList(i, Math.min(i + batchSize, list.size()))); } return result; }

然后分批调用,每一批一般都要在一个事务里,完成后再提交:

@Transactional(rollbackFor = Exception.class) public void batchInsertUsers(List<User> users) { List<List<User>> batches = partition(users, 500); for (List<User> batch : batches) { userMapper.batchInsert(batch); } }

注意:如果把@Transactional加在外部方法上,所有批次的插入都会汇聚到一个物理事务里,任一批次失败都会整体回滚。如果你希望"失败之前已插入的不回滚",那就得手动控制提交时机,而不是依赖 Spring 事务,这取决于具体业务诉求,没有绝对对错。

另外一个隐藏参数也要记得看,MySQL 驱动里有一个rewriteBatchedStatements=true。这个参数对 JDBC 的addBatch()影响很大,开启后 MySQL 驱动会把同一连接上的多条 INSERTaddBatch自动重写为一条多值 INSERT 再发给服务端。如果你在代码里用的是 BatchExecutor,但连接串忘了加这个参数,你会发现批量效果大打折扣,因为每条 INSERT 还是被独立送到了服务端。

2.3 批量插入的主键回填陷阱

批量插入时经常要回填主键,很多同学在 XML 里写:

<insert id="batchInsert" useGeneratedKeys="true" keyProperty="id">

天真地以为每条记录都能拿到自己的自增主键,结果发现列表里所有对象的id全都变成了同一个值,或者只有第一个有值,剩下的都是 null。这不一定是你代码写错了,很可能是驱动和 MyBatis 版本配合踩坑。

MySQL 的 JDBC 驱动在 5.1.7 之后支持多行生成的键,但它返回的键是按行分组的。MyBatis 的useGeneratedKeys在批量场景下的处理行为依赖具体驱动实现。实测下来,有些版本确实会把第一行的主键赋给所有对象,非常坑。如果你想稳妥地拿到每条记录的自增主键,比较好的做法是:插入完成后,手动按唯一业务键回查一次主键映射,或者干脆设计时就避免批量回填这个需求。

如果数据库是 PostgreSQL,情况好一些,它支持RETURNING id,批量插入时更可靠。但这也说明一个问题:useGeneratedKeys不是银弹,数据库能力和驱动行为都要考虑。

3. 批量修改:CASE WHEN 与 BatchExecutor 的实战对比

3.1 foreach 拼多条 UPDATE:谨慎使用

批量修改最容易想到的写法是 foreach 拼接多条 UPDATE:

<update id="batchUpdateByMultiSql"> <foreach collection="list" item="item" separator=";"> UPDATE t_user SET name = #{item.name}, age = #{item.age} WHERE id = #{item.id} </foreach> </update>

这种写法有个硬前提:MySQL 连接参数必须开启allowMultiQueries=true。默认情况下,MySQL 驱动不允许一次发送多条以分号分隔的语句,直接执行会报 SQL 语法错误。

就算加了allowMultiQueries=true,我也非常不建议你这么干。原因有三个:第一,多条语句只要有一条失败,整个包的执行结果会比较难判断,错误信息往往不够精准;第二,这种写法会绕过 PreparedStatement 的预编译优化,每条语句单独解析;第三,SQL 注入的暴露面变大,虽然 MyBatis 的#{}能挡住一部分,但动态拼接带来的风险仍然不值得冒。

如果你的批量更新需求是不同行、不同更新值,真正推荐的方案是下面的 CASE WHEN 单语句更新。

3.2 CASE WHEN 单语句批量更新

CASE WHEN 的思路是:把多条 UPDATE 合并成一条 UPDATE,通过CASE id WHEN ... THEN ...按行分散更新不同字段。XML 写法如下:

<update id="batchUpdate"> 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>

这条 SQL 拼出来大概长这样:

UPDATE t_user SET name = CASE id WHEN 1 THEN '张三' WHEN 2 THEN '李四' END, age = CASE id WHEN 1 THEN 18 WHEN 2 THEN 20 END WHERE id IN (1, 2)

翻译过来就是:把符合条件的两行数据,分别设置成各自想要的 name 和 age。整个操作只用了一条 SQL,网络交互、数据库解析次数都降到了最低。

这种写法在字段数量少(比如 3 到 5 个字段)的时候性能非常好,数据库执行计划也能高效处理。但当更新字段特别多时,SQL 文本会非常长,CASE表达式树也大,解析开销明显上升。我做过的对比测试里,字段超过 6 个后,CASE WHEN 的耗时增长很夸张,不如改用 BatchExecutor 逐条 update 更稳。

CASE WHEN 还有一个需要注意的地方:WHERE id IN (...)里面那串 id 必须和每个CASE里的 id 一一对应,否则会出现"SQL 执行成功但没更新到目标行"的静默错误。建议在业务层保证集合内数据完整性,或者先用DISTINCT去重。

3.3 批量修改的 NULL 值处理

CASE WHEN 写法碰上 NULL 值,问题就来了。CASE id WHEN 1 THEN NULL确实会把name更新为NULL,这在 SQL 语义上是"明确更新为空",但往往不是业务想要的结果。很多公司的表设计里,字段的允许 NULL 与不允许 NULL 并存,业务中"不传字段就保留原值"的需求非常普遍。

因此,修改前要明确策略:是把 NULL 当作有效值写入数据库,还是当作"不更新该字段"的信号。如果是后者,光靠 MyBatis 的<if test="item.name != null">动态 SQL 在 UPDATE 里不好使,因为 CASE WHEN 的分支是整体结构。比较常见的解法是在 Java 层做预处理:遍历集合,把 null 字段替换成原值或排除掉。

还有一个纯 SQL 层面的技巧,利用COALESCE和子查询保留原值:

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

COALESCE(#{item.name}, name)的意思是:如果传入的 name 是 null,就取当前行的 name(也就是原值)。这样就能做到"null 不覆盖,非 null 才更新"。不过要注意,这种做法要求更新前先查出原值,或者在 SQL 执行时访问同一行的旧值,MySQL 的 UPDATE 语句里取自身旧值是允许的,实际效果也是符合预期的。

4. 批量删除:最需要边界保护的操作

4.1 IN 列表实现批量删除

批量删除的常规写法是 foreach 生成IN列表:

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

Mapper 接口:

int batchDelete(@Param("ids") List<Integer> ids);

拼出来的 SQL:

DELETE FROM t_user WHERE id IN (1, 2, 3)

这条 SQL 简单明了,绝大多数场景都够用。性能方面,只要id上有主键或索引,MySQL 的 IN 列表查询优化还是做得不错的。

但要注意,批量删除属于高危操作,在真实业务中比插入、更新更需要边界保护。我见过好几个线上事故,都是删除条件没拼对,或者参数集合误传成了一个空集合,直接把整表数据清掉了。所以核心逻辑里加一个保护判断非常有必要:

public void batchDelete(List<Integer> ids) { if (ids == null || ids.isEmpty()) { throw new IllegalArgumentException("删除 id 列表不能为空"); } userMapper.batchDelete(ids); }

这些保护不能只靠 Mapper XML 层解决,因为空集合在 XML 里如果处理不好,还会引发语法错误,下面细说。

4.2 空集合与超长 IN 列表的处理

先看一个经典问题:如果传入的ids是空集合,上面的 XML 会拼出什么?答案是:

DELETE FROM t_user WHERE id IN ()

这在 MySQL 里是语法错误,在 Oracle 里也是语法错误。所以处理空集合必须在进入 Mapper 之前完成,或者用<if test="ids != null and ids.size() > 0">包裹住整段 SQL,实在要用动态守卫,我更推荐前者,因为守卫放在 Service 层更直观、更好排查。

再看超长 IN 列表的问题。MySQL 本身对 IN 列表的长度没有硬性限制,但是 SQL 文本太大同样会被max_allowed_packet卡住。Oracle 则直接限制 IN 列表最多 1000 个元素,超了会抛 ORA-01795。应对方案同样是分批,每批 500 到 900 个 id,然后在一个事务里循环执行。

对于 MySQL,还有一个优化点:数据量特别大时(比如一次删几万条),DELETE ... WHERE id IN (...)单条语句的锁范围和回滚段占用可能会超标。这种情况下建议改用分段删除,比如按主键范围分批删除,每批之间加sleep或者限制影响行数,避免主从延迟和锁冲突被放大。

另一个容易被忽略的问题:物理删除的连带数据。删除主表记录之前,要检查是否有子表外键关联或者冗余数据需要同步处理。很多项目里批量删除的真实需求其实是"逻辑删除",只是把deleted字段置为 1。如果是这样,那你要用的不是 DELETE,而是 UPDATE 批量修改,可以直接复用第 3 章的 CASE WHEN 或者 BatchExecutor 方案。

5. 常见问题与排查技巧实录

5.1 高频报错速查表

直接整理一个报错速查表,都是我实际项目中遇到过的真实报错。

报错现象根本原因解决方案
SQL 语法错误 near '('空集合传入,拼成IN ()或VALUES ()Service 层提前判空,或<if>包裹
Packet for query is too large单条 SQL 超过 MySQLmax_allowed_packet调小批次,或调大max_allowed_packet
allowMultiQueries=true缺失foreach 拼接多条分号分隔 UPDATE/DELETE不要用这种写法,改用 CASE WHEN
parameter index out of rangeSQL 中#{}和实际列数不一致,或动态字段缺失检查动态 SQL 拼接,确保参数完整
ORA-01795: maximum number of expressions in a list is 1000Oracle IN 列表超过 1000分批执行,每批不超过 900
事务回滚不生效Spring 事务方法调用同类内部方法,代理失效拆分到不同 Bean 或通过注入自身调用事务方法
useGeneratedKeys 回填主键错乱批量插入时不同版本驱动行为不一致如必须回填,改用 RETURNING 子句或回查

5.2 批量操作下的性能调优经验

当批量操作已经不再是"能不能跑"的问题,而是"跑得够不够快"的问题时,可以从几个方向去压榨性能。

第一,优先考虑减少网络往返。这一点在前面反复提过,foreach 拼接和 BatchExecutor 都是围绕这个思路诞生的。但注意,并不是 SQL 数量越少就一定越快。我做过一个对比:向两个业务表各插入 5 万条数据,用 CASE WHEN 批量更新时单条 SQL 非常大,MySQL 在解析和复制阶段消耗了大量 CPU,最终耗时反而比 BatchExecutor 分批更新多了将近一倍。这个案例充分说明方案必须结合数据量和数据库负载评估。

第二,BatchExecutor要配合rewriteBatchedStatements=true才能发挥最大威力。没有这个参数,JDBC 驱动只是把多条 SQL 累积成批,但一条一条发给 MySQL;开了之后,驱动会尝试将这些 INSERT 重写成一条多值 INSERT。实测开启前后,同样的批量插入,耗时能差 3 到 4 倍。

第三,注意在 Spring 环境下如何使用 BatchExecutor。很多人以为得到注入的普通SqlSessionTemplate之后,在 Mapper 上连续调 N 次insert就是批量了,但普通 SqlSessionTemplate 默认是ExecutorType.SIMPLE,每调一次方法就提交一次,完全谈不上批量。需要单独定义一个批量模式的模板:

@Bean(name = "batchSqlSessionTemplate") public SqlSessionTemplate batchSqlSessionTemplate( @Qualifier("sqlSessionFactory") SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory, ExecutorType.BATCH); }

然后通过@Qualifier("batchSqlSessionTemplate")注入使用。在 BatchExecutor 模式下,MyBatis 会把累积的 SQL 放到BatchExecutor的 statement list 里,只有调用flushStatements()或事务提交时才会真正执行。这个机制既是优势也是风险:如果中间某条语句参数异常,真正执行失败时会有一批语句一起报错,不好定位。

5.3 事务与回滚的正确姿势

批量操作是事务的高发场景,也是最容易因为事务没管理好而出现"插了一半、改了一半、删了一半"这种脏数据的场景。

明确的建议是:真正的批量写操作,一定要在事务边界内整体提交,或者至少具备完整的失败回滚能力。Spring 的@Transactional是最省事的方案:

@Transactional(rollbackFor = Exception.class) public void batchOperate(List<User> inserts, List<User> updates, List<Integer> deletes) { if (inserts != null && !inserts.isEmpty()) { userMapper.batchInsert(inserts); } if (updates != null && !updates.isEmpty()) { userMapper.batchUpdate(updates); } if (deletes != null && !deletes.isEmpty()) { userMapper.batchDelete(deletes); } }

三个批量操作在同一个事务里,任何一个失败都会回滚全部,特别适合"主表、子表同步更新"的场景。

BatchExecutor 模式下的事务要格外注意:如果你在带事务的 Spring 方法里混用普通 SqlSessionTemplate 和 batchSqlSessionTemplate,各自的缓存和 Executor 模式不同,可能引起隐式 flush 的意外行为。我的经验是在一个事务方法里尽量只用一种执行模式,混合使用之前一定要确认清楚 SQL 的执行时机。

最后说一个很多人忽略的细节:批量操作的数据量很大时,哪怕整体在一个事务里,rollback的开销也可能非常大,原因在于 InnoDB 需要回滚大量行级变更。所以大事务不是越大连得越久就越好,超过一定量级后,应该按业务拆分成多个小事务,每批单独提交。例如"从 Excel 导入百万级数据",正确做法就是 500 条一个小事务,成功一批提交一批,并且打印进度日志,这样既能控制回滚成本,也方便中途断点重试。

我在实际项目中踩过的坑主要有两个。一个是早期做批量插入时,以为useGeneratedKeys能自动给所有对象回填主键,结果列表里每个对象的 id 都变成了同一条,后来排查半天才发现是驱动版本的行为差异。另一个是给 MySQL 连接串加了rewriteBatchedStatements=true,但对一个较老版本的驱动来说,这个参数反而导致批量 UPDATE 的批量语义失效,最终花了很大精力验证驱动版本和参数组合。所以建议你做批量优化之前,先把实际环境里的数据库版本、驱动版本、MyBatis 版本都确认清楚,再上生产,别只看网上那一份省事的配置抄过来直接开跑。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 18:28:23

POA优化GRU超参数:时间序列预测自动调参全解析

我直接用博主写项目的口吻写&#xff0c;不铺垫任何创作说明&#xff0c;单刀直入。以下是正文。先说结论&#xff1a;这个项目做下来&#xff0c;最值钱的不是“POA比GRU好”这个结论本身&#xff0c;而是整套“优化器时序模型”的调试路径。鹈鹕优化算法&#xff08;Pelican …

作者头像 李华
网站建设 2026/10/14 18:28:22

正点原子STM32精英板驱动AD9850 DDS模块:从接线到扫频的完整实战

简介&#xff1a;这份资源面向嵌入式开发初学者与电子爱好者&#xff0c;提供基于正点原子STM32精英板驱动AD9850 DDS芯片的完整工程&#xff0c;用于实现可输出正弦波、方波、锯齿波等常见波形的任意波形发生器。压缩包共200个文件&#xff0c;约5.34MB&#xff0c;以c与h源码…

作者头像 李华
网站建设 2026/10/14 18:27:42

OpenClaw实战:让AI自动操作企业微信与钉钉的桌面自动化方案

前段时间处理一个本地 Agent 项目时&#xff0c;有朋友跑过来问&#xff1a;能不能让 OpenClaw 自己打开企业微信&#xff0c;给指定的人发消息&#xff1f;或者说钉钉里挂在待办里的审批&#xff0c;能不能让它每天定时帮我点掉&#xff1f;当时听上去像是个伪需求&#xff0c…

作者头像 李华
网站建设 2026/10/14 18:27:26

graphics.h在现代C/C++环境下的配置与移植实战

简介&#xff1a;为 Visual C 6.0 及兼容环境准备的 graphics.h 头文件与 Graphics.lib 静态库资源包&#xff0c;主要面向需要在 C/C 控制台工程中调用绘图函数&#xff08;如画线、画圆、区域填充&#xff09;的开发者。压缩包内共 4 个文件&#xff0c;包含 Graphics.h、Gra…

作者头像 李华
网站建设 2026/10/14 18:26:55

shell 脚本知识点总结

Shell 脚本知识点总结适用&#xff1a;Linux bash&#xff0c;运维面试、写自动化脚本、日志处理、批量操作一、基础概念脚本开头#!/bin/bash # 告诉系统用bash解释执行&#xff0c;必须写在第一行 # 注释&#xff1a;# 单行注释&#xff1b;没有原生多行注释&#xff0c;可使用…

作者头像 李华
网站建设 2026/10/14 18:25:52

MySQL JSON_EXTRACT完全指南:路径语法、索引优化与实战场景

1. 项目概述与使用动机1.1 为什么偏偏是 JSON_EXTRACT我最初接触到JSON_EXTRACT这个函数&#xff0c;是在处理一张业务埋点表的时候。当时的需求说起来也不复杂&#xff0c;运营同事想要从一列名为extra_info的 JSON 字符串里&#xff0c;把用户的下单渠道、广告来源、页面停留…

作者头像 李华