MyBatis-Plus (MP) 的乐观锁实现是一种基于版本号(Version) 或时间戳(Timestamp) 的机制,用于解决并发更新时的数据冲突问题。其核心原理是:在读取数据时记录版本号,更新时检查当前数据库中的版本号是否与之前读取的版本号一致。如果一致则更新成功并递增版本号;如果不一致则认为数据已被修改过,更新失败(通常抛出异常或返回影响行数为0)。
核心概念
版本字段 (
@Version):- 在数据库表中需要一个专门的字段(如
version)来记录数据的版本信息。 - 在对应的实体类中,需要使用 MP 的
@Version注解标记这个字段。 - 该字段的类型通常是数值型(
Integer,Long)或时间戳(Date,LocalDateTime)。强烈推荐使用数值型(如Integer)。
- 在数据库表中需要一个专门的字段(如
乐观锁插件 (
OptimisticLockerInterceptor):- MP 通过一个内置的拦截器插件来实现乐观锁逻辑。
- 该插件会在执行
updateById和update方法(使用实体作为更新条件时)之前自动进行拦截。 - 拦截器的工作流程:
- 获取实体对象中的
@Version字段的当前值 (oldVersion)。 - 生成更新 SQL:
SET ... , version=oldVersion + 1 WHERE ... AND version=oldVersion。 - 执行 SQL。
- 检查 SQL 执行后影响的行数 (
affectedRows):- 如果
affectedRows == 0:说明 WHERE 条件中的version=oldVersion不成立(即记录已被其他事务修改),乐观锁生效,更新失败。MP 默认会抛出OptimisticLockException异常(需要自行捕获处理)。 - 如果
affectedRows > 0:更新成功,同时数据库中的version字段值已递增 (oldVersion + 1)。MP 会自动将实体对象中的version字段值也更新为oldVersion + 1。
- 如果
- 获取实体对象中的
详细操作步骤 (Spring Boot 环境为例)
数据库表添加版本字段:
ALTER TABLE your_table ADD COLUMN version INT DEFAULT 0 NOT NULL COMMENT '乐观锁版本号'; -- 或者使用 BIGINT, TIMESTAMP 等,但 INT 最常见。 -- 确保新插入记录的 version 有初始值 (DEFAULT 0)。实体类添加版本字段并标记
@Version注解:import com.baomidou.mybatisplus.annotation.Version; public class YourEntity { // ... 其他字段 (id, name, etc.) ... @Version // 关键注解,标识该字段为乐观锁版本字段 private Integer version; // 推荐使用 Integer 或 Long // Getter and Setter ... }配置乐观锁插件:
- Spring Boot 方式 (推荐): 创建一个配置类
MybatisPlusConfig。
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加乐观锁插件到拦截器链 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }- 传统 XML 方式 (较少用): 在
mybatis-config.xml中配置插件 (确保 MP 的配置生效)。
<configuration> <plugins> <plugin interceptor="com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor"/> </plugins> </configuration>- Spring Boot 方式 (推荐): 创建一个配置类
使用 MP 的
updateById或update方法进行更新:YourEntity entity = yourEntityService.getById(id); // 1. 查询记录,此时 entity 中包含当前的 version 值 (假设为 1) entity.setName("New Name"); // 2. 修改需要更新的业务字段 boolean success = yourEntityService.updateById(entity); // 3. 调用 updateById 更新 // 或 yourEntityMapper.updateById(entity); if (!success) { // 4. 处理更新失败的情况 (乐观锁冲突) // success 为 false 表示影响行数为0,即乐观锁生效,更新失败。 // 也可以捕获 OptimisticLockException (默认抛出) log.warn("更新失败,数据已被他人修改!"); // 通常策略:重试、提示用户刷新等 } else { // 5. 更新成功 // 此时 entity 对象中的 version 字段已被 MP 自动更新为 (oldVersion + 1) }
常见错误 & 注意事项
忘记配置插件: 没有在配置类中添加
OptimisticLockerInnerInterceptor。结果:@Version注解失效,version 字段不会被自动递增,WHERE 条件中也不会包含version=oldVersion,乐观锁功能完全不起作用。- 检查: 确保
MybatisPlusConfig被 Spring 扫描到,且mybatisPlusInterceptorBean 正确创建并包含了乐观锁拦截器。
- 检查: 确保
@Version注解字段类型错误:- 不支持的类型: 使用了
String等非数值/时间戳类型。结果: MP 无法正确执行version = oldVersion + 1操作,可能导致 SQL 错误或逻辑错误。 - 时间戳类型问题: 虽然支持
Date/LocalDateTime,但强烈不推荐。时间戳精度(毫秒/微秒/纳秒)和系统时间同步问题可能导致在高并发下,两个几乎同时发生的更新可能获得相同的时间戳值,从而乐观锁失效。坚持使用Integer或Long。
- 不支持的类型: 使用了
@Version注解字段未初始化:- 插入时未设置初始值: 新插入的记录,其
version字段必须有一个初始值(通常为 0)。如果实体对象在insert时version为null,且数据库字段是NOT NULL,会报错。 - 解决方案:
- 在数据库表定义中设置
DEFAULT 0。 - 在实体类的
version字段上直接初始化private Integer version = 0;。 - 在插入前手动
setVersion(0)。
- 在数据库表定义中设置
- 插入时未设置初始值: 新插入的记录,其
在
update方法中使用Wrapper但未包含 Version 实体:update(T entity, Wrapper<T> updateWrapper)方法。如果entity参数不为null,插件只会使用entity中的@Version字段值来构造乐观锁条件。如果你在updateWrapper中手动设置了set,但忽略了entity中的version值,或者entity为null,乐观锁将失效。- 正确做法:
- 优先使用
updateById(entity),让 MP 自动处理乐观锁。 - 如果必须用
Wrapper,确保将@Version字段作为条件的一部分加入到Wrapper中,并且在entity中设置好正确的旧版本值(这非常容易出错,不推荐)。或者确保entity不为null且包含正确的version值。
- 优先使用
- 正确做法:
手动修改 Version 值: 业务代码中不应该随意修改
@Version字段的值(除了让 MP 在成功更新后自动递增)。手动设置一个更大的值会破坏乐观锁的递增逻辑和冲突检测。并发冲突处理缺失: 当
updateById返回false或抛出OptimisticLockException时,业务代码没有做任何处理(如重试、提示用户)。结果: 用户可能感知不到更新失败,或者体验很差。- 解决方案: 必须捕获异常或检查返回值,根据业务场景选择重试、提示用户刷新数据重新编辑等策略。
使用技巧
结合 Wrapper 进行条件更新: 虽然乐观锁主要用在
updateById,但也可以用在带条件的update方法上。只要update方法的参数是一个实体对象(且该对象包含@Version字段和正确的旧值),乐观锁插件就会生效。例如:YourEntity entity = new YourEntity(); entity.setId(id); entity.setStatus(newStatus); entity.setVersion(oldVersion); // !!! 必须手动设置查询到的旧版本号 !!! UpdateWrapper<YourEntity> wrapper = new UpdateWrapper<>(); wrapper.eq("category_id", categoryId); // 添加额外的更新条件 boolean success = yourService.update(entity, wrapper); // 会生成 WHERE id=id AND version=oldVersion AND category_id=categoryId- 注意: 这种方式需要手动从数据库查询或缓存中获取
oldVersion并设置到entity中,容易出错。优先考虑在查询出完整实体修改后再用updateById。
- 注意: 这种方式需要手动从数据库查询或缓存中获取
自定义冲突异常: 默认抛出
OptimisticLockException。可以在全局异常处理器 (@RestControllerAdvice+@ExceptionHandler) 中捕获它,返回更友好的错误信息或特定状态码给前端。
最佳实践 & 性能优化
坚持使用数值型 Version 字段 (
Integer/Long): 避免时间戳带来的精度和同步问题。Long可以提供更大的范围。数据库字段设置
NOT NULL DEFAULT 0: 确保新记录有有效初始值,避免NullPointerException。合理的冲突处理策略:
- 用户交互型系统: 捕获冲突异常,提示用户“数据已被他人修改,请刷新页面后重试”。
- 后台服务/高并发场景:
- 简单重试 (自旋): 在捕获到冲突后,立即重新查询最新数据,应用业务逻辑修改,再次尝试更新。设置一个合理的最大重试次数 (e.g., 3-5次) 和重试间隔 (避免活锁和雪崩)。适用于冲突概率不高、业务逻辑执行快的场景。
int maxRetries = 3; int retries = 0; boolean updated = false; while (retries < maxRetries && !updated) { YourEntity entity = service.getById(id); // ... 应用业务修改 ... try { updated = service.updateById(entity); // 或捕获 OptimisticLockException } catch (OptimisticLockException e) { retries++; if (retries >= maxRetries) { throw new BusinessException("更新过于频繁,请稍后再试", e); } // 可选:短暂休眠 Thread.sleep(50 * retries); } } if (!updated) { // 重试后仍失败的处理 }- 队列/异步处理: 对于冲突频繁或处理耗时的场景,可以将更新请求放入队列 (如 RabbitMQ, Kafka),由消费者按顺序处理,避免并发冲突。牺牲实时性换取最终一致性。
- 放弃更新/记录日志: 对于非关键更新或冲突后可忽略的场景,直接记录日志并跳过。
避免在
Wrapper中绕过 Version 更新: 除非有绝对必要且完全理解后果,否则不要使用只传Wrapper的update方法(如update(Wrapper<T> updateWrapper))或者UpdateWrapper.setSql("version = version + 1")来直接更新 Version 字段。这会完全绕过乐观锁插件的拦截和冲突检测逻辑。监控冲突率: 在重试逻辑或捕获
OptimisticLockException的地方添加监控指标 (如使用 Micrometer 上报到 Prometheus/Grafana)。高冲突率可能表明:- 热点数据竞争激烈。
- 业务逻辑处理时间过长,增加了冲突窗口。
- 需要重新设计数据模型或业务流(如拆分子订单、使用悲观锁等)。
理解适用场景:
- 适合: 读多写少,冲突概率相对较低的场景。性能开销小(只在写时加一次版本检查)。
- 不适合: 写非常频繁,冲突概率极高的场景(如秒杀库存扣减的最后阶段)。此时过多的重试会极大降低系统吞吐量,甚至导致系统崩溃。应考虑使用悲观锁 (如
SELECT ... FOR UPDATE)、分布式锁、或者更专门的解决方案 (Redis Lua 原子操作扣减、数据库行锁 + CAS 等)。
总结关键点
- 核心:
@Version注解 +OptimisticLockerInnerInterceptor插件。 - 步骤:
- 加数据库字段 (
INT DEFAULT 0 NOT NULL)。 - 实体字段加
@Version(private Integer version;)。 - 配置插件 (
MybatisPlusConfig中添加OptimisticLockerInnerInterceptor)。 - 使用
updateById(entity)或update(entity, wrapper)(注意entity需带version) 更新。 - 必须检查返回值或捕获
OptimisticLockException处理冲突。
- 加数据库字段 (
- 避坑: 插件配置、字段类型 (
Integer)、初始值、避免手动改 Version、正确处理冲突。 - 优化: 数值型 Version、合理重试策略、监控冲突率、理解场景适用性。