1. 理解saveOrUpdate的核心机制
MybatisPlus的saveOrUpdate方法是一个让人又爱又恨的功能。它表面上看起来很简单——根据主键是否存在来决定是插入还是更新数据。但实际使用中,我发现这个方法的坑远比想象中要多。
先说说它的基本工作原理。当你不带任何条件构造器调用saveOrUpdate时,MybatisPlus会先根据实体类的主键去数据库查询。如果查到了记录,就执行更新操作;如果没查到,就执行插入操作。听起来很合理对吧?但问题就出在这个"主键"的判断上。
我遇到过最典型的问题就是自动递增主键的场景。假设我们有个用户表,主键是自增ID。当我新建一个用户对象,不设置ID值,直接调用saveOrUpdate时,会发生什么?按照常理,这应该是个新用户,应该执行插入操作。但MybatisPlus会先执行一个查询:SELECT * FROM user WHERE id=null。显然这个查询会返回空,于是它就会执行插入。看起来没问题?但如果你传入了UpdateWrapper,情况就变得复杂了。
2. 非主键字段冲突的常见场景
在实际开发中,我们经常需要根据业务唯一键而不是主键来判断记录是否存在。比如用户表除了自增主键ID外,还有手机号字段需要保持唯一。这种情况下,saveOrUpdate的默认行为就完全不能满足需求了。
我最近就遇到了这样一个案例:一个电商系统的商品分类表。分类有自增ID作为主键,但同时分类名称也必须是唯一的。当用户提交一个分类数据时,我们需要判断:如果分类名称已存在,就更新该分类;如果不存在,就新建分类。
按照saveOrUpdate的默认逻辑,它会根据主键ID来判断,而我们的ID是自增的,永远都是新ID,这就导致每次都会执行插入操作,最终造成分类名称重复的数据。这显然不是我们想要的结果。
3. 使用UpdateWrapper解决非主键冲突
经过一番折腾,我发现可以通过UpdateWrapper来解决这个问题。具体做法是这样的:
UpdateWrapper<Category> wrapper = new UpdateWrapper<Category>() .eq("category_name", category.getCategoryName()); categoryService.saveOrUpdate(category, wrapper);这段代码的逻辑是:先尝试根据分类名称更新记录,如果更新影响的行数为0(说明没有这个分类名称的记录),再执行默认的saveOrUpdate逻辑。
但这里有个细节需要注意:UpdateWrapper的eq条件字段必须是数据库列名,而不是实体类属性名。如果你不小心用了驼峰命名,比如categoryName,就会报错。这个坑我踩过好几次,现在都会特别注意。
另外,UpdateWrapper和QueryWrapper在这个场景下的表现是不同的。虽然方法签名接受的是Wrapper类型,但如果你传入QueryWrapper,MybatisPlus会把它当作UpdateWrapper来处理。这点从源码中也能看出来:
default boolean saveOrUpdate(T entity, Wrapper<T> updateWrapper) { return this.update(entity, updateWrapper) || this.saveOrUpdate(entity); }4. ON DUPLICATE KEY UPDATE的优化方案
对于MySQL数据库,其实有更高效的解决方案——ON DUPLICATE KEY UPDATE语法。这是MySQL特有的语法,可以在一次SQL操作中完成"存在则更新,不存在则插入"的逻辑。
假设我们的分类表结构如下:
CREATE TABLE category ( id BIGINT AUTO_INCREMENT PRIMARY KEY, category_name VARCHAR(100) UNIQUE, description VARCHAR(500), create_time DATETIME );我们可以这样使用ON DUPLICATE KEY UPDATE:
INSERT INTO category (category_name, description, create_time) VALUES ('电子产品', '各类电子设备', NOW()) ON DUPLICATE KEY UPDATE description = VALUES(description), create_time = VALUES(create_time);这条SQL的意思是:尝试插入一条新记录,如果category_name(因为有UNIQUE约束)已存在,就更新description和create_time字段。
在MybatisPlus中,我们可以通过自定义SQL的方式使用这个特性。首先在Mapper接口中定义方法:
@Insert("INSERT INTO category (category_name, description, create_time) " + "VALUES (#{categoryName}, #{description}, NOW()) " + "ON DUPLICATE KEY UPDATE " + "description = VALUES(description), " + "create_time = VALUES(create_time)") int saveOrUpdateByCategoryName(Category category);这种方式的性能明显优于先查询再判断的方案,因为它只需要一次数据库交互。特别是在批量操作时,优势更加明显。
5. 批量操作的性能优化
说到批量操作,MybatisPlus自带的saveOrUpdateBatch方法在处理非主键冲突时也有局限性。它本质上还是循环调用单条的saveOrUpdate,性能并不理想。
对于大批量数据,我们可以利用MySQL的ON DUPLICATE KEY UPDATE特性来实现真正的批量操作。下面是一个示例:
@Insert("<script>" + "INSERT INTO category (category_name, description, create_time) VALUES " + "<foreach collection='list' item='item' separator=','>" + "(#{item.categoryName}, #{item.description}, NOW())" + "</foreach>" + "ON DUPLICATE KEY UPDATE " + "description = VALUES(description), " + "create_time = VALUES(create_time)" + "</script>") int batchSaveOrUpdate(@Param("list") List<Category> categories);这个方案在处理上千条数据时,性能可以提升几十倍。我在实际项目中做过测试,插入1000条数据(其中约30%需要更新),使用saveOrUpdateBatch需要约5秒,而这个批量方案只需要不到0.1秒。
6. 主键回填的注意事项
使用saveOrUpdate时,另一个需要注意的问题是主键回填。对于插入操作,我们通常需要获取数据库生成的主键值。Mybatis本身支持主键回填,但在saveOrUpdate场景下,情况会复杂一些。
如果使用默认的saveOrUpdate方法,当执行插入操作时,实体对象的主键字段会被自动填充。但如果是通过UpdateWrapper先执行更新操作,就不会有主键回填,因为更新的记录主键本来就是已知的。
这里有个容易混淆的点:即使更新操作影响了多行记录,MybatisPlus也只会把Wrapper中eq条件对应的主键值回填到实体对象。如果你需要获取所有被更新记录的主键,就需要额外查询。
7. 事务与并发控制
在高并发场景下使用saveOrUpdate还需要考虑事务和并发问题。特别是当多个线程同时判断记录不存在并尝试插入时,可能会引发唯一键冲突。
我建议在这些场景下:
- 确保数据库相关字段有正确的唯一索引
- 使用事务保证操作的原子性
- 考虑添加适当的重试机制
Spring的@Transactional注解可以很方便地管理事务:
@Transactional public void saveOrUpdateCategory(Category category) { UpdateWrapper<Category> wrapper = new UpdateWrapper<Category>() .eq("category_name", category.getCategoryName()); categoryService.saveOrUpdate(category, wrapper); }8. 实际项目中的最佳实践
经过多个项目的实践,我总结出以下几点经验:
- 对于简单的单条操作,可以使用saveOrUpdate加UpdateWrapper
- 对于批量操作,优先考虑ON DUPLICATE KEY UPDATE方案
- 确保所有业务唯一键都有数据库层面的唯一索引约束
- 在高并发场景下,配合使用事务和重试机制
- 注意监控和日志,及时发现和处理冲突情况
一个完整的工具类示例:
public class CategoryService { @Autowired private CategoryMapper categoryMapper; // 单条保存或更新 @Transactional public void saveOrUpdateByName(Category category) { UpdateWrapper<Category> wrapper = new UpdateWrapper<Category>() .eq("category_name", category.getCategoryName()); if (!categoryService.saveOrUpdate(category, wrapper)) { throw new RuntimeException("保存分类失败"); } } // 批量保存或更新 @Transactional public void batchSaveOrUpdate(List<Category> categories) { if (CollectionUtils.isEmpty(categories)) { return; } int affected = categoryMapper.batchSaveOrUpdate(categories); log.info("批量保存分类,处理{}条,影响{}行", categories.size(), affected); } // 带重试的保存 @Transactional public void saveWithRetry(Category category, int maxRetries) { int retries = 0; while (retries < maxRetries) { try { saveOrUpdateByName(category); return; } catch (Exception e) { retries++; if (retries >= maxRetries) { throw e; } try { Thread.sleep(100 * retries); } catch (InterruptedException ie) { Thread.currentThread().interrupt(); throw new RuntimeException(ie); } } } } }9. 性能对比与选型建议
为了帮助大家更好地选择方案,我做了个简单的性能对比:
| 方案 | 单条耗时(ms) | 1000条耗时(ms) | 适用场景 |
|---|---|---|---|
| saveOrUpdate默认 | 15-20 | 15000-20000 | 简单场景,数据量小 |
| saveOrUpdate+Wrapper | 20-25 | 20000-25000 | 非主键判断,数据量小 |
| ON DUPLICATE单条 | 5-10 | 5000-10000 | MySQL,单条或小批量 |
| ON DUPLICATE批量 | - | 50-100 | MySQL,大批量操作 |
从对比可以看出,对于大批量操作,ON DUPLICATE KEY UPDATE方案具有绝对优势。但在非MySQL数据库或需要兼容多数据库的场景下,还是得使用saveOrUpdate加Wrapper的方案。
10. 常见问题排查
在使用过程中,可能会遇到各种问题。这里分享几个常见问题的排查方法:
报错"can not find column for id from entity"检查实体类是否使用了@TableId注解标记主键字段
根据非主键字段更新不生效确认UpdateWrapper的条件字段名是数据库列名而非实体属性名 检查数据库是否有对应的唯一索引
批量操作性能差考虑使用ON DUPLICATE KEY UPDATE批量方案 检查数据库连接池配置是否合理
主键没有回填确认操作实际执行的是插入而非更新 检查实体类主键字段的@TableId配置
高并发下出现重复数据确保数据库有唯一索引约束 考虑添加分布式锁或重试机制
11. 源码解析与扩展思路
对于想深入理解saveOrUpdate原理的开发者,可以看看MybatisPlus的源码。核心逻辑在com.baomidou.mybatisplus.extension.service.IService中:
default boolean saveOrUpdate(T entity) { if (null != entity) { Class<?> cls = entity.getClass(); TableInfo tableInfo = TableInfoHelper.getTableInfo(cls); if (null != tableInfo && tableInfo.isWithInsertFill() && null == tableInfo.getKeyProperty()) { return save(entity); } } return null == getById(entity) ? save(entity) : updateById(entity); }这段代码清晰地展示了默认的saveOrUpdate逻辑:先尝试根据ID查询,再决定插入或更新。
如果你想实现更复杂的逻辑,比如根据多个字段组合判断记录是否存在,可以考虑继承ServiceImpl类并重写相关方法。这种扩展方式既能保持MybatisPlus的便利性,又能满足特定业务需求。