很多人刚上手 MyBatis-Plus(简称 MP)时,会觉得它特别“省心”——CRUD 操作几乎一行搞定,saveOrUpdate()更是被视为“懒人利器”。它能根据主键是否存在自动决定是insert还是update。但实际用过一段时间后,你可能会发现,这玩意儿藏着不少坑。下面结合我在生产环境踩过的几个例子,聊聊它到底容易出错的点在哪,以及怎么绕过去。
一、表面上很香的saveOrUpdate()
MP 官方文档里是这样说的:
boolean saveOrUpdate(T entity);
作用简单明了:
如果主键不存在 执行INSERT
如果主键存在 执行UPDATE
比如下面这段代码,看起来再正常不过:
User user = new User();
user.setId(1L);
user.setName("张三");
user.setAge(25);
userService.saveOrUpdate(user);
理论上,如果数据库中id = 1的记录存在,就更新;不存在就插入。 问题是,这个“存在”到底是怎么判断的?
二、隐藏逻辑:它是查了数据库的
很多人以为它会直接判断主键是否为 null 来决定 insert 还是 update,其实不是。
saveOrUpdate()底层会先执行一条SELECT:
SELECT id FROM user WHERE id = ?
如果查得到,就执行:
UPDATE user SET name=?, age=? WHERE id=?
否则执行:
INSERT INTO user (id, name, age) VALUES (?, ?, ?)
看出问题了吗? 这意味着——每次调用saveOrUpdate()都要查一次数据库,哪怕你很确定那条记录肯定存在。
对于单条操作没啥,但批量操作或者高并发场景下,这会直接导致性能暴跌。 尤其是那种在定时任务或批处理里循环调用的写法,简直是自杀行为:
for (User user : userList) {
userService.saveOrUpdate(user);
}
实际执行的 SQL 数量 =2 * userList.size(),先查后改,非常耗时。
三、另一个坑:主键策略导致误判
有些表的主键并不是数据库自增,而是用雪花算法、UUID 或者业务逻辑生成。 这时候,如果你在saveOrUpdate()里传了一个新生成的 ID,但数据库中其实还没有这条数据,MP 会先查一下:
SELECT id FROM user WHERE id = 'xxxx'
查不到,于是它会INSERT。 听起来没问题,但如果你用了分布式 ID 生成算法(比如多个节点同时生成),就有可能插入重复键。
举个例子:
User user = new User();
user.setId(IdUtil.getSnowflakeNextId());
user.setName("李四");
userService.saveOrUpdate(user);
当两个服务节点生成了相同的 ID(这事真不是没发生过),saveOrUpdate()的“查 插入”间隙就会出现并发写入,导致主键冲突。
四、更新逻辑不完整:字段丢失问题
默认情况下,saveOrUpdate()使用的是全字段更新。
也就是说,即使你的对象里某个字段是null,它也会把数据库里对应的字段更新成null。 例如:
User user = new User();
user.setId(1L);
user.setAge(30);
userService.saveOrUpdate(user);
结果数据库里name字段被更新成了null—— 因为你的User对象没填这个字段。 这在生产环境里是很危险的。
解决办法有两个:
使用@TableField(updateStrategy = FieldStrategy.NOT_NULL)避免空值覆盖。
或者改用updateById()+ 自定义更新逻辑。
例如:
@TableField(updateStrategy = FieldStrategy.NOT_NULL)
private String name;
五、批量更新的“假优化”
MyBatisPlus 还提供了一个批量方法:
boolean saveOrUpdateBatch(Collection<T> entityList);
很多人以为这会批量执行一条 SQL,但其实不是。 它的内部实现是循环执行单条saveOrUpdate(),只是帮你做了个 for 循环。
源码逻辑大概是这样的:
for (T entity : entityList) {
saveOrUpdate(entity);
}
所以,批量插入/更新千万不要用它!正确姿势应该是:
userService.saveBatch(userList);
或者用:
userService.updateBatchById(userList);
这两个方法才是真正的批量操作。
六、最坑的一种情况:逻辑删除表
如果你的表启用了逻辑删除字段,比如:
@TableLogic
private Integer deleted;
那么当你调用saveOrUpdate()时,它在判断“记录是否存在”时并不会排除逻辑删除的记录。 也就是说,如果数据库里那条记录被逻辑删除了(deleted=1),它还是会被当成“存在”,然后执行更新操作。 这会让逻辑删除的记录被“复活”回来!
示例:
// deleted=1 的数据被误更新成 deleted=0
userService.saveOrUpdate(user);
解决方法只能是:在业务层自己判断逻辑删除状态,不要盲信 MP 的存在判断。
七、推荐的使用方式
能分就分:
新增用save()
更新用updateById()
不确定时再用saveOrUpdate()(比如导入同步类业务)
需要部分字段更新时用updateWrapper明确指定更新字段:
userService.update(
new UpdateWrapper<User>().eq("id", user.getId()).set("age", user.getAge())
);
逻辑删除表要慎用saveOrUpdate()最好是显式判断:
User exist = userMapper.selectById(user.getId());
if (exist == null) {
userService.save(user);
} else if (exist.getDeleted() == 0) {
userService.updateById(user);
}
八、最后
saveOrUpdate()是个方便的工具,但它不是万能的。 在小型系统或后台管理类项目里用没问题,但一旦涉及高并发、大批量、逻辑删除、多源数据同步这些场景,就一定要拆开用。
你要清楚它底层到底做了什么,而不是“以为它会自动判断”。
有时候一行“懒人代码”,藏着的 SQL 比你自己写的还多。 而性能瓶颈、并发冲突、字段丢失,全都可能从这里埋下。
所以,真想少踩坑,最好老老实实写 CRUD。
如果你现在项目里有大规模使用saveOrUpdate()的代码,建议你先查下 SQL 日志,看看实际执行的数量——那一瞬间你大概就会明白,为什么线上总是“慢得离谱”。