从手动检查到自动拦截:MyBatis-Plus 乐观锁冲突检测的优雅实现

3 阅读6分钟

从手动检查到自动拦截:MyBatis-Plus 乐观锁冲突检测的优雅实现

一、问题背景

在并发场景下,乐观锁是保证数据一致性的常用方案。MyBatis-Plus 提供了 @Version 注解和 OptimisticLockerInnerInterceptor,可以自动在 SQL 层面拼接 WHERE version = ?SET version = version + 1

但这里有一个隐含的陷阱:updateById 返回影响行数为 0 时(即版本号不匹配,更新失败),MyBatis-Plus 并不会抛出任何异常,而是静默返回 0。

这意味着业务开发者必须手动编写如下防御性代码:

if (itemMapper.updateById(item) == 0) {
    throw new OptimisticLockConflictException();
}

当项目中有大量使用乐观锁的实体和更新操作时,这种模板代码会出现几十次,既冗余又容易遗漏。一旦某个开发者忘记加这个判断,乐观锁就形同虚设。

二、方案设计

2.1 目标

  1. 自动检测:当 updateById 影响行数为 0 时,自动抛出 OptimisticLockConflictException,无需手动检查
  2. 精确拦截:只拦截带 @Version 注解的乐观锁实体,避免误伤普通更新操作
  3. 作用域控制:只在 @OptimisticLockRetry 标注的方法内自动抛异常,不影响其他不需要重试的更新逻辑
  4. 与 AOP 重试联动:异常抛出后被 @OptimisticLockRetry 切面捕获,自动重试

2.2 架构设计

整体分为三层协作:

graph TD
    A["@OptimisticLockRetry<br/>public void borrowItem(...)"] --> B["AOP 切面<br/>设置 ThreadLocal"]
    A --> C["MyBatis 拦截器<br/>检测 rows==0 + ThreadLocal"]

    B --> D["OptimisticLockContext<br/>(ThreadLocal)"]
    C --> D

    C -->|抛出异常| E["自动抛出<br/>OptimisticLockConflictException"]
    E --> F["AOP 捕获异常并重试"]
    F --> A

    style A fill:#e1f5fe
    style D fill:#fff3e0

关键设计决策:用 ThreadLocal 桥接 AOP 与 MyBatis 拦截器

为什么不直接在 MyBatis 拦截器中无条件抛异常?因为项目中并非所有 updateById 都需要乐观锁重试。比如 update 方法里的 updateById 只是更新物品信息,即使版本冲突也无需重试。ThreadLocal 确保拦截器只在 AOP 切面激活的作用域内才生效。

三、逐层实现

3.1 ThreadLocal 上下文桥接层

OptimisticLockContext 是一个极简的 ThreadLocal 工具类,提供进入/退出/查询三个静态方法:

public class OptimisticLockContext {
    private static final ThreadLocal<Boolean> IN_RETRY_SCOPE = new ThreadLocal<>();

    public static void enter() {
        IN_RETRY_SCOPE.set(true);
    }

    public static void exit() {
        IN_RETRY_SCOPE.remove();  // remove 而非 set(false),避免内存泄漏
    }

    public static boolean isInRetryScope() {
        return Boolean.TRUE.equals(IN_RETRY_SCOPE.get());
    }
}

注意exit() 使用 remove() 而非 set(false),避免在线程池复用场景下 ThreadLocal 残留导致的内存泄漏和逻辑错误。

3.2 MyBatis 拦截器层

这是核心——OptimisticLockResultInterceptor,拦截 Executor.update 方法:

@Intercepts({
    @Signature(type = Executor.class, method = "update", 
               args = {MappedStatement.class, Object.class})
})
public class OptimisticLockResultInterceptor implements Interceptor {

    @Override
    public Object intercept(Invocation invocation) throws Throwable {
        Object result = invocation.proceed();

        // 三重判断:影响行数为0 + 处于重试作用域 + 是乐观锁实体
        if (result instanceof Integer rows && rows == 0 
                && OptimisticLockContext.isInRetryScope()) {
            MappedStatement ms = (MappedStatement) invocation.getArgs()[0];
            Object parameter = invocation.getArgs()[1];

            if (isUpdateById(ms.getId()) && hasVersionField(parameter)) {
                throw new OptimisticLockConflictException();
            }
        }

        return result;
    }
}

