告别并发更新噩梦:MyBatis-Plus @Version 乐观锁实战指南
在高并发的系统中,多个用户或进程同时修改同一条数据是常有的事。想象一下这个场景:
- 用户 A 读取了商品 X 的库存,当前是 10。
- 几乎同时,用户 B 也读取了商品 X 的库存,也是 10。
- 用户 A 完成操作,将库存更新为 9 (UPDATE ... SET stock = 9 WHERE id = X)。
- 紧接着,用户 B 也完成操作,将库存更新为 8 (UPDATE ... SET stock = 8 WHERE id = X)。
结果是什么?库存最终变成了 8。但实际上,两次操作应该使库存变为 10 - 1 - 1 = 8 才对吗?不,这里用户 A 的更新被用户 B 的更新覆盖了,这就是经典的 “丢失更新”(Lost Update) 问题。
如何解决这个问题呢?常见的并发控制策略有两种:
- 悲观锁 (Pessimistic Locking): 假设冲突总是会发生。在读取数据时就将其锁定(例如数据库的 SELECT ... FOR UPDATE),阻止其他事务修改,直到当前事务完成。缺点是性能开销大,可能导致长时间等待甚至死锁。
- 乐观锁 (Optimistic Locking): 假设冲突很少发生。读取数据时不加锁。在更新数据时,检查一下自上次读取后,数据是否被其他事务修改过。如果没被修改,就执行更新;如果已被修改,就放弃本次更新(或进行重试等其他处理)。
MyBatis-Plus (MP) 提供了一个非常优雅的方式来实现乐观锁,那就是 @Version 注解。
什么是 @Version?
@Version 是 MP 提供的一个注解,用于标记实体类中代表版本号的字段。启用后,MP 会在执行更新操作时,自动利用这个版本号字段来实现乐观锁机制。
如何使用 @Version?
使用 @Version 同样非常简单,只需三步:
第一步:数据库表设计
在需要进行乐观锁控制的表中,添加一个用于存储版本号的字段。通常使用 INT 或 BIGINT 类型,并给一个初始值(如 1 或 0)。
ALTER TABLE `your_table_name`
ADD COLUMN `version` INT NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)';
-- 或者使用 BIGINT
-- ALTER TABLE `your_table_name` ADD COLUMN `version` BIGINT NOT NULL DEFAULT 1 COMMENT '版本号(乐观锁)';-- TIMESTAMP 类型也可以,但数值类型更常见且直观
注意: 初始值很重要,通常设为 1 或 0。
第二步:实体类配置
在对应的 Java 实体类中,添加该字段,并使用 @Version 注解标记它:
import com.baomidou.mybatisplus.annotation.Version;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;@Data
@TableName("your_table_name")
public class YourEntity {private Long id;private String name;private Integer stock; // 假设这是需要并发控制的字段// ... 其他字段@Version // 标记为版本号字段 (乐观锁)private Integer version; // 字段类型与数据库对应
}
第三步:注册乐观锁拦截器
乐观锁的功能是由 MP 的 OptimisticLockerInnerInterceptor 拦截器实现的。你必须在 MP 的配置中注册这个拦截器,它才能生效。
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; // 如果也需要分页
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;@Configuration
public class MybatisPlusConfig {@Beanpublic MybatisPlusInterceptor mybatisPlusInterceptor() {MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();// 添加乐观锁插件 !!! 这是必需的 !!!interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());// 如果你还需要分页插件或其他内部插件,也在这里添加// interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));return interceptor;}
}
关键: 如果没有注册 OptimisticLockerInnerInterceptor,@Version 注解将完全不起作用!
@Version 如何工作(幕后机制)?
配置完成后,当你使用 MP 提供的标准更新方法时,乐观锁拦截器会自动修改 SQL:
-
当你执行 selectById 或类似查询获取数据时:
YourEntity entity = yourMapper.selectById(1L); // 假设查出的 entity 中 version = 1
SQL 基本不变:
SELECT id, name, stock, version /*,...*/ FROM your_table_name WHERE id = 1;
-
当你修改实体并执行 updateById 或 update 时:
entity.setStock(entity.getStock() - 1); // stock 变为 9 int result = yourMapper.updateById(entity); // 尝试更新
MP 的乐观锁拦截器会介入,自动修改 UPDATE 语句:
UPDATE your_table_name SETstock = 9,version = 2 -- 版本号会自动加 1 WHEREid = 1AND version = 1 -- 条件中会带上之前查到的版本号
- 核心: UPDATE 语句的 WHERE 条件中包含了 version = 1。这意味着,只有当数据库中当前的 version 值仍然是 1 时(表示从你上次读取到现在,没有其他事务修改过它),这条 UPDATE 才会成功执行,并且将 version 更新为 2。
- 如果期间有其他事务已经修改了数据并将 version 更新为了 2,那么你的 UPDATE ... WHERE ... version = 1 语句将找不到匹配的行,更新操作影响的行数将是 0。
如何处理更新失败?
当 updateById(entity) 或 update(entity, wrapper) 方法因为版本冲突而更新失败时,它们的返回值会是 0(表示影响行数为 0)。
你的应用程序必须检查这个返回值,并根据业务需求进行处理,常见的策略有:
-
重试 (Retry): 重新读取最新的数据(包括最新的版本号),再次尝试修改和更新。通常需要设置重试次数限制,避免无限循环。
int maxRetries = 3; int currentRetry = 0; boolean success = false; while (currentRetry < maxRetries && !success) {YourEntity currentEntity = yourMapper.selectById(entity.getId());if (currentEntity == null) { /* 处理记录不存在的情况 */ break; }// 在新查出的数据上进行业务操作currentEntity.setStock(currentEntity.getStock() - 1);int result = yourMapper.updateById(currentEntity); // 使用最新的 version 尝试更新if (result > 0) {success = true; // 更新成功} else {currentRetry++;// 可选:增加短暂等待,避免活锁Thread.sleep(50);} } if (!success) {// 达到最大重试次数,抛出异常或通知用户throw new OptimisticLockingFailureException("Failed to update entity after multiple retries."); }
-
通知用户: 提示用户数据已被修改,请刷新后重试。
int result = yourMapper.updateById(entity); if (result == 0) {// 抛出特定异常,由全局异常处理器转换为友好的前端提示throw new DataConcurrentModificationException("Data has been modified by others. Please refresh and try again."); }
-
放弃或合并: 根据业务复杂性,可能选择放弃本次修改,或者尝试将用户的修改与数据库的最新状态进行合并(这通常比较复杂)。
重点: 必须处理更新返回值为 0 的情况,否则你的业务逻辑可能会在并发下出错。
@Version 的优势
- 简单易用: 只需添加一个字段、一个注解和一个拦截器即可启用。
- 性能较好: 相比悲观锁,它在读取时不产生数据库锁,只有在更新时才增加一个 version 条件,并发性能通常更好。
- 保证数据一致性: 有效防止了“丢失更新”问题。
限制与注意事项
- 仅支持 MP 内置方法: 乐观锁功能只对 updateById 和 update(entity, wrapper) 方法生效。对于自定义 SQL (在 XML 中或使用 @Update 注解编写的 SQL),乐观锁不会自动生效,你需要手动在 SQL 中实现版本检查和递增逻辑。
- 拦截器必须注册: 重复强调,没有注册 OptimisticLockerInnerInterceptor,一切都是空谈。
- 失败处理是关键: 必须在代码中显式处理更新失败(返回值为 0)的情况。
- 仅限更新: @Version 主要解决的是并发更新冲突,它不解决并发读导致的数据显示陈旧问题。
总结
MyBatis-Plus 的 @Version 注解为解决并发更新问题提供了一个强大而简洁的乐观锁实现方案。通过简单的配置,它可以极大地提高系统的并发处理能力和数据一致性。然而,务必记住正确配置拦截器,并在业务代码中妥善处理可能发生的更新失败情况,这样才能真正发挥乐观锁的威力,让你的应用在并发环境下稳如磐石。