批量update实现方案全面解析与最佳实践,带你掌握到底怎么批量更新最快、性能最高

1.概述

在当今应用开发中,数据操作是底层基础,批量更新是实际开发中一个常见的操作,同时也是一个性能瓶颈点。有多种批量更新的实现方式,但不同的方案在性能、可维护性和数据库兼容性等方面差异显著。本文将基于MyBatis全面剖析各种批量更新方案的实现原理、性能表现和适用场景,帮助开发者做出合理的技术选型,从而实现性能最高的更新。

2.准备工作

这里我们还是以用户表tb_user为示例,并且基于上面总结快速插入了500多万条数据:

复制
CREATE TABLE `tb_user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键, `user_no` varchar(255) NOT NULL COMMENT 编号, `name` varchar(255) DEFAULT NULL COMMENT 昵称, `email` varchar(255) DEFAULT NULL COMMENT 邮箱, `phone` varchar(255) NOT NULL COMMENT 手机号, `gender` tinyint(4) NOT NULL DEFAULT 0 COMMENT 性别 0:男生 1:女生, `birthday` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 出生日期, `is_delete` tinyint(4) NOT NULL DEFAULT 0 COMMENT 删除标志 0:否 1:是, `create_time` datetime DEFAULT NULL COMMENT 创建时间, `update_time` datetime DEFAULT NULL COMMENT 更新时间, `creator` bigint(20) DEFAULT NULL COMMENT 创建人, `updater` bigint(20) DEFAULT NULL COMMENT 更新人, `address` varchar(1024) DEFAULT NULL COMMENT 地址, `role_id` varchar(100) DEFAULT NULL COMMENT 角色id, `hobby` varchar(255) DEFAULT NULL COMMENT 爱好, `remark` varchar(255) DEFAULT NULL COMMENT 个人说明, `org_id` bigint(20) NOT NULL COMMENT 公司id, PRIMARY KEY (`id`) USING BTREE, UNIQUE KEY `uk_user_no` (`user_no`) USING BTREE ) ENGINE=InnoDB AUTO_INCREMENT=5201026 DEFAULT CHARSET=utf8mb4 ROW_FORMAT=DYNAMIC;1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.

当同样更新100条数据时,小表(几千条)和大表(几百万条)使用相同的批量更新方式,执行效率会有差异,差异程度取决于多个因素

效率不会完全相同,但差异可能不明显,主要因为:

数据定位成本:大表可能需要更多I/O来定位记录索引结构差异:大表的索引层级可能更深内存缓存影响:小表更可能完全缓存在内存中

所以我这里为了更能突出区别不同批量更新方案的执行效率,选择了对大表进行批量更新10000条数据来示例。当然了执行效率还与MySQL服务的配置有关,配置2核2G和4核8G肯定是不一样的。

3.批量更新实现方案

这里我先查出10000条数据,更新user的name,gender,address等字段

复制
public List<User> listUsers() { LambdaQueryWrapper<User> queryWrapper = new LambdaQueryWrapper<>(); queryWrapper.select(User::getId, User::getName); queryWrapper.ge(User::getId, 10000L).lt(User::getId, 20000L); List<User> users = userDAO.selectList(queryWrapper); users.forEach(user -> { user.setName(user.getName() + "1"); user.setAddress("杭州" + user.getId()); user.setGender(user.getId() % 2 == 0 ? 1 : 0); user.setUpdateTime(new Date()); }); return users; }1.2.3.4.5.6.7.8.9.10.11.12.13.

3.1 循环单条更新

这种方式最简单,直接看代码:

复制
@Test public void testBatchUpdateByFor() { List<User> users = listUsers(); long start = System.currentTimeMillis(); users.forEach(user -> { userDAO.updateById(user); }); long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.9.10.

执行SQL部分如下:

复制
c.p.b.e.mybatis.dao.UserDAO.updateById : ==> Preparing: UPDATE tb_user SET gender=?, address=?, name=?, update_time=?, updater=? WHERE id=? c.p.b.e.mybatis.dao.UserDAO.updateById : ==> Parameters: 1(Integer), 杭州19998(String), 罗百夜1(String), 2025-07-08 11:08:23.588(Timestamp), null, 19998(Long) c.p.b.e.mybatis.dao.UserDAO.updateById : <== Updates: 1 c.p.b.e.mybatis.dao.UserDAO.updateById : ==> Preparing: UPDATE tb_user SET gender=?, address=?, name=?, update_time=?, updater=? WHERE id=? c.p.b.e.mybatis.dao.UserDAO.updateById : ==> Parameters: 0(Integer), 杭州19999(String), 张七土1(String), 2025-07-08 11:08:23.588(Timestamp), null, 19999(Long) c.p.b.e.mybatis.dao.UserDAO.updateById : <== Updates: 11.2.3.4.5.6.

可以看出是一条一条提交执行的。

执行时长:3846ms

这种方式产生N条独立SQL语句,网络IO次数与数据量成正比,性能很差,在平时开发中几乎不能使用,当然如果是操作小表小批量数据,也问题不大,但最好别这么写,显得代码水平不行,同时这种方式也是代码性能提升方式经常提到一大问题点:for循环里面单条操作SQL语句,这种方式写了就有性能问题~~~

3.2 foreach多条SQL

这种方式需要通过XML写SQL语句实现

复制
public interface UserDAO extends BaseMapperX<User> { int batchUpdateByForeach(@Param("userList") List<User> userList); }1.2.3.

XML配置如下:

复制
<update id="batchUpdateByForeach"> <foreach collection="userList" item="u" separator=";"> UPDATE tb_user SET update_time = now() <if test="u.name != null"> ,name = #{u.name} </if> <if test="u.address != null"> ,address = #{u.address} </if> <if test="u.gender != null"> ,gender = #{u.gender} </if> WHERE id = #{u.id} </foreach> </update>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.

测试代码:

复制
@Test public void testBatchUpdateByForeach() { List<User> users = listUsers(); long start = System.currentTimeMillis(); // 分批处理 List<List<User>> splitList = CollUtil.split(users, 500); splitList.forEach(userList -> { userDAO.batchUpdateByForeach(userList); }); long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.9.10.11.12.

这里我只给出了3条数据的更新SQL,500条全给出来太多了。

复制
c.p.b.e.m.d.U.batchUpdateByForeach : ==> Preparing: UPDATE tb_user SET update_time = now() ,name = ? ,address = ? ,gender = ? WHERE id = ? ; UPDATE tb_user SET update_time = now() ,name = ? ,address = ? ,gender = ? WHERE id = ? ; UPDATE tb_user SET update_time = now() ,name = ? ,address = ? ,gender = ? WHERE id = ? ; c.p.b.e.m.d.U.batchUpdateByForeach : ==> Parameters: 王十金1111(String), 杭州13000(String), 1(Integer), 13000(Long), 杨一月1111(String), 杭州13001(String), 0(Integer), 13001(Long), 周六云1111(String), 杭州13002(String), 1(Integer), 13002(Long) 2025-07-08T13:55:41.618+08:00 DEBUG 53878 --- [plasticene-boot-mybatis-example] [ main] c.p.b.e.m.d.U.batchUpdateByForeach : <== Updates: 11.2.3.

可以看出是单次请求包含多条SQL语句,但本质上每条数据都是单独执行更新的

执行时长:1417ms

3.3 CASE WHEN表达式

直接看XML配置里面写的SQL语句:

复制
<update id="batchUpdateByCaseWhen"> UPDATE tb_user SET update_time=now(), name = CASE <foreach collection="userList" item="item"> WHEN id = #{item.id} AND #{item.name} IS NOT NULL THEN #{item.name} </foreach> ELSE name END, address = CASE <foreach collection="userList" item="item"> WHEN id = #{item.id} AND #{item.address} IS NOT NULL THEN #{item.address} </foreach> ELSE address END, gender = CASE <foreach collection="userList" item="item"> WHEN id = #{item.id} AND #{item.gender} IS NOT NULL THEN #{item.gender} </foreach> ELSE gender END WHERE id IN <foreach collection="userList" item="item" open="(" separator="," close=")"> #{item.id} </foreach> </update>1.2.3.4.5.6.7.8.9.10.11.12.13.14.15.16.17.18.19.20.21.22.23.24.25.26.27.

测试代码:

复制
@Test public void testBatchUpdateByCaseWhen() { List<User> users = listUsers(); long start = System.currentTimeMillis(); // 分批处理 List<List<User>> splitList = CollUtil.split(users, 500); for (List<User> userList : splitList) { userDAO.batchUpdateByCaseWhen(userList); } long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.9.10.11.12.

这里就不给出控制台的输出的SQL语句了,太长了,大家自行执行查看

执行时长:988ms

真正的单SQL批量操作,性能很好,但要注意防止SQL语句长度超过限制。

3.4 ON DUPLICATE KEY UPDATE

ON DUPLICATE KEY UPDATE是MySQL特有语法,批量插入,遇到主键/唯一键冲突时转为更新。

复制
<insert id="batchUpdateOnDuplicate"> INSERT INTO tb_user(user_no, name, phone, address, gender, org_id) VALUES <foreach collection="userList" item="item" separator=","> (#{item.userNo}, #{item.name}, #{item.phone}, #{item.address}, #{item.gender}, #{item.orgId}) </foreach> ON DUPLICATE KEY UPDATE name=VALUES(name), org_id=VALUES(org_id) </insert>1.2.3.4.5.6.7.8.

测试代码:

复制
@Test public void testBatchUpdateOnDuplicate() { List<User> users = listUsers(); long start = System.currentTimeMillis(); // 分批处理 List<List<User>> splitList = CollUtil.split(users, 500); for (List<User> userList : splitList) { userDAO.batchUpdateOnDuplicate(userList); } long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.9.10.11.12.

执行时长:1080ms

3.5 REPLACE INTO

replace into与on duplicate key update在一定程度上都能实现无记录时插入,有记录时更新。其判断都是根据主键/唯一键是否存在,但是replace into实现更新的方式是先删除在插入,这就会产生两个binlog,可能导致消费binlog出问题,同时这种更新如果是唯一键冲突,那么先删后插会导致主键变了,如果之前的主键id有在其他表关联使用,这种更新是很危险的。

复制
<insert id="batchUpdateReplace"> REPLACE INTO tb_user(user_no, name, phone, address, gender, org_id) VALUES <foreach collection="userList" item="item" separator=","> (#{item.userNo}, #{item.name}, #{item.phone}, #{item.address}, #{item.gender}, #{item.orgId}) </foreach> </insert>1.2.3.4.5.6.

测试代码:

复制
@Test public void testBatchUpdateReplace() { List<User> users = listUsers(); long start = System.currentTimeMillis(); // 分批处理 List<List<User>> splitList = CollUtil.split(users, 500); for (List<User> userList : splitList) { userDAO.batchUpdateReplace(userList); } long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.9.10.11.12.

执行时长:6705ms

3.6 通过MyBatis-Plus批量更新

直接看代码:

复制
@Test public void testBatchUpdateByMybatisPlus() { List<User> users = listUsers(); long start = System.currentTimeMillis(); userDAO.updateById(users, 500); long end = System.currentTimeMillis(); System.out.println("执行时长:" + (end - start) + "ms"); }1.2.3.4.5.6.7.8.

执行时长:1730ms

4.性能对比表格

方案

1万条耗时

网络IO次数

SQL解析次数

适用数据量

数据库兼容性

for循环单条更新

3.-4.s

N

N

<100

全兼容

foreach多条SQL

1-2s

1

N

100-5000

需配置

mybaits-plus

1-2s

1

1

100-5000

全兼容

CASE WHEN

0.5-1s

1

1

>1000

全兼容

ON DUPLICATE KEY UPDATE

0.5-1s

1

1

>1000

MySQL only

replace into

4-7s

1

N

100-3000

全兼容

除了for循环单条更新不推荐之外,其他方式我个人感觉都可以选择,可以根据具体场景选择具体方式。追求极致性能首选case when

如果存在做更新,没有就插入实现方案首选ON DUPLICATE KEY UPDATE,因为replace into操作可能存在问题,具体看上面叙述,当然了MyBatis-Plus提供了saveOrUpdateBatch可以操作小批量数据,因为它底层是for循环单条操作实现的,比较慢。

5.总结

批量更新方案的选择需要综合考虑数据库类型、数据量大小、系统架构要求和团队技术栈等因素。对于大多数MySQL应用场景,ON DUPLICATE KEY UPDATE方案提供了最佳的性能和可维护性平衡。而在需要多数据库支持的场景中,CASE WHEN表达式则是更为通用的选择。无论采用哪种方案,都应该结合分批次处理、连接参数优化和适当的监控手段,才能在实际生产环境中获得理想的性能表现。

阅读剩余
THE END