上周在做一个经销商数据导入的需求时,用户那边给了一张两万多行的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 (!upd
