尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

MyBatis-Plus乐观锁实战:原理、配置与高并发场景应用

MyBatis-Plus乐观锁实战:原理、配置与高并发场景应用 1. 项目概述为什么我们需要乐观锁在开发一个涉及数据并发修改的后端服务时我猜你一定遇到过这样的场景一个商品库存还剩最后一件两个用户几乎同时点击了“立即购买”。如果不做任何处理数据库里很可能会记录下两次成功的扣减最终库存变成-1这显然是个大问题。这就是典型的“丢失更新”问题。为了解决它我们通常会上“锁”。悲观锁大家可能比较熟比如SELECT ... FOR UPDATE它假设冲突总会发生所以在操作前就先锁住数据别人只能干等着。这种方式在并发高的场景下性能开销大容易造成线程阻塞。而乐观锁则采取了一种更“乐观”的策略它假设冲突不经常发生因此允许多个事务同时读取数据但在更新时会检查数据自读取以来是否被其他事务修改过。如果没被改过就更新成功如果被改过了就更新失败通常需要业务层决定重试或提示用户。这种机制特别适合读多写少、冲突概率较低的场景比如电商的库存扣减虽然冲突概率不低但通过重试机制可以很好处理、账户余额更新、文章点赞计数等。MyBatis-Plus简称MP作为MyBatis的增强工具对乐观锁提供了非常优雅的开箱即用支持。它通过一个Version注解几乎零成本地帮我们实现了这套机制。今天我就结合自己踩过的坑和实战经验来聊聊如何娴熟地运用MP的乐观锁让它真正成为你高并发业务中的“定海神针”而不是一个摆设。2. 核心原理与设计思路拆解2.1 乐观锁的实现机制剖析MP的乐观锁实现本质上是对CASCompare And Swap比较并交换思想的一种封装。我们来看看它的核心工作流程版本号标记在数据库表中需要额外添加一个整型字段通常命名为version这个字段就是数据的“版本标识”。读取与携带当从数据库查询出一条记录时这个version字段的值会随着其他字段一起被加载到实体对象中。更新与校验当执行更新操作如updateById时MP会自动将UPDATE语句构造成这样UPDATE user SET name新名字, version2 WHERE id1 AND version1;注意看WHERE条件它同时用id和旧的version值这里是1作为条件。结果判定执行这条SQL后数据库会返回受影响的行数affected rows。如果返回1说明id1且version1的记录存在更新成功并且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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 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会自动处理version3.3 更新方法的区别与选择MP提供了多个更新方法对乐观锁的支持程度不同updateById(T entity)这是乐观锁的“黄金搭档”。它根据实体对象的主键和Version字段进行更新完全支持乐观锁。是最常用、最安全的方式。update(T entity, WrapperT updateWrapper)这个方法不支持自动乐观锁即使你的实体对象里有version字段MP也不会自动将其加入到WHERE条件中。因为Wrapper提供了高度自定义的更新条件MP无法判断你是否还想包含version条件。// 示例根据条件更新库存但此方法不支持自动乐观锁 LambdaUpdateWrapperProduct 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 stock10 WHERE name iPhone15; // 注意WHERE条件里没有 version5如果你想在条件更新中使用乐观锁必须手动将version条件添加到Wrapper中LambdaUpdateWrapperProduct 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 ServiceImplProductMapper, 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条件和SET1 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) { ... }逻辑是正常的。但如果你忽略了返回值或者调用方以为失败会抛异常那就出问题了。排查步骤检查返回值确保你的业务代码处理了updateById返回的false。检查版本号通过日志或调试确认你用于更新的实体对象其version值是否是从数据库最新查询出来的。是不是不小心在某个地方new了一个对象或者被其他代码修改了version字段。检查SQL日志开启MP的SQL日志mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl查看实际执行的SQL语句。重点看WHERE条件里的version值是否正确以及SET子句中是否包含了version原值1。5.2 自定义的UpdateWrapper更新不生效正如3.3节所述使用update(T entity, WrapperT updateWrapper)方法时乐观锁不会自动生效。你必须手动在Wrapper中添加版本条件。问题复现与解决// 错误代码乐观锁失效 LambdaUpdateWrapperUser wrapper new LambdaUpdateWrapper(); wrapper.eq(User::getStatus, 1); wrapper.set(User::getName, UpdatedName); userService.update(new User(), wrapper); // 即使User对象有version也不会被加入条件 // 正确代码手动添加乐观锁 LambdaUpdateWrapperUser 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 age20 WHERE id1 AND version?。name字段因为值为null不会被加入到SET子句中因此数据库中的name字段保持不变而不会被更新为NULL。如果你确实需要将某个字段更新为NULL有几种方法使用UpdateWrapperLambdaUpdateWrapperUser 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的乐观锁是一个工具它把复杂的并发控制逻辑简化成了一个注解和一次配置。但工具用得好不好取决于使用者是否理解了其背后的原理和边界条件。真正娴熟的运用是在理解“比较并交换”这一核心思想的基础上结合具体的业务场景做出正确的设计选择何时用乐观锁重试策略如何定并妥善处理所有边界情况。当你面对高并发数据更新不再心慌能够清晰地分析出是哪里出现了竞争并知道如何优雅地解决它时你才算真正掌握了这把利器。
返回列表