MyBatis-Plus乐观锁实战:原理、配置与高并发场景应用
2026/8/26 22:00:25 网站建设 项目流程

1. 项目概述:为什么我们需要乐观锁?

在开发一个涉及数据并发修改的后端服务时,我猜你一定遇到过这样的场景:一个商品库存还剩最后一件,两个用户几乎同时点击了“立即购买”。如果不做任何处理,数据库里很可能会记录下两次成功的扣减,最终库存变成-1,这显然是个大问题。这就是典型的“丢失更新”问题。为了解决它,我们通常会上“锁”。悲观锁大家可能比较熟,比如SELECT ... FOR UPDATE,它假设冲突总会发生,所以在操作前就先锁住数据,别人只能干等着。这种方式在并发高的场景下,性能开销大,容易造成线程阻塞。

而乐观锁则采取了一种更“乐观”的策略:它假设冲突不经常发生,因此允许多个事务同时读取数据,但在更新时,会检查数据自读取以来是否被其他事务修改过。如果没被改过,就更新成功;如果被改过了,就更新失败,通常需要业务层决定重试或提示用户。这种机制特别适合读多写少冲突概率较低的场景,比如电商的库存扣减(虽然冲突概率不低,但通过重试机制可以很好处理)、账户余额更新、文章点赞计数等。

MyBatis-Plus(简称MP)作为MyBatis的增强工具,对乐观锁提供了非常优雅的开箱即用支持。它通过一个@Version注解,几乎零成本地帮我们实现了这套机制。今天,我就结合自己踩过的坑和实战经验,来聊聊如何娴熟地运用MP的乐观锁,让它真正成为你高并发业务中的“定海神针”,而不是一个摆设。

2. 核心原理与设计思路拆解

2.1 乐观锁的实现机制剖析

MP的乐观锁实现,本质上是对CAS(Compare And Swap,比较并交换)思想的一种封装。我们来看看它的核心工作流程:

  1. 版本号标记:在数据库表中,需要额外添加一个整型字段(通常命名为version),这个字段就是数据的“版本标识”。
  2. 读取与携带:当从数据库查询出一条记录时,这个version字段的值会随着其他字段一起被加载到实体对象中。
  3. 更新与校验:当执行更新操作(如updateById)时,MP会自动将UPDATE语句构造成这样:
    UPDATE user SET name='新名字', version=2 WHERE id=1 AND version=1;
    注意看WHERE条件,它同时用id和旧的version值(这里是1)作为条件。
  4. 结果判定:执行这条SQL后,数据库会返回受影响的行数(affected rows)。如果返回1,说明id=1version=1的记录存在,更新成功,并且version被设置为2。如果返回0,则说明在本次更新准备期间,已经有其他操作修改了这条数据(导致version不再是1),本次更新失败。

这个流程的精妙之处在于,整个“读取-判断-写入”的原子性是由数据库的单条UPDATE语句保证的。数据库引擎会确保这条语句的执行是原子的,从而避免了并发下的竞态条件。

2.2 MyBatis-Plus的优雅封装

MP没有让我们自己去拼写这个WHERE version = ?的条件。它通过以下两步实现透明化:

  1. 实体类标注:在实体类的版本字段上加上@Version注解。
    @Data public class User { private Long id; private String name; @Version private Integer version; // ... 其他字段 }
  2. 插件配置:在MyBatis的配置类中,添加OptimisticLockerInnerInterceptor插件。
    @Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加乐观锁插件 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }

配置完成后,所有通过MP的updateByIdupdate方法(Wrapper条件更新除外,后面会细说)进行的操作,都会自动带上乐观锁逻辑。作为开发者,你几乎感知不到它的存在,只需要关心更新失败后的业务处理即可。

注意@Version注解标识的字段,MP只支持Integer,Long,Date/Timestamp类型。推荐使用IntegerLong。一旦字段被标记,它就会被MP自动维护,你不应该在代码中手动给这个字段赋值。

3. 核心细节解析与实操要点

3.1 数据库表结构设计

乐观锁的前提是有一张设计合理的表。除了业务字段,你需要添加一个版本字段。

