MyBatis-Plus (MP) 的乐观锁实现是一种基于版本号(Version)时间戳(Timestamp) 的机制,用于解决并发更新时的数据冲突问题。其核心原理是:在读取数据时记录版本号,更新时检查当前数据库中的版本号是否与之前读取的版本号一致。如果一致则更新成功并递增版本号;如果不一致则认为数据已被修改过,更新失败(通常抛出异常或返回影响行数为0)。

核心概念

  1. 版本字段 (@Version):

    • 在数据库表中需要一个专门的字段(如 version)来记录数据的版本信息。
    • 在对应的实体类中,需要使用 MP 的 @Version 注解标记这个字段。
    • 该字段的类型通常是数值型(Integer, Long)或时间戳(Date, LocalDateTime)。强烈推荐使用数值型(如 Integer)。
  2. 乐观锁插件 (OptimisticLockerInterceptor):

    • MP 通过一个内置的拦截器插件来实现乐观锁逻辑。
    • 该插件会在执行 updateByIdupdate 方法(使用实体作为更新条件时)之前自动进行拦截。
    • 拦截器的工作流程:
      1. 获取实体对象中的 @Version 字段的当前值 (oldVersion)。
      2. 生成更新 SQL:SET ... , version=oldVersion + 1 WHERE ... AND version=oldVersion
      3. 执行 SQL。
      4. 检查 SQL 执行后影响的行数 (affectedRows):
        • 如果 affectedRows == 0:说明 WHERE 条件中的 version=oldVersion 不成立(即记录已被其他事务修改),乐观锁生效,更新失败。MP 默认会抛出 OptimisticLockException 异常(需要自行捕获处理)。
        • 如果 affectedRows > 0:更新成功,同时数据库中的 version 字段值已递增 (oldVersion + 1)。MP 会自动将实体对象中的 version 字段值也更新为 oldVersion + 1

详细操作步骤 (Spring Boot 环境为例)

  1. 数据库表添加版本字段:

    ALTER TABLE your_table ADD COLUMN version INT DEFAULT 0 NOT NULL COMMENT '乐观锁版本号';
    -- 或者使用 BIGINT, TIMESTAMP 等,但 INT 最常见。
    -- 确保新插入记录的 version 有初始值 (DEFAULT 0)。
    
  2. 实体类添加版本字段并标记 @Version 注解:

    import com.baomidou.mybatisplus.annotation.Version;
    
    public class YourEntity {
        // ... 其他字段 (id, name, etc.) ...
    
        @Version // 关键注解,标识该字段为乐观锁版本字段
        private Integer version; // 推荐使用 Integer 或 Long
    
        // Getter and Setter ...
    }
    
  3. 配置乐观锁插件:

    • 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>
    
  4. 使用 MP 的 updateByIdupdate 方法进行更新:

    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)
    }
    

常见错误 & 注意事项

  1. 忘记配置插件: 没有在配置类中添加 OptimisticLockerInnerInterceptor结果: @Version 注解失效,version 字段不会被自动递增,WHERE 条件中也不会包含 version=oldVersion,乐观锁功能完全不起作用。

    • 检查: 确保 MybatisPlusConfig 被 Spring 扫描到,且 mybatisPlusInterceptor Bean 正确创建并包含了乐观锁拦截器。
  2. @Version 注解字段类型错误:

    • 不支持的类型: 使用了 String 等非数值/时间戳类型。结果: MP 无法正确执行 version = oldVersion + 1 操作,可能导致 SQL 错误或逻辑错误。
    • 时间戳类型问题: 虽然支持 Date/LocalDateTime,但强烈不推荐。时间戳精度(毫秒/微秒/纳秒)和系统时间同步问题可能导致在高并发下,两个几乎同时发生的更新可能获得相同的时间戳值,从而乐观锁失效。坚持使用 IntegerLong
  3. @Version 注解字段未初始化:

    • 插入时未设置初始值: 新插入的记录,其 version 字段必须有一个初始值(通常为 0)。如果实体对象在 insertversionnull,且数据库字段是 NOT NULL,会报错。
    • 解决方案:
      • 在数据库表定义中设置 DEFAULT 0
      • 在实体类的 version 字段上直接初始化 private Integer version = 0;
      • 在插入前手动 setVersion(0)
  4. update 方法中使用 Wrapper 但未包含 Version 实体: update(T entity, Wrapper<T> updateWrapper) 方法。如果 entity 参数不为 null,插件只会使用 entity 中的 @Version 字段值来构造乐观锁条件。如果你在 updateWrapper 中手动设置了 set,但忽略了 entity 中的 version 值,或者 entitynull乐观锁将失效

    • 正确做法:
      • 优先使用 updateById(entity),让 MP 自动处理乐观锁。
      • 如果必须用 Wrapper,确保将 @Version 字段作为条件的一部分加入到 Wrapper 中,并且entity 中设置好正确的旧版本值(这非常容易出错,不推荐)。或者确保 entity 不为 null 且包含正确的 version 值。
  5. 手动修改 Version 值: 业务代码中不应该随意修改 @Version 字段的值(除了让 MP 在成功更新后自动递增)。手动设置一个更大的值会破坏乐观锁的递增逻辑和冲突检测。

  6. 并发冲突处理缺失:updateById 返回 false 或抛出 OptimisticLockException 时,业务代码没有做任何处理(如重试、提示用户)。结果: 用户可能感知不到更新失败,或者体验很差。

    • 解决方案: 必须捕获异常或检查返回值,根据业务场景选择重试、提示用户刷新数据重新编辑等策略。