三重判断的设计思路:

判断条件目的不加会怎样
rows == 0只关注更新失败的操作所有更新都会抛异常
isInRetryScope()只在 @OptimisticLockRetry 作用域内生效普通更新也会被拦截,导致误抛异常
isUpdateById() && hasVersionField()只拦截乐观锁实体的 updateById其他类型的更新(如 delete、非乐观锁实体更新)也会被误杀

hasVersionField 的实现通过反射检测参数对象是否包含 @Version 注解:

private boolean hasVersionField(Object parameter) {
    if (parameter == null) return false;
    Class<?> clazz = parameter.getClass();
    for (Field field : clazz.getDeclaredFields()) {
        if (field.isAnnotationPresent(
                com.baomidou.mybatisplus.annotation.Version.class)) {
            return true;
        }
    }
    return false;
}

3.3 AOP 切面层

OptimisticLockRetryAspect 是重试的调度中心,负责管理 ThreadLocal 生命周期和重试逻辑:

@Around("@annotation(retry)")
public Object around(ProceedingJoinPoint joinPoint, OptimisticLockRetry retry) 
        throws Throwable {
    int maxRetries = retry.maxRetries() >= 0 
            ? retry.maxRetries() : properties.getMaxRetries();
    long delay = retry.delay() >= 0 
            ? retry.delay() : properties.getDelay();

    OptimisticLockContext.enter();  // ① 进入作用域
    try {
        for (int i = 0; i <= maxRetries; i++) {
            try {
                return joinPoint.proceed();
            } catch (OptimisticLockConflictException e) {
                if (i >= maxRetries) {
                    log.error("乐观锁重试耗尽:方法={}, 重试次数={}", 
                            joinPoint.getSignature().toShortString(), maxRetries);
                    throw new BaseException("系统繁忙,请稍后重试");
                }
                log.warn("乐观锁冲突,重试第{}次:方法={}", 
                        i + 1, joinPoint.getSignature().toShortString());
                if (delay > 0) {
                    Thread.sleep(delay);
                }
            }
        }
        throw new BaseException("系统繁忙,请稍后重试");
    } finally {
        OptimisticLockContext.exit();  // ② 保证退出作用域
    }
}

finally 中的 exit() 保证了即使出现未预期的异常,ThreadLocal 也会被清理,避免线程池复用时污染其他请求。

3.4 注册拦截器

在 MyBatis-Plus 配置类中注册自定义拦截器:

@Configuration
public class MybatisPlusConfig {
    @Bean
    public MybatisPlusInterceptor mybatisPlusInterceptor() {
        MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
        interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
        interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.POSTGRE_SQL));
        return interceptor;
    }

    @Bean
    public OptimisticLockResultInterceptor optimisticLockResultInterceptor() {
        return new OptimisticLockResultInterceptor();
    }
}

注意 OptimisticLockResultInterceptor 必须作为独立 Bean 注册,而不是放在 MybatisPlusInterceptor 的内部拦截器链中。因为 MybatisPlusInterceptor 中的 InnerInterceptor 是在 SQL 执行前修改 SQL(如拼接 WHERE version=?),而我们需要的是在 SQL 执行检查返回值,两者的拦截时机不同。

3.5 注解与配置

@OptimisticLockRetry 注解支持方法级覆盖全局配置:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface OptimisticLockRetry {
    int maxRetries() default -1;   // -1 表示使用全局配置
    long delay() default -1;       // -1 表示使用全局配置
}

全局配置通过 yml 管理:

optimistic-lock:
  retry:
    max-retries: 3
    delay: 50

四、效果对比

改造前

