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 扫描到,且mybatisPlusInterceptor
Bean 正确创建并包含了乐观锁拦截器。
- 检查: 确保
@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、合理重试策略、监控冲突率、理解场景适用性。