使用技巧

  1. 结合 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
  2. 自定义冲突异常: 默认抛出 OptimisticLockException。可以在全局异常处理器 (@RestControllerAdvice + @ExceptionHandler) 中捕获它,返回更友好的错误信息或特定状态码给前端。

最佳实践 & 性能优化

  1. 坚持使用数值型 Version 字段 (Integer/Long): 避免时间戳带来的精度和同步问题。Long 可以提供更大的范围。

  2. 数据库字段设置 NOT NULL DEFAULT 0 确保新记录有有效初始值,避免 NullPointerException

  3. 合理的冲突处理策略:

    • 用户交互型系统: 捕获冲突异常,提示用户“数据已被他人修改,请刷新页面后重试”。
    • 后台服务/高并发场景:
      • 简单重试 (自旋): 在捕获到冲突后,立即重新查询最新数据,应用业务逻辑修改,再次尝试更新。设置一个合理的最大重试次数 (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),由消费者按顺序处理,避免并发冲突。牺牲实时性换取最终一致性。
      • 放弃更新/记录日志: 对于非关键更新或冲突后可忽略的场景,直接记录日志并跳过。
  4. 避免在 Wrapper 中绕过 Version 更新: 除非有绝对必要且完全理解后果,否则不要使用只传 Wrapperupdate 方法(如 update(Wrapper<T> updateWrapper))或者 UpdateWrapper.setSql("version = version + 1") 来直接更新 Version 字段。这会完全绕过乐观锁插件的拦截和冲突检测逻辑。

  5. 监控冲突率: 在重试逻辑或捕获 OptimisticLockException 的地方添加监控指标 (如使用 Micrometer 上报到 Prometheus/Grafana)。高冲突率可能表明:

    • 热点数据竞争激烈。
    • 业务逻辑处理时间过长,增加了冲突窗口。
    • 需要重新设计数据模型或业务流(如拆分子订单、使用悲观锁等)。
  6. 理解适用场景:

    • 适合: 读多写少,冲突概率相对较低的场景。性能开销小(只在写时加一次版本检查)。
    • 不适合: 写非常频繁,冲突概率极高的场景(如秒杀库存扣减的最后阶段)。此时过多的重试会极大降低系统吞吐量,甚至导致系统崩溃。应考虑使用悲观锁 (如 SELECT ... FOR UPDATE)、分布式锁、或者更专门的解决方案 (Redis Lua 原子操作扣减、数据库行锁 + CAS 等)。

总结关键点

  1. 核心: @Version 注解 + OptimisticLockerInnerInterceptor 插件。
  2. 步骤:
    • 加数据库字段 (INT DEFAULT 0 NOT NULL)。
    • 实体字段加 @Version (private Integer version;)。
    • 配置插件 (MybatisPlusConfig 中添加 OptimisticLockerInnerInterceptor)。
    • 使用 updateById(entity)update(entity, wrapper) (注意 entity 需带 version) 更新。
    • 必须检查返回值或捕获 OptimisticLockException 处理冲突。
  3. 避坑: 插件配置、字段类型 (Integer)、初始值、避免手动改 Version、正确处理冲突。
  4. 优化: 数值型 Version、合理重试策略、监控冲突率、理解场景适用性。