@OptimisticLockRetry
public void borrowItem(Long itemId, Integer quantity) {
    Item item = itemMapper.selectById(itemId);
    item.setAvailableQuantity(item.getAvailableQuantity() - quantity);
    item.setBorrowedQuantity(item.getBorrowedQuantity() + quantity);
    // 必须手动检查,遗漏则乐观锁失效
    if (itemMapper.updateById(item) == 0) {
        throw new OptimisticLockConflictException();
    }
}

改造后

@OptimisticLockRetry
public void borrowItem(Long itemId, Integer quantity) {
    Item item = itemMapper.selectById(itemId);
    item.setAvailableQuantity(item.getAvailableQuantity() - quantity);
    item.setBorrowedQuantity(item.getBorrowedQuantity() + quantity);
    itemMapper.updateById(item);  // 冲突自动检测,无需手动检查
}

业务代码更简洁,且不可能遗漏检查

五、完整执行流程

阶段1:冲突检测与异常抛出

graph TD
    Start([用户请求]) --> Enter["AOP: enter() → ThreadLocal=true"]
    Enter --> Select["selectById (version=5)"]
    Select --> Update["updateById 执行"]
    Update --> SQL["SQL 自动拼接 version: WHERE id=? AND version=5"]
    SQL --> DB[(数据库)]
    DB --> Result{影响行数?}
    Result -->|0| Intercept["拦截器检测 rows==0 && 在重试作用域 && 是乐观锁实体"]
    Intercept --> Throw["抛出 OptimisticLockConflictException"]
    Result -->|1| Success["正常返回"]

阶段2:重试机制

graph TD
    Exception[捕获异常] --> Check{重试次数 < maxRetries?}
    Check -->|是| Wait["等待 delay ms"]
    Wait --> Retry[重新执行完整方法]
    Retry --> SelectNew["selectById 获取最新 version=6"]
    SelectNew --> UpdateNew["updateById 成功"]
    UpdateNew --> Return[返回结果]
    Check -->|否| Fail["抛出友好提示: 系统繁忙,请稍后重试"]
    Return --> Finally["finally: exit() 清理 ThreadLocal"]
    Fail --> Finally

六、设计考量与踩坑

6.1 为什么不用 MybatisPlusInterceptor 的 InnerInterceptor?

MybatisPlusInterceptor 中的 InnerInterceptor 设计用于 SQL 执行的干预(如改写 SQL、添加条件),其 beforeUpdate 方法没有返回值,无法获取 SQL 执行结果。因此必须在更底层的 MyBatis Interceptor 层面拦截 Executor.update,在 invocation.proceed() 之后检查返回值。

6.2 拦截器注册顺序

MyBatis 拦截器按注册顺序形成插件链,后注册的先执行(洋葱模型)。OptimisticLockResultInterceptorinvocation.proceed() 之后才检查结果,因此不受注册顺序影响——proceed() 会走完整个插件链包括 OptimisticLockerInnerInterceptor 的版本号改写逻辑。

6.3 ThreadLocal 与线程池

Web 服务器使用线程池处理请求,ThreadLocal 如不清理会在线程复用时影响后续请求。本方案在 finally 块中调用 remove() 保证了清理,且 OptimisticLockContext 没有使用 InheritableThreadLocal,避免了子线程污染。

6.4 非重试场景的安全性

当方法没有标注 @OptimisticLockRetry 时,OptimisticLockContext.isInRetryScope() 返回 false,拦截器不会抛异常,updateById 静默返回 0,行为与原始 MyBatis-Plus 一致。这确保了向后兼容。

七、总结

本方案通过 ThreadLocal 桥接 + MyBatis 拦截器 + AOP 切面 三层协作,实现了乐观锁冲突的自动检测与重试,核心收益:

  • 消除模板代码:无需在每处 updateById 后手动检查返回值
  • 防止遗漏:只要方法标注了 @OptimisticLockRetry,冲突检测就是自动的
  • 精确拦截:三重条件判断确保只拦截需要处理的乐观锁冲突
  • 作用域隔离:ThreadLocal 确保非重试场景不受影响
  • 配置灵活:注解级覆盖 + 全局 yml 配置

这种架构思路同样适用于其他需要在框架层面统一处理的横切关注点,如自动填充操作人、数据权限过滤等场景。