1. 项目概述:为什么我们需要乐观锁?
在开发一个涉及数据并发修改的后端服务时,我猜你一定遇到过这样的场景:一个商品库存还剩最后一件,两个用户几乎同时点击了“立即购买”。如果不做任何处理,数据库里很可能会记录下两次成功的扣减,最终库存变成-1,这显然是个大问题。这就是典型的“丢失更新”问题。为了解决它,我们通常会上“锁”。悲观锁大家可能比较熟,比如SELECT ... FOR UPDATE,它假设冲突总会发生,所以在操作前就先锁住数据,别人只能干等着。这种方式在并发高的场景下,性能开销大,容易造成线程阻塞。
而乐观锁则采取了一种更“乐观”的策略:它假设冲突不经常发生,因此允许多个事务同时读取数据,但在更新时,会检查数据自读取以来是否被其他事务修改过。如果没被改过,就更新成功;如果被改过了,就更新失败,通常需要业务层决定重试或提示用户。这种机制特别适合读多写少、冲突概率较低的场景,比如电商的库存扣减(虽然冲突概率不低,但通过重试机制可以很好处理)、账户余额更新、文章点赞计数等。
MyBatis-Plus(简称MP)作为MyBatis的增强工具,对乐观锁提供了非常优雅的开箱即用支持。它通过一个@Version注解,几乎零成本地帮我们实现了这套机制。今天,我就结合自己踩过的坑和实战经验,来聊聊如何娴熟地运用MP的乐观锁,让它真正成为你高并发业务中的“定海神针”,而不是一个摆设。
2. 核心原理与设计思路拆解
2.1 乐观锁的实现机制剖析
MP的乐观锁实现,本质上是对CAS(Compare And Swap,比较并交换)思想的一种封装。我们来看看它的核心工作流程:
- 版本号标记:在数据库表中,需要额外添加一个整型字段(通常命名为
version),这个字段就是数据的“版本标识”。 - 读取与携带:当从数据库查询出一条记录时,这个
version字段的值会随着其他字段一起被加载到实体对象中。 - 更新与校验:当执行更新操作(如
updateById)时,MP会自动将UPDATE语句构造成这样:
注意看UPDATE user SET name='新名字', version=2 WHERE id=1 AND version=1;WHERE条件,它同时用id和旧的version值(这里是1)作为条件。 - 结果判定:执行这条SQL后,数据库会返回受影响的行数(
affected rows)。如果返回1,说明id=1且version=1的记录存在,更新成功,并且version被设置为2。如果返回0,则说明在本次更新准备期间,已经有其他操作修改了这条数据(导致version不再是1),本次更新失败。
这个流程的精妙之处在于,整个“读取-判断-写入”的原子性是由数据库的单条UPDATE语句保证的。数据库引擎会确保这条语句的执行是原子的,从而避免了并发下的竞态条件。
2.2 MyBatis-Plus的优雅封装
MP没有让我们自己去拼写这个WHERE version = ?的条件。它通过以下两步实现透明化:
- 实体类标注:在实体类的版本字段上加上
@Version注解。@Data public class User { private Long id; private String name; @Version private Integer version; // ... 其他字段 } - 插件配置:在MyBatis的配置类中,添加
OptimisticLockerInnerInterceptor插件。@Configuration public class MybatisPlusConfig { @Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor(); // 添加乐观锁插件 interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor()); return interceptor; } }
配置完成后,所有通过MP的updateById和update方法(Wrapper条件更新除外,后面会细说)进行的操作,都会自动带上乐观锁逻辑。作为开发者,你几乎感知不到它的存在,只需要关心更新失败后的业务处理即可。
注意:
@Version注解标识的字段,MP只支持Integer,Long,Date/Timestamp类型。推荐使用Integer或Long。一旦字段被标记,它就会被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,但需要和实体类属性名对应。 - 类型:
INT或BIGINT。 - 默认值:通常设置为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提供了多个更新方法,对乐观锁的支持程度不同:
updateById(T entity):这是乐观锁的“黄金搭档”。它根据实体对象的主键和@Version字段进行更新,完全支持乐观锁。是最常用、最安全的方式。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参数可以传nullsaveOrUpdate(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方法返回的是boolean,true表示成功,false表示失败。但MP的ServiceImpl默认的updateById方法,在返回false时并不会抛出异常。
// 来自 com.baomidou.mybatisplus.extension.service.IService default boolean updateById(T entity) { return getBaseMapper().updateById(entity) == 1; }它只是简单地判断受影响行数是否为1。所以,如果你像最开始的例子那样,直接判断if (!isSuccess) { ... },逻辑是正常的。但如果你忽略了返回值,或者调用方以为失败会抛异常,那就出问题了。
排查步骤:
- 检查返回值:确保你的业务代码处理了
updateById返回的false。 - 检查版本号:通过日志或调试,确认你用于更新的实体对象,其
version值是否是从数据库最新查询出来的。是不是不小心在某个地方new了一个对象,或者被其他代码修改了version字段。 - 检查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,有几种方法:
使用
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);在实体类字段上使用
@TableField(strategy = FieldStrategy.IGNORED)注解(不推荐):@TableField(strategy = FieldStrategy.IGNORED) private String name;这个策略会忽略所有判断,即使字段为null也会参与更新。但这是全局设置,风险很高,容易导致误操作。
使用MP的
alwaysUpdateSomeColumnById方法(需要配置插件): 这是一个激进的方法,配置后updateById会更新所有字段,无论是否为null。同样不推荐在常规场景使用。
最佳实践建议:明确你的更新意图。如果业务上就是需要将某个字段置空,那么使用UpdateWrapper是最清晰、最安全的方式。不要滥用全局策略注解。
5.4 高并发下的大量重试导致性能问题?
乐观锁+重试在冲突率低时性能很好,但如果冲突率很高(比如秒杀场景初期),大量线程不断重试查询和更新,会对数据库造成压力。
优化思路:
- 减少锁粒度:如果可能,将库存字段拆分成多行(如分桶库存),让并发请求分散到不同的数据行上,减少单行数据的竞争。
- 前置校验与限流:在进入数据库扣减流程前,用Redis等内存中间件做一个粗略的库存扣减或计数器,拦截掉大部分无效请求。
- 结合悲观锁或队列:对于极端高并发场景,可以在最核心的扣减步骤使用数据库悲观锁(
SELECT ... FOR UPDATE),或者使用消息队列串行化处理请求。此时乐观锁可能不是最优选,需要根据业务特点做架构上的权衡。
5.5 版本号字段类型为Date/Timestamp的坑
@Version支持Date或Timestamp类型。MP会将其当前时间戳(毫秒)作为新版本号。但这会带来一个问题:更新过于频繁时,时间戳可能相同。如果两个并发事务在同一毫秒内完成查询和更新,它们可能持有相同的旧版本号(时间戳),导致两者都更新成功,破坏了乐观锁的互斥性。
强烈建议:使用自增整数(Integer或Long)作为版本号字段类型。数据库保证每次更新version = version + 1后,新值一定是唯一的、递增的,彻底杜绝因时间戳重复导致的锁失效问题。
6. 性能监控与最佳实践总结
6.1 如何监控乐观锁的健康状况?
一个健壮的系统需要可观测性。对于乐观锁,我们可以关注几个指标:
- 冲突率:
(乐观锁更新失败次数) / (总更新尝试次数)。可以在updateById返回false的地方进行计数,并上报到监控系统(如Prometheus)。如果冲突率持续过高,说明业务竞争激烈,可能需要考虑上述的优化方案。 - 重试次数分布:记录每次请求最终成功前所经历的重试次数。这有助于评估重试策略的有效性和设置合理的最大重试次数。
- SQL监控:通过APM工具(如SkyWalking, Arthas)监控带有
WHERE ... AND version=?条件的UPDATE语句的执行时间和频率。
6.2 娴熟运用的最佳实践清单
- 表设计先行:为需要并发控制的表加上
version字段(INT/BIGINT,默认0)。 - 实体类标注:在对应字段上加
@Version注解,使用Integer或Long类型。 - 插件配置:在配置类中按正确顺序添加
OptimisticLockerInnerInterceptor。 - 更新用
updateById:单条更新优先使用updateById(entity),它自动支持乐观锁。 - 更新前先查询:确保用于更新的实体对象是从数据库查询出的最新对象。
- 永远不要手动setVersion:将version字段的管理完全交给MP。
- Wrapper更新需手动:使用
update(entity, wrapper)时,记得在Wrapper中手动添加.eq(version)条件和.setSql("version = version + 1")。 - 必须实现重试逻辑:乐观锁更新失败是正常流程,业务层必须捕获并实现重试机制(如循环重试或Spring Retry)。
- 合理设置重试参数:设置最大重试次数(如3次)和重试间隔(如随机退避)。
- 区分异常与冲突:只有乐观锁更新失败才触发重试,业务异常(如库存不足)应直接失败。
- 关注事务边界:确保重试循环内的查询和更新在同一个事务内,避免不可重复读等问题。
- 监控与告警:建立冲突率等监控指标,及时发现性能瓶颈。
说到底,MyBatis-Plus的乐观锁是一个工具,它把复杂的并发控制逻辑简化成了一个注解和一次配置。但工具用得好不好,取决于使用者是否理解了其背后的原理和边界条件。真正娴熟的运用,是在理解“比较并交换”这一核心思想的基础上,结合具体的业务场景,做出正确的设计选择(何时用乐观锁?重试策略如何定?),并妥善处理所有边界情况。当你面对高并发数据更新不再心慌,能够清晰地分析出是哪里出现了竞争,并知道如何优雅地解决它时,你才算真正掌握了这把利器。