CREATE TABLE `t_product` ( `id` bigint(20) NOT NULL COMMENT '主键', `name` varchar(255) NOT NULL COMMENT '商品名', `stock` int(11) NOT NULL DEFAULT '0' COMMENT '库存', `version` int(11) NOT NULL DEFAULT '0' COMMENT '版本号,用于乐观锁', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='商品表';

关键点:

  • 字段名:不一定非得叫version,但需要和实体类属性名对应。
  • 类型INTBIGINT
  • 默认值:通常设置为0。新增记录时,MP会自动将其设为0(如果你用的是Integer类型且未赋值)或1(如果你用的是Long类型且未赋值,或数据库默认值不为NULL)。为了清晰,建议在表定义中设置DEFAULT 0
  • 初始值:对于已有数据的表,需要为所有存量数据初始化这个字段,比如UPDATE t_product SET version = 0;

3.2 实体类与注解的正确使用

实体类的映射是基础,但有些细节容易出错。

@Data @TableName("t_product") // 明确指定表名是好习惯 public class Product { @TableId(type = IdType.ASSIGN_ID) // 使用MP的雪花算法ID生成器 private Long id; private String name; private Integer stock; @Version private Integer version; // 使用Integer类型 // 注意:不要为version字段生成setter方法?不,@Data注解会生成。 // 关键在于,不要在业务代码中主动调用setVersion()。 }

实操心得

  • 不要手动管理Version:这是最常见的错误。有些同学在更新前,先查询出对象,然后product.setVersion(product.getVersion() + 1),再调用updateById。这完全破坏了MP的机制。MP会在生成SQL时,自动将SET子句中的version设置为version值 + 1,而WHERE条件中的version是你查询出来的旧值。你手动加一,会导致WHERE条件永远不匹配,更新永远失败。
  • 更新前务必先查询:乐观锁更新必须基于一个从数据库最新查询出来的实体对象(包含最新的version值)。你不能自己new一个Product,只设置id和要改的字段就去更新,那样会丢失version信息,导致乐观锁失效。
    // 错误示范 Product updateProduct = new Product(); updateProduct.setId(1L); updateProduct.setStock(newStock); productService.updateById(updateProduct); // 乐观锁失效! // 正确示范 Product product = productService.getById(1L); product.setStock(newStock); productService.updateById(product); // MP会自动处理version

3.3 更新方法的区别与选择

MP提供了多个更新方法,对乐观锁的支持程度不同:

  1. updateById(T entity)这是乐观锁的“黄金搭档”。它根据实体对象的主键和@Version字段进行更新,完全支持乐观锁。是最常用、最安全的方式。

  2. update(T entity, Wrapper<T> updateWrapper):这个方法不支持自动乐观锁!即使你的实体对象里有version字段,MP也不会自动将其加入到WHERE条件中。因为Wrapper提供了高度自定义的更新条件,MP无法判断你是否还想包含version条件。

    // 示例:根据条件更新库存,但此方法不支持自动乐观锁 LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Product::getName, "iPhone15"); wrapper.set(Product::getStock, 10); Product entity = new Product(); entity.setVersion(5); // 这行代码是无效的,version不会作为条件 productService.update(entity, wrapper); // 生成的SQL: UPDATE t_product SET stock=10 WHERE name = 'iPhone15'; // 注意:WHERE条件里没有 version=5

    如果你想在条件更新中使用乐观锁,必须手动将version条件添加到Wrapper中

    LambdaUpdateWrapper<Product> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(Product::getName, "iPhone15") .eq(Product::getVersion, oldVersion); // 手动添加版本条件 wrapper.set(Product::getStock, 10); // 同时,set子句中的version也需要手动+1 wrapper.setSql("version = version + 1"); productService.update(null, wrapper); // entity参数可以传null
  3. saveOrUpdate(T entity):这个方法在判断是插入还是更新时,如果走更新逻辑,其内部调用的是updateById,因此支持乐观锁

核心选择建议单条记录的精确更新,无脑用updateById。涉及批量或复杂条件更新,需要手动在Wrapper中处理乐观锁逻辑。

4. 实操过程与核心环节实现

让我们通过一个完整的“商品扣减库存”场景,来串联整个乐观锁的使用流程。

4.1 场景搭建与基础代码

假设我们有一个ProductService和对应的ProductServiceImpl

@Service public class ProductServiceImpl extends ServiceImpl<ProductMapper, Product> implements ProductService { /** * 扣减库存(基础版,无重试) * @param productId 商品ID * @param quantity 扣减数量 * @return 是否扣减成功 */ @Override @Transactional(rollbackFor = Exception.class) // 添加事务 public boolean deductStock(Long productId, Integer quantity) { // 1. 查询最新商品信息(携带version) Product product = this.getById(productId); if (product == null) { throw new RuntimeException("商品不存在"); } // 2. 校验库存 Integer currentStock = product.getStock(); if (currentStock < quantity) { throw new RuntimeException("库存不足"); } // 3. 计算新库存 product.setStock(currentStock - quantity); // 4. 执行更新(MP会自动处理version的WHERE条件和SET+1) boolean isSuccess = this.updateById(product); if (!isSuccess) { // 更新失败,说明数据已被其他线程修改 throw new RuntimeException("库存更新失败,请重试"); } // 5. 更新成功,执行后续业务逻辑(如创建订单) // createOrder(...); return true; } }

这个基础版的问题在于,一旦发生乐观锁冲突(updateById返回false),就直接抛异常给用户了,体验不好。在高并发下,冲突是常态而非异常,我们需要重试机制

4.2 引入重试机制增强鲁棒性

重试是乐观锁方案不可或缺的一部分。我们可以使用Spring的@Retryable注解(需要引入spring-retry依赖)或手动循环来实现。

这里展示一个手动循环的重试方案,更直观:

@Override @Transactional(rollbackFor = Exception.class) public boolean deductStockWithRetry(Long productId, Integer quantity) { // 定义最大重试次数 int maxRetries = 3; int retryCount = 0; while (retryCount < maxRetries) { retryCount++; try { Product product = this.getById(productId); if (product == null) { throw new RuntimeException("商品不存在"); } if (product.getStock() < quantity) { throw new RuntimeException("库存不足"); } product.setStock(product.getStock() - quantity); boolean isSuccess = this.updateById(product); if (isSuccess) { log.info("库存扣减成功,商品ID:{}, 重试次数:{}", productId, retryCount); // 后续业务... return true; } else { log.warn("库存扣减乐观锁冲突,商品ID:{}, 进行第{}次重试", productId, retryCount); // 更新失败,等待一小段时间后重试,避免活锁 Thread.sleep(50); // 简单等待,生产环境可用随机时间 } } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("重试过程被中断", e); } catch (Exception e) { // 其他异常(如库存不足、商品不存在)直接抛出,不重试 throw e; } } // 重试耗尽仍未成功 log.error("库存扣减失败,已达到最大重试次数,商品ID:{}", productId); throw new RuntimeException("系统繁忙,请稍后再试"); }

重试策略要点

  • 重试次数:不宜过多,通常3-5次,避免长时间阻塞。
  • 等待时间:冲突后立即重试可能再次冲突,建议等待一小段随机时间(如Thread.sleep(new Random().nextInt(100))),这被称为“指数退避”策略的简化版。
  • 异常区分:只对乐观锁更新失败进行重试。像“库存不足”、“商品不存在”这类业务异常,应立即失败。
  • 事务边界:注意@Transactional注解的范围。上述代码中,每次重试的查询和更新都在同一个事务里,这是正确的。如果事务范围不对,可能导致“读已提交”隔离级别下,重试时读不到其他事务已提交的最新数据。

4.3 结合多租户等高级场景

从热词中我们看到“基于mybatis-plus的多租户注解实现”。在实际项目中,乐观锁经常和多租户(@TenantId)等其他MP插件一起使用。配置时需要注意插件的执行顺序

MybatisPlusInterceptor采用的是责任链模式,添加的顺序就是执行的顺序。

@Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 1. 先添加多租户插件(如果有多租户需求) interceptor.addInnerInterceptor(new TenantLineInnerInterceptor(new TenantLineHandler() { @Override public String getTenantIdColumn() { return "tenant_id"; } @Override public Expression getTenantId() { // 从当前上下文中获取租户ID return new StringValue("your_tenant_id"); } // 忽略某些表的方法省略... })); // 2. 再添加乐观锁插件 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); // 还可以添加分页插件、动态表名插件等 // interceptor.addInnerInterceptor(new PaginationInnerInterceptor()); return interceptor; }

顺序很重要:理论上,多租户插件需要在乐观锁插件之前执行。因为多租户插件负责在SQL的WHERE条件中自动添加tenant_id = ?,这个条件应该和id = ? AND version = ?一起构成最终的更新条件。如果顺序反了,可能导致SQL组装异常。

5. 常见问题与排查技巧实录

即使理解了原理,实战中还是会遇到各种“坑”。下面是我总结的几个典型问题及解决方案。

5.1 更新返回影响行数为0,但程序不报错?

这是最让人困惑的情况之一。updateById方法返回的是booleantrue表示成功,false表示失败。但MP的ServiceImpl默认的updateById方法,在返回false时并不会抛出异常。

// 来自 com.baomidou.mybatisplus.extension.service.IService default boolean updateById(T entity) { return getBaseMapper().updateById(entity) == 1; }

它只是简单地判断受影响行数是否为1。所以,如果你像最开始的例子那样,直接判断if (!isSuccess) { ... },逻辑是正常的。但如果你忽略了返回值,或者调用方以为失败会抛异常,那就出问题了。

排查步骤

  1. 检查返回值:确保你的业务代码处理了updateById返回的false
  2. 检查版本号:通过日志或调试,确认你用于更新的实体对象,其version值是否是从数据库最新查询出来的。是不是不小心在某个地方new了一个对象,或者被其他代码修改了version字段。
  3. 检查SQL日志:开启MP的SQL日志(mybatis-plus.configuration.log-impl=org.apache.ibatis.logging.stdout.StdOutImpl),查看实际执行的SQL语句。重点看WHERE条件里的version值是否正确,以及SET子句中是否包含了version=原值+1

5.2 自定义的UpdateWrapper更新不生效?

正如3.3节所述,使用update(T entity, Wrapper<T> updateWrapper)方法时,乐观锁不会自动生效。你必须手动在Wrapper中添加版本条件。

问题复现与解决

// 错误代码:乐观锁失效 LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(User::getStatus, 1); wrapper.set(User::getName, "UpdatedName"); userService.update(new User(), wrapper); // 即使User对象有version,也不会被加入条件 // 正确代码:手动添加乐观锁 LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(User::getStatus, 1) .eq(User::getVersion, 5); // 手动添加版本条件 wrapper.set(User::getName, "UpdatedName"); wrapper.setSql("version = version + 1"); // 手动设置版本自增 userService.update(null, wrapper); // entity参数传null即可

5.3 关于“updateById可以修改字段值为null吗”的热词解答

这是一个非常常见的问题。MP的updateById方法默认使用的是“非null更新”策略。意思是,当你传入一个实体对象时,MP只会更新那些值不为null的字段。

User user = new User(); user.setId(1L); user.setName(null); // 显式设置为null user.setAge(20); userService.updateById(user);

生成的SQL可能是:UPDATE user SET age=20 WHERE id=1 AND version=?name字段因为值为null,不会被加入到SET子句中,因此数据库中的name字段保持不变,而不会被更新为NULL

如果你确实需要将某个字段更新为NULL,有几种方法:

  1. 使用UpdateWrapper

    LambdaUpdateWrapper<User> wrapper = new LambdaUpdateWrapper<>(); wrapper.eq(User::getId, 1L) .set(User::getName, null) // 使用Wrapper的set方法可以设置null .set(User::getAge, 20); userService.update(null, wrapper);
  2. 在实体类字段上使用@TableField(strategy = FieldStrategy.IGNORED)注解(不推荐)

    @TableField(strategy = FieldStrategy.IGNORED) private String name;

    这个策略会忽略所有判断,即使字段为null也会参与更新。但这是全局设置,风险很高,容易导致误操作。

  3. 使用MP的alwaysUpdateSomeColumnById方法(需要配置插件): 这是一个激进的方法,配置后updateById会更新所有字段,无论是否为null。同样不推荐在常规场景使用。

最佳实践建议明确你的更新意图。如果业务上就是需要将某个字段置空,那么使用UpdateWrapper是最清晰、最安全的方式。不要滥用全局策略注解。

5.4 高并发下的大量重试导致性能问题?

乐观锁+重试在冲突率低时性能很好,但如果冲突率很高(比如秒杀场景初期),大量线程不断重试查询和更新,会对数据库造成压力。

优化思路

  1. 减少锁粒度:如果可能,将库存字段拆分成多行(如分桶库存),让并发请求分散到不同的数据行上,减少单行数据的竞争。
  2. 前置校验与限流:在进入数据库扣减流程前,用Redis等内存中间件做一个粗略的库存扣减或计数器,拦截掉大部分无效请求。
  3. 结合悲观锁或队列:对于极端高并发场景,可以在最核心的扣减步骤使用数据库悲观锁(SELECT ... FOR UPDATE),或者使用消息队列串行化处理请求。此时乐观锁可能不是最优选,需要根据业务特点做架构上的权衡。

5.5 版本号字段类型为Date/Timestamp的坑

@Version支持DateTimestamp类型。MP会将其当前时间戳(毫秒)作为新版本号。但这会带来一个问题:更新过于频繁时,时间戳可能相同。如果两个并发事务在同一毫秒内完成查询和更新,它们可能持有相同的旧版本号(时间戳),导致两者都更新成功,破坏了乐观锁的互斥性。

强烈建议使用自增整数(IntegerLong)作为版本号字段类型。数据库保证每次更新version = version + 1后,新值一定是唯一的、递增的,彻底杜绝因时间戳重复导致的锁失效问题。

6. 性能监控与最佳实践总结

6.1 如何监控乐观锁的健康状况?

一个健壮的系统需要可观测性。对于乐观锁,我们可以关注几个指标:

  • 冲突率(乐观锁更新失败次数) / (总更新尝试次数)。可以在updateById返回false的地方进行计数,并上报到监控系统(如Prometheus)。如果冲突率持续过高,说明业务竞争激烈,可能需要考虑上述的优化方案。
  • 重试次数分布:记录每次请求最终成功前所经历的重试次数。这有助于评估重试策略的有效性和设置合理的最大重试次数。
  • SQL监控:通过APM工具(如SkyWalking, Arthas)监控带有WHERE ... AND version=?条件的UPDATE语句的执行时间和频率。

6.2 娴熟运用的最佳实践清单

  1. 表设计先行:为需要并发控制的表加上version字段(INT/BIGINT,默认0)。
  2. 实体类标注:在对应字段上加@Version注解,使用IntegerLong类型。
  3. 插件配置:在配置类中按正确顺序添加OptimisticLockerInnerInterceptor
  4. 更新用updateById:单条更新优先使用updateById(entity),它自动支持乐观锁。
  5. 更新前先查询:确保用于更新的实体对象是从数据库查询出的最新对象。
  6. 永远不要手动setVersion:将version字段的管理完全交给MP。
  7. Wrapper更新需手动:使用update(entity, wrapper)时,记得在Wrapper中手动添加.eq(version)条件和.setSql("version = version + 1")
  8. 必须实现重试逻辑:乐观锁更新失败是正常流程,业务层必须捕获并实现重试机制(如循环重试或Spring Retry)。
  9. 合理设置重试参数:设置最大重试次数(如3次)和重试间隔(如随机退避)。
  10. 区分异常与冲突:只有乐观锁更新失败才触发重试,业务异常(如库存不足)应直接失败。
  11. 关注事务边界:确保重试循环内的查询和更新在同一个事务内,避免不可重复读等问题。
  12. 监控与告警:建立冲突率等监控指标,及时发现性能瓶颈。

说到底,MyBatis-Plus的乐观锁是一个工具,它把复杂的并发控制逻辑简化成了一个注解和一次配置。但工具用得好不好,取决于使用者是否理解了其背后的原理和边界条件。真正娴熟的运用,是在理解“比较并交换”这一核心思想的基础上,结合具体的业务场景,做出正确的设计选择(何时用乐观锁?重试策略如何定?),并妥善处理所有边界情况。当你面对高并发数据更新不再心慌,能够清晰地分析出是哪里出现了竞争,并知道如何优雅地解决它时,你才算真正掌握了这把利器。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询