1.概述
在当今应用开发中,数据操作是底层基础,批量更新是实际开发中一个常见的操作,同时也是一个性能瓶颈点。有多种批量更新的实现方式,但不同的方案在性能、可维护性和数据库兼容性等方面差异显著。本文将基于MyBatis全面剖析各种批量更新方案的实现原理、性能表现和适用场景,帮助开发者做出合理的技术选型,从而实现性能最高的更新。
2.准备工作
之前我总结分享过关于批量插入的知识点:最近做百万级数据性能压测,来看看人家如何使用MyBatis 优雅批量插入数据,从80s优化到1s!!!可以让我们快速插入大批量数据
也总结了SQL语句的执行流程:一条SQL语句到底是怎么执行的?可以看看更新语句是怎么执行的。
这里我们还是以用户表tb_user
为示例,并且基于上面总结快速插入了500多万条数据:
sql
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;
当同样更新100条数据时,小表(几千条)和大表(几百万条)使用相同的批量更新方式,执行效率会有差异,差异程度取决于多个因素
效率不会完全相同,但差异可能不明显,主要因为:
- 数据定位成本:大表可能需要更多I/O来定位记录
- 索引结构差异:大表的索引层级可能更深
- 内存缓存影响:小表更可能完全缓存在内存中
所以我这里为了更能突出区别不同批量更新方案的执行效率,选择了对大表进行批量更新10000条数据来示例。当然了执行效率还与MySQL服务的配置有关,配置2核2G和4核8G肯定是不一样的。
3.批量更新实现方案
这里我先查出10000条数据,更新user的name,gender,address等字段
ini
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;
}
3.1 循环单条更新
这种方式最简单,直接看代码:
ini
@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");
}
执行SQL部分如下:
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: 1
可以看出是一条一条提交执行的。
执行时长:3846ms
这种方式产生N条独立SQL语句,网络IO次数与数据量成正比,性能很差,在平时开发中几乎不能使用,当然如果是操作小表小批量数据,也问题不大,但最好别这么写,显得代码水平不行,同时这种方式也是代码性能提升方式经常提到一大问题点:for循环里面单条操作SQL语句,这种方式写了就有性能问题~~~
3.2 foreach多条SQL
这种方式需要通过XML写SQL语句实现
less
public interface UserDAO extends BaseMapperX<User> {
int batchUpdateByForeach(@Param("userList") List<User> userList);
}
XML配置如下:
ini
<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>
测试代码:
ini
@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");
}
这里我只给出了3条数据的更新SQL,500条全给出来太多了。
vbnet
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: 1
可以看出是单次请求包含多条SQL语句,但本质上每条数据都是单独执行更新的
执行时长:1417ms
3.3 CASE WHEN表达式
直接看XML配置里面写的SQL语句:
ini
<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>
测试代码:
ini
@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");
}
这里就不给出控制台的输出的SQL语句了,太长了,大家自行执行查看
执行时长:988ms
真正的单SQL批量操作,性能很好,但要注意防止SQL语句长度超过限制。
3.4 ON DUPLICATE KEY UPDATE
ON DUPLICATE KEY UPDATE
是MySQL特有语法,批量插入,遇到主键/唯一键冲突时转为更新。
sql
<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>
测试代码:
ini
@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");
}
执行时长:1080ms
3.5 REPLACE INTO
replace into
与on duplicate key update
在一定程度上都能实现无记录时插入,有记录时更新。其判断都是根据主键/唯一键是否存在,但是replace into
实现更新的方式是先删除在插入,这就会产生两个binlog,可能导致消费binlog出问题,同时这种更新如果是唯一键冲突,那么先删后插会导致主键变了,如果之前的主键id有在其他表关联使用,这种更新是很危险的。
ini
<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>
测试代码:
ini
@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");
}
执行时长:6705ms
3.6 通过MyBatis-Plus批量更新
直接看代码:
ini
@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");
}
执行时长: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 | 1 | 100-3000 | 全兼容 |
除了for循环单条更新不推荐之外,其他方式我个人感觉都可以选择,可以根据具体场景选择具体方式。追求极致性能首选case when
如果存在做更新,没有就插入实现方案首选ON DUPLICATE KEY UPDATE
,因为replace into
操作可能存在问题,具体看上面叙述,当然了MyBatis-Plus提供了saveOrUpdateBatch
可以操作小批量数据,因为它底层是for循环单条操作实现的,比较慢。
5.总结
批量更新方案的选择需要综合考虑数据库类型、数据量大小、系统架构要求和团队技术栈等因素。对于大多数MySQL应用场景,ON DUPLICATE KEY UPDATE
方案提供了最佳的性能和可维护性平衡。而在需要多数据库支持的场景中,CASE WHEN
表达式则是更为通用的选择。无论采用哪种方案,都应该结合分批次处理、连接参数优化和适当的监控手段,才能在实际生产环境中获得理想的性能表现。