上周在做一个经销商数据导入的需求时,用户那边给了一张两万多行的Excel,里面没有主键id,唯一能确定身份的是手机号。业务逻辑很简单:库里已有这个手机号就更新,没有就新增。我第一反应是直接用mybatis-plus的saveOrUpdateBatch,结果批量跑完,库里塞了一堆重复手机号,当场被同事问得哑口无言。
翻车的根子其实一句话就能说清:saveOrUpdateBatch并不按业务字段去判断记录是否存在,它只认主键id。这个坑很隐蔽,因为单条数据少的时候它表现正常,一旦数据量上来、业务字段有了重复值,问题就会集中爆发。这篇文章就把这个事情的来龙去脉、可落地的替代方案、以及我怎么封装出一个“按任意字段saveOrUpdateBatch”的完整过程写出来。
1. saveOrUpdateBatch只认主键,这不是玄学是源码逻辑
1.1 方法内部到底做了什么
如果你只是把MyBatis-Plus当成工具包用,很少去翻它的源码,那对saveOrUpdateBatch的理解大概率停留在“自动判断存在就更新、不存在就插入”这种模糊印象上。我建议你花五分钟看一下ServiceImpl里这段逻辑,它真的没有那么智能。
核心动作是逐条遍历集合,每一条调一次saveOrUpdate,而saveOrUpdate的判断依据只有一个:实体主键id是否为空。如果id不为空,它先尝试updateById,影响行数为0再执行insert;如果id为空,直接insert。换句话说,它判断“存在或不存在”,用的不是手机号、订单号、身份证号这些业务唯一键,而是数据库主键。
问题就在这里来了。我的导入Excel里根本没有id这一列,实体类的id是null,于是所有记录全部走了insert分支,重复手机号自然就攒出来了。这个行为不是bug,是设计如此,只是它对“业务字段唯一性”这件事完全不关心。
1.2 哪些场景最容易踩坑
和这个坑打过照面的场景其实很固定,做后端几年基本都会遇到:
- 数据导入同步:Excel、CSV或者第三方接口批量推送数据,记录里只有业务单号,没有主键id。
- 定时任务拉取外部数据:每次全量拉回后做增量更新,按某个唯一编码判断是新增还是修改。
- 开放平台回调消息:第三方回调的通知场景,需要按流水号幂等处理。
这几个场景的共同特征,就是数据源在外部,主键id根本不在我们手里。这时候仍依赖id去调MyBatis-Plus内置方法,结果必然要么报唯一索引冲突,要么产生大量重复数据。所以严格来说,标题里写的“按任意字段saveOrUpdateBatch”,并不是MyBatis-Plus开箱即用的能力,而是需要我们自己动手封装的东西。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 按任意字段“存或改”前,先把三条实现路径想清楚
动手写代码之前,我习惯先把选项摆出来,因为不同方案的取舍差别很大,一旦代码写了一半再换,成本会更高。
2.1 路径A:循环查询后再逐个插入或更新
这是最直白的写法,也是很多人第一版会实现的方案。遍历集合,用业务字段去查一遍库,查得到就updateById,查不到就insert。
java复制for (User user : userList) {
User exist = userMapper.selectOne(new LambdaQueryWrapper<User>()
.eq(User::getPhone, user.getPhone()));
if (exist != null) {
user.setId(exist.getId());
userMapper.updateById(user);
} else {
userMapper.insert(user);
}
}
这段代码在数据量小的时候没问题,两三条、几十条都能跑。但一旦到了上千条,性能会非常难看,因为每条记录都要先select一次,再update或insert一次,数据库网络往返次数是数据量的两倍。而且它还有一个并发隐患:两个请求同时查到不存在,然后同时insert,照样会触发唯一索引冲突。
这个方案的优点是逻辑简单、不依赖数据库方言,任何关系型数据库都能跑。适合数据量长期保持在两位数以下的内部小工具场景。
2.2 路径B:用update(wrapper)先更新,更新不到再插入
比路径A聪明一点的做法,是先尝试用业务字段去更新,影响行数为0时再插入。这样把一次select省掉了,网络往返次数减半。
java复制for (User user : userList) {
boolean updated = userMapper.update(user, new LambdaUpdateWrapper<User>()
.eq(User::getPhone, user.getPhone())) > 0;
if (!updated) {
userMapper.insert(user);
}
}
这个写法的好处是代码比路径A更短,也不需要先查库取主键。但它仍然是一条条发送SQL,性能只有量级上的改善,本质没有变。并发场景下,两个线程同时走到“更新不到”,然后一起insert,一样会撞唯一索引。所以这个方案适合中等数据量(几百条以内)、并发要求不高的场景。
2.3 路径C:数据库原生upsert,把判断交给唯一索引
第三个方案是让数据库来做“存在还是不存在”的判断,也就是利用INSERT ... ON DUPLICATE KEY UPDATE这条原生语法。它依赖于唯一索引,索引冲突时自动转为更新,索引没冲突时正常插入。
这个方案最大的优势是真正意义上的批量:一条SQL可以带几百上千行values,数据库一次性处理完。MySQL对于这些记录的判断是在引擎内部完成的,不存在应用层逐条网络往返。如果业务唯一键涉及组合字段,比如手机号+状态,那就建一个组合唯一索引,语法上完全不需要改,判断逻辑依然成立。
它的代价也很明显:SQL方言和特定数据库绑定。换到PostgreSQL要改成ON CONFLICT,Oracle要用MERGE INTO,不能像前两个方案那样一套代码跑遍所有数据库。但考虑到大多数项目长期钉在一种数据库上,这个代价完全可以接受。
2.4 路径对比表
| 维度 | 路径A 循环select+insert/update | 路径B update(wrapper)+insert | 路径C 数据库原生upsert |
|---|---|---|---|
| 实现复杂度 | 最低 | 低 | 中等 |
| 数据库依赖 | 无 | 无 | MySQL/PG/Oracle 各自语法 |
| 单条数据表现 | 可以 | 可以 | 可以 |
| 千条以上性能 | 很差 | 较差 | 优 |
| 并发安全性 | 有唯一索引冲突风险 | 有唯一索引冲突风险 | 依赖唯一索引,天然安全 |
| 代码维护性 | 逻辑直白,但循环体多 | 循环体简化 | 关注XML即可 |
对我个人来说,只要数据库是MySQL,批量场景我根本不会考虑前两个方案。路径C的性能优势太大了,而且语义清晰。如果你的场景是一次性小批量、又不想碰XML,那路径B是快速止血的办法。
3. MySQL原生批量upsert在MyBatis-Plus里的落地封装
3.1 唯一索引是一切的前提
很多人在网上看完“insert on duplicate key update”的语法后,直接复制到项目里跑,结果发现重复数据照样插入,没有任何更新效果。原因几乎都是同一个:表上根本没建唯一索引。
这条SQL本身的判断逻辑是“遇到唯一索引冲突才走更新分支”,如果业务字段上没有任何唯一约束,数据库根本不知道什么算冲突,那它就只会傻傻地把所有行都插进去。
所以“按任意字段saveOrUpdateBatch”的第一步不是写代码,而是先确认字段上有唯一索引:
sql复制ALTER TABLE t_user ADD UNIQUE KEY uk_phone (phone);
如果业务上是组合唯一键,比如同一个手机号可以存在多条记录,但同一手机号+同一业务状态只能有一条,那就建组合索引:
sql复制ALTER TABLE t_user ADD UNIQUE KEY uk_phone_status (phone, status);
索引建好之后,代码里指定的判断字段必须与索引字段完全对齐。这一步做完,后面的SQL才有意义。
3.2 Mapper XML核心实现
接下来我们要在UserMapper里加一个自定义方法,SQL走批量upsert。这里以phone作为业务唯一键举例。
先在Mapper接口添加方法:
java复制public interface UserMapper extends BaseMapper<User> {
int upsertBatchByPhone(@Param("list") List<User> list);
}
然后在UserMapper.xml里写实现:
xml复制<insert id="upsertBatchByPhone">
INSERT INTO t_user (
name, phone, age, create_time, update_time
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.name},
#{item.phone},
#{item.age},
NOW(),
NOW()
)
</foreach>
AS new
ON DUPLICATE KEY UPDATE
name = new.name,
age = new.age,
update_time = NOW()
</insert>
这里有个版本细节要提醒。AS new这种别名写法是MySQL 8.0.20以后官方推荐的,目的是替代已经废弃的VALUES()函数。如果你的项目数据库是MySQL 5.7,或者用的是MariaDB老版本,这段SQL需要改回传统写法:
xml复制<insert id="upsertBatchByPhone">
INSERT INTO t_user (
name, phone, age, create_time, update_time
) VALUES
<foreach collection="list" item="item" separator=",">
(
#{item.name},
#{item.phone},
#{item.age},
NOW(),
NOW()
)
</foreach>
ON DUPLICATE KEY UPDATE
name = VALUES(name),
age = VALUES(age),
update_time = NOW()
</insert>
两种写法我都列出来,是因为很多公司线上还在用5.7,直接用新写法会编译报错。而如果已经上了8.0.20以上版本,继续用VALUES()虽然能跑,但每次执行都会在日志里刷deprecated警告,观感很不好。建议先确认线上版本,再选择对应写法。
3.3 Service层封装与调用
XML里的方法返回的是受影响行数。这里有个MySQL的特性需要知道:在ON DUPLICATE KEY UPDATE场景下,插入一条记录影响行数是1,更新一条记录影响行数是2,记录没有任何变化则影响行数是0。所以如果批量插入了800条、更新了200条,返回值大约会是1200。
在Service层封装时,不需要太纠结这个具体数值,把它当“本次操作影响了多少行”即可。
java复制@Service
public class UserServiceImpl extends ServiceImpl<UserMapper, User> implements UserService {
@Override
@Transactional(rollbackFor = Exception.class)
public boolean upsertBatchByPhone(List<User> list) {
if (CollUtil.isEmpty(list)) {
return false;
}
return baseMapper.upsertBatchByPhone(list) > 0;
}
}
调用方式就和平时的saveOrUpdateBatch几乎一样:
java复制List<User> userList = getImportDataFromExcel();
userService.upsertBatchByPhone(userList);
这样从调用方视角看,它已经做到“按手机号批量保存或更新”,和标题所述的语义完全一致。唯一需要说明的是,接口名可以按实际规则命名,不要拘泥于upsertBatchByPhone,如果你的统一字段是订单号,那就叫upsertBatchByOrderNo,更好读。
3.4 如果“任意字段”是组合唯一键或非固定条件
可能有人会问:我的业务唯一键不固定,今天按手机号,明天按身份证号,后面又要按手机号+来源渠道组合,每次都写一个Mapper方法是不是太蠢了?
确实有办法做得通用一点,但代价是牺牲一部分安全性,要谨慎。通用写法是把表名、插入列、更新列全部动态拼接:
xml复制<insert id="upsertBatchByColumns">
INSERT INTO ${tableName} (${columns})
VALUES
<foreach collection="list" item="item" separator=",">
<foreach collection="columns" item="col" open="(" separator="," close=")">
#{item[${col}]}
</foreach>
</foreach>
ON DUPLICATE KEY UPDATE
<foreach collection="updateColumns" item="col" separator=",">
${col} = VALUES(${col})
</foreach>
</insert>
注意,这里的${tableName}、${columns}都是直接拼SQL,如果这些参数来自用户输入,就是SQL注入的入口。我的建议是:这类通用方法只允许内部代码调用,列为白名单控制,前端传什么一律不拼。如果你不确定自己能控制好这个边界,宁可多写几个固定方法,也别图一时省事。
从维护角度来说,我其实更推荐“一个唯一字段对应一个专用方法”的做法。大部分项目的核心业务唯一键就那么一两个,写死了反而清楚,排查问题也快。
4. 性能实测:单条循环和批量upsert差了一个数量级
4.1 测试环境与数据准备
说再多原理,不如实际跑一组数据。我在本地测试环境做了对比,环境是MySQL 8.0、Spring Boot 2.7、MyBatis-Plus 3.5.2,表结构就是t_user,10个字段,phone字段有唯一索引。测试数据预先构造好,一部分是库里已存在的记录,一部分是全新记录,保证upsert时既有插入也有更新。
对比的三段代码分别是:
- 路径A:循环selectOne + updateById / insert
- 路径B:循环update(wrapper) + insert
- 路径C:自定义Mapper批量upsert
4.2 实测数据
| 数据量 | 路径A耗时 | 路径B耗时 | 路径C耗时 |
|---|---|---|---|
| 100条 | 约200ms | 约130ms | 约30ms |
| 1000条 | 约2300ms | 约1500ms | 约90ms |
| 5000条 | 约11000ms | 约7200ms | 约350ms |
| 10000条 | 约23000ms | 约14000ms | 约650ms |
这个差距在10000条时已经拉得很明显了。路径A在万级数据下跑了二十多秒,路径B用了14秒,而批量upsert只要650毫秒左右,接近20倍的差距。原因也很直白:路径A要发两万条SQL,路径B要发一万条SQL,而路径C从头到尾就是一条大SQL,数据库在引擎内部完成批量判断和写入。
需要说明的是,这个数据是在我本机单表、无其他负载的环境下测出来的,绝对数值只供参考,不同机器差异会很大。但性能差的量级是稳定的,批量SQL在网络往返和SQL解析上的优势,不会因为换台机器就消失。
4.3 分批策略与大SQL限制
批量upsert也不是越大越好。一次插入1万行,整个SQL文本可能有几百KB甚至上MB,会碰到MySQL的max_allowed_packet限制。如果SQL文本超过这个上限,MySQL会直接报错断开连接。
我的做法是每批500条循环提交:
java复制List<List<User>> batchList = CollUtil.split(userList, 500);
for (List<User> batch : batchList) {
userService.upsertBatchByPhone(batch);
}
分批还有另一个好处:单条SQL执行时间更短,不会长时间持有表锁或行锁。如果某一批失败,事务回滚的范围也小得多,不会因为一条数据异常导致整个万级导入全部回滚。
5. 实战踩坑记录:从若依+lombok到多模块的细节
5.1 逻辑删除字段会让唯一索引失效
用MyBatis-Plus开发的项目,有相当一部分会开启逻辑删除功能,t_user表里通常有个deleted字段。这时候按业务字段建唯一索引会碰到一个很尴尬的问题:如果某条记录被逻辑删除了,它仍然占据着phone的唯一索引位置。下次同一个手机号的新用户来导入,数据库判断phone冲突,跑到ON DUPLICATE KEY UPDATE分支,结果把那条已经逻辑删除的老记录给“更新活”了,新用户根本插不进去。
这个问题在PostgreSQL里可以用“部分唯一索引”解决,但MySQL不支持partial index。我在实战中用的办法是加一个生成列,用deleted字段控制唯一性是否生效:
sql复制ALTER TABLE t_user
ADD COLUMN active_phone VARCHAR(32)
GENERATED ALWAYS AS (IF(deleted = 0, phone, NULL)) STORED;
ALTER TABLE t_user
ADD UNIQUE KEY uk_active_phone (active_phone);
逻辑删除时deleted被置为1,active_phone生成NULL,而MySQL允许同一列下有多个NULL值,不会互相冲突。未删除的记录,active_phone存的就是真实手机号,继续保持唯一约束。upsert时把判断字段从phone改成active_phone,这个方案就能绕过逻辑删除带来的唯一索引死结。
5.2 MyBatis-Plus自动填充在自定义SQL里不会触发
如果你用了@TableField(fill = FieldFill.INSERT)和@TableField(fill = FieldFill.INSERT_UPDATE)来自动填充createTime和updateTime,那么自定义XML里的insert要注意:这个填充逻辑只对MyBatis-Plus内置的insert和updateById等方法有效,我自己手写的INSERT INTO ... VALUES不会经过那个拦截器,填充不会生效。
解决方案有两种。一种是在SQL里直接用NOW()写死时间,像我在前面的XML示例里那样;另一种是在Java层先手动set好时间字段,再传入Mapper:
java复制user.setCreateTime(LocalDateTime.now());
user.setUpdateTime(LocalDateTime.now());
我倾向于用第一种,少一行Java代码,而且所有数据的时间由数据库统一生成,不会出现应用服务器时钟不一致的问题。
5.3 lombok和实体类字段的坑
RuoYi相关项目里实体类基本都用了Lombok的@Data,整体没啥问题,但有几个细节容易在upsert时暴露出来。
一个是字段类型不要用基本类型boolean声明逻辑删除字段。MyBatis-Plus的@TableLogic配合Lombok,如果字段是boolean deleted,正常情况没问题,但一旦字段名是isDeleted这种is开头的形式,Lombok生成的是getIsDeleted(),MyBatis-Plus在做属性映射时可能会找不到对应的setter,运行期直接反射异常。所以实体类字段名建议老老实实叫deleted,数据库列名如果叫is_deleted,用@TableField("is_deleted")去映射。
另一个是@Builder和MyBatis-Plus的兼容问题。有些同事喜欢给实体类加@Builder,同时用了无参构造被覆盖掉的写法,MyBatis-Plus通过反射创建对象时找不到无参构造器,会报java.lang.NoSuchMethodException。解决办法是在实体类上同时加@NoArgsConstructor和@AllArgsConstructor,别只加一个。
5.4 多模块下Mapper XML扫描配置
多模块Maven工程下,自定义Mapper XML放在非启动类所在模块是常见操作。如果你的mapper目录在user-module里,而启动类在admin-module里,只在application.yml写mybatis-plus.mapper-locations: classpath:mapper/**/*.xml是不够的,因为默认只扫当前模块的classpath。
我需要把配置改成classpath*:前缀,让它扫描所有依赖模块的classpath:
yaml复制mybatis-plus:
mapper-locations: classpath*:mapper/**/*.xml
type-aliases-package: com.yourcompany.*.domain
同时启动类上的@MapperScan要覆盖到所有Mapper接口所在包:
java复制@MapperScan("com.yourcompany.**.mapper")
这两个配置缺一个,都会出现Mapper接口找到了但XML里的SQL报“Invalid bound statement”的经典错误。如果你在若依框架上做多模块扩展,这一点尤其重要,因为默认的单模块配置不会覆盖这种场景。
至于 MyBatis-Plus、Lombok、Spring Boot 之间的版本兼容,我的体会是尽量用同一套官方推荐版本组合。比如Spring Boot 2.7配合MyBatis-Plus 3.5.2是常见稳定组合,升级到Spring Boot 3以后,MyBatis-Plus的包名和部分API变化比较大,网上很多3.x版本的代码不能直接照搬。在做“按任意字段upsert”这种自定义SQL时,版本差异对XML本身影响不大,真正有影响的是自动填充、分页插件这些内置拦截器行为。
最后再分享一个我踩过几次坑之后总结的习惯:凡是自定义的批量upsert,上线前一定要在测试库先跑一遍“相同数据重复导入两次”的用例。第二次导入时,如果记录能被正确更新而不是产生重复数据,这个方案才算真正可用。这个用例我建议你写进项目的回归测试里,它比什么原理讲解都管用。
