
1. 项目概述为什么我们需要乐观锁在后台服务开发里处理并发数据更新是个绕不开的坎。想象一个场景电商系统的商品库存或者内容平台的点赞数同一时间可能有成百上千个请求涌进来都想修改同一条数据。如果只是简单地update table set stock stock - 1 where id 1在高并发下很可能出现超卖或者数据覆盖导致最终结果和预期对不上。这就是典型的并发更新冲突问题。解决这类问题数据库层面有“悲观锁”比如select ... for update它假设冲突总会发生所以在操作前就先锁住数据别人只能等着。这种方式虽然安全但代价是性能尤其是在读多写少的场景下大量的锁等待会拖慢整个系统。于是“乐观锁”就成了更优雅的选择。它的核心思想很乐观假设冲突不常发生所以操作前不上锁。那怎么保证安全呢它引入了一个版本号或时间戳机制。每次读取数据时把当前的版本号一起读出来更新时除了设置新数据还必须带上这个版本号作为条件update table set stock new_stock, version version 1 where id 1 and version old_version。如果更新时发现数据库里的版本号和自己持有的不一致说明这条数据在自己读取之后已经被别人改过了那么本次更新就会失败影响行数为0。这时业务代码可以决定是重试、抛异常还是做其他处理。MyBatis-Plus简称MP作为MyBatis的增强工具极大地简化了CRUD操作。它内置了对乐观锁的优雅支持让你无需手动拼接那些繁琐的version条件语句通过简单的注解和配置就能让乐观锁在项目中自动生效。这对于开发需要处理并发更新的后台管理系统、电商平台、社交应用等场景的开发者来说是一个必备的技能点。接下来我们就深入看看MP里乐观锁怎么玩得转。2. 乐观锁的核心原理与MP实现机制2.1 乐观锁的底层逻辑与数据表设计乐观锁的实现离不开数据库表结构的一个小改动你需要添加一个专门的字段来充当版本标识。通常这个字段命名为version类型是整数INT或BIGINT即可。每次执行更新操作时这个字段的值都会自动加1。它的工作流程可以拆解为以下几步查询并获取版本号当你通过ID查询一条记录时需要把version字段的值也查出来。例如查询商品ID为1001的记录得到stock10, version5。执行业务逻辑在内存中计算新的数据。比如用户购买一件商品新的库存new_stock 10 - 1 9。执行带版本校验的更新执行更新SQL时将版本号作为更新条件的一部分。SQL类似于UPDATE product SET stock9, version6 WHERE id1001 AND version5。判断更新结果成功如果这条SQL影响了1行数据说明在本次更新前没有其他操作修改过这条记录因为version还是5更新成功并且version被更新为6。失败如果影响了0行数据说明在你读取数据version5之后、执行更新之前已经有其他请求成功更新了这条数据此时数据库中的version可能已经是6或更大。本次更新因条件不匹配而失败数据没有被修改。这个机制完美解决了“丢失更新”的问题。它把并发控制的压力从数据库锁转移到了应用层通过一次额外的条件判断来保证数据一致性在并发冲突不频繁的场景下性能优势明显。2.2 MyBatis-Plus的自动化实现MyBatis-Plus的聪明之处在于它通过拦截器机制把上面第3步中“自动追加版本条件并更新版本号”这个重复性劳动给自动化了。你不需要在每一个UPDATE语句里手动写SET versionversion1和WHERE version#{oldVersion}。MP实现乐观锁的核心是一个叫做OptimisticLockerInnerInterceptor的插件拦截器。一旦你配置了这个插件并在实体类的版本字段上加了Version注解那么当你使用MP提供的updateById或update方法时拦截器会默默地在背后做三件事检查实体对象中Version注解的字段是否有值不能为null。将该字段的值作为等值条件拼接到WHERE语句中例如WHERE id1 AND version2。在SET语句中将该字段的值设置为“原值1”例如SET ..., version3。这一切对开发者都是透明的。你只需要关心业务逻辑当更新返回的影响行数为0时就知道发生了并发冲突然后根据业务需求决定是提示用户“数据已变更请刷新重试”还是在服务层进行自动重试。注意MP的乐观锁插件仅对updateById(id)和update(entity, updateWrapper)方法生效。对于自定义的SQL或者直接使用updateWrapper.set()而不传入实体对象的情况乐观锁插件是不会自动介入的。这一点是实践中的关键。3. 从零开始在项目中配置与使用乐观锁3.1 第一步修改实体类添加版本字段首先在你的实体类对应数据库表中添加一个整数类型的字段并使用Version注解进行标记。这是MP识别乐观锁字段的关键。import com.baomidou.mybatisplus.annotation.*; import lombok.Data; Data TableName(t_product) // 假设你的表名是 t_product public class Product { TableId(type IdType.AUTO) private Long id; private String name; private Integer stock; // 乐观锁版本字段 Version private Integer version; // 其他字段... private LocalDateTime updateTime; }关键点解析字段类型通常使用Integer或Long。从null开始或从一个初始值如0开始都可以。第一次更新时MP会将其设置为1。Version注解必须加这是MP的契约。数据库表别忘了在对应的数据库表t_product中也添加version字段建议设置默认值为0 (DEFAULT 0)。3.2 第二步配置乐观锁插件接下来你需要将乐观锁拦截器配置到MyBatis-Plus的插件链中。在Spring Boot项目中通常在一个配置类里完成。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()); // 你可以继续添加其他插件比如分页插件 // interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }配置完成后MP的乐观锁功能就全局启用了。所有带有Version注解的实体在使用MP提供的标准更新方法时都会自动生效。3.3 第三步在业务中进行并发更新测试现在让我们写一个简单的服务方法模拟并发减库存的场景看看乐观锁如何工作。import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; Service public class ProductService { Resource private ProductMapper productMapper; /** * 购买商品扣减库存使用乐观锁 * param productId 商品ID * param quantity 购买数量 * return 是否购买成功 */ Transactional(rollbackFor Exception.class) public boolean purchaseWithOptimisticLock(Long productId, Integer quantity) { // 1. 查询商品携带版本号 Product product productMapper.selectById(productId); if (product null) { throw new RuntimeException(商品不存在); } // 检查库存是否充足 if (product.getStock() quantity) { throw new RuntimeException(库存不足); } // 2. 在内存中计算新库存 product.setStock(product.getStock() - quantity); // 注意此时 product 对象里的 version 字段是从数据库查出来的旧版本号 // 3. 使用 updateById 进行更新 // MP会自动将 product 中的 version 值作为 WHERE 条件并执行 version version 1 int updateCount productMapper.updateById(product); // 4. 判断更新结果 if (updateCount 0) { // 更新失败说明发生了并发冲突数据已被其他请求修改 // 这里可以进行重试。例如使用简单的for循环重试3次 for (int i 0; i 3; i) { product productMapper.selectById(productId); if (product.getStock() quantity) { throw new RuntimeException(重试过程中库存不足); } product.setStock(product.getStock() - quantity); updateCount productMapper.updateById(product); if (updateCount 0) { return true; // 重试成功 } // 重试间隔避免活锁 try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } // 重试多次后仍然失败抛出异常或返回失败 throw new RuntimeException(系统繁忙请稍后重试); } // 更新成功 return true; } }代码逻辑解读selectById查询时version字段被一同查询出来。计算新库存此时内存中的product对象持有旧的version值。updateById(product)MP会生成SQLUPDATE t_product SET stock?, version?1 WHERE id? AND version?。如果其他请求先更新成功version已变则此条SQL条件不匹配影响行数updateCount为0。根据updateCount判断成功与否。失败后示例中采用了有限次重试的策略这是一种常见的乐观锁冲突处理方式。4. 深入实践高级场景与避坑指南4.1 使用UpdateWrapper时的注意事项MP的乐观锁插件在update(T entity, WrapperT updateWrapper)方法中也能生效但有一个极易踩坑的细节。正确用法你需要将带有版本号的实体对象作为第一个参数传入。public boolean updateProductName(Long id, String newName) { Product product productMapper.selectById(id); if (product null) return false; UpdateWrapperProduct wrapper new UpdateWrapper(); wrapper.eq(category_id, 10); // 额外的更新条件 // 设置要更新的字段非版本字段 product.setName(newName); // 将携带旧version的product对象传入 int rows productMapper.update(product, wrapper); // 生成的SQL: UPDATE t_product SET name?, version?1 WHERE id? AND version? AND category_id10 return rows 0; }错误用法如果使用update(WrapperT updateWrapper)方法并且只在Wrapper中通过.set()设置字段乐观锁将完全失效因为它没有实体对象来获取版本号。// 错误示例乐观锁失效 UpdateWrapperProduct wrapper new UpdateWrapper(); wrapper.eq(id, 1) .set(stock, 5); // 直接set字段 // 这里没有传入实体对象拦截器无法获取版本信息 productMapper.update(null, wrapper); // 生成的SQL: UPDATE t_product SET stock5 WHERE id1 没有version条件实操心得在使用Wrapper进行更新时养成习惯总是先查询出实体或至少构造一个包含ID和当前version的实体然后将其作为第一个参数传入update(entity, wrapper)方法。这是保证乐观锁生效的铁律。4.2 与多租户注解的协同工作“多租户”是SaaS系统的常见需求即一套系统为多个客户租户服务数据在逻辑或物理上隔离。MP提供了TenantId注解来实现透明的数据隔离。当乐观锁遇到多租户时它们是如何协作的呢实际上MP的插件链是有顺序的。通常多租户插件 (TenantLineInnerInterceptor) 会在乐观锁插件之前执行。这意味着执行的SQL会先加上租户条件再加上版本条件。假设你的实体类如下Data TableName(t_order) public class Order { TableId private Long id; private String orderNo; TenantId // 多租户ID字段 private String tenantId; Version // 乐观锁版本字段 private Integer version; }当你执行updateById(order)时MP最终生成的SQL会类似于UPDATE t_order SET order_no?, version?1 WHERE id? AND tenant_id当前租户ID AND version?协同工作流程多租户插件根据当前线程上下文通常从TenantContextHolder获取自动将tenant_idxxx条件注入到WHERE子句。乐观锁插件接着注入AND versionxxx条件并修改SET子句中的version。这样更新操作既保证了数据只会在当前租户内被更新又通过乐观锁避免了租户内的并发冲突。注意事项在多租户环境下你的版本冲突重试逻辑需要确保在正确的租户上下文下进行重试查询避免查到其他租户的数据。4.3 关于updateById能否将字段更新为null的问题这是一个非常具体但常见的问题。默认情况下MP的updateById方法不会更新值为null的字段。这是MP的设计策略目的是防止在部分更新时意外地用null覆盖了数据库中的已有值。例如Product product new Product(); product.setId(1L); product.setName(null); // 显式设置为null product.setStock(100); // version字段有值由MP自动管理 int rows productMapper.updateById(product);即使product.setName(null)生成的SQL大概率是UPDATE t_product SET stock100, version?1 WHERE ...name字段不会被包含在SET语句中因此数据库中的name值保持不变。如果你确实需要将某个字段更新为null有几种方法使用UpdateWrapper的set方法UpdateWrapperProduct wrapper new UpdateWrapper(); wrapper.eq(id, 1L) .set(name, null) // 明确设置为null .set(stock, 100); // 注意此法需配合实体对象传入才能触发乐观锁 Product entity new Product(); entity.setId(1L); // 需要从数据库查出当前version值赋给entity或者使用其他方式保证乐观锁生效 // entity.setVersion(oldVersion); productMapper.update(entity, wrapper);在实体类字段上使用TableField注解并设置strategy FieldStrategy.IGNORED不推荐TableField(strategy FieldStrategy.IGNORED) private String name;此策略表示“忽略判断”无论字段是否为null都会参与SQL拼接。慎用因为它破坏了MP默认的防误覆盖机制。使用MP的alwaysUpdateSomeColumnById方法需配合自定义SQL注入器这是一种更高级的用法可以配置某些字段总是被更新。最佳实践建议对于业务上允许为null且需要显式置null的字段推荐使用方法1即通过UpdateWrapper.set(column, null)来明确意图同时通过传入实体对象来保证乐观锁生效。这样代码的意图最清晰。5. 性能优化、监控与常见问题排查5.1 高并发下的优化策略乐观锁在冲突率低时性能卓越但在冲突率高的热点数据如秒杀商品库存上大量的失败重试会消耗资源甚至导致系统响应变慢。针对这种场景可以考虑以下优化组合拳业务层面分流将热点库存进行拆分比如一个总库存为1000的商品拆分成100个库存为10的“子库存项”用户请求随机路由到不同的子项上扣减。这能将并发冲突的粒度减小。应用层排队或令牌桶在进入数据库更新前用Redis分布式锁或消息队列进行请求排队将并行请求转为串行处理彻底避免冲突。或者使用令牌桶算法在缓存层控制流量。数据库层面如果必须直接面对高并发更新可以考虑使用更专业的数据库特性如MySQL的UPDATE ... SET stock stock - 1 WHERE id 1 AND stock 0利用行锁但这又回到了悲观锁或类似机制。也可以将库存字段类型改为无符号整数利用数据库的原子操作。优化重试策略不要使用固定的、无延迟的重试这容易导致“惊群效应”。采用指数退避算法进行重试例如第一次等待50ms第二次100ms第三次200ms。这能有效降低在冲突瞬间对数据库的持续压力。// 简单的指数退避重试示例 int retryTimes 0; long waitTime 50L; // 初始等待50ms while (retryTimes MAX_RETRY) { // ... 尝试更新逻辑 ... if (updateSucceed) break; retryTimes; try { Thread.sleep(waitTime); } catch (InterruptedException e) { break; } waitTime waitTime * 2; // 等待时间翻倍 }5.2 如何监控乐观锁冲突知道冲突发生的频率对于评估系统健康度和优化至关重要。你可以通过以下几种方式监控日志记录在乐观锁更新失败updateCount 0的分支里记录一条WARN级别的日志包含业务ID、操作类型等信息。通过ELK等日志系统收集并统计WARN日志的频率。if (updateCount 0) { log.warn(乐观锁更新冲突productId: {}, currentThread: {}, productId, Thread.currentThread().getName()); // ... 重试或抛异常 ... }Metrics指标利用Micrometer等指标库在冲突时递增一个计数器。然后可以将此指标接入Prometheus和Grafana绘制出冲突率的趋势图。Service public class ProductService { private final MeterRegistry meterRegistry; private final Counter optimisticLockConflictCounter; public ProductService(MeterRegistry meterRegistry) { this.meterRegistry meterRegistry; this.optimisticLockConflictCounter Counter.builder(mp.optimistic.lock.conflict) .tag(service, product) .description(MyBatis-Plus乐观锁冲突次数) .register(meterRegistry); } public boolean purchase(...) { // ... 更新逻辑 ... if (updateCount 0) { optimisticLockConflictCounter.increment(); // 冲突计数1 // ... 处理冲突 ... } } }数据库慢查询日志虽然乐观锁更新本身很快但大量的冲突重试会导致短时间内对同一条数据进行多次SELECT和UPDATE。监控数据库的QPS和慢查询如果发现某个表的相关操作异常增高可能暗示着热点冲突。5.3 常见问题排查清单在实际开发中你可能会遇到乐观锁“好像没生效”的情况。下面是一个快速排查清单问题现象可能原因解决方案更新总是成功即使模拟并发。1. 插件未正确配置。2. 实体类字段未加Version注解。3. 使用了不支持的更新方法如仅用UpdateWrapper的set。1. 检查MybatisPlusConfig是否被Spring加载。2. 检查实体类字段和数据库表字段。3. 确保使用updateById(entity)或update(entity, wrapper)。更新时抛出异常提示版本字段为null。执行更新前实体对象的version字段值为null。更新前务必确保实体对象是从数据库查询出来的带有version或者手动为其设置一个有效的版本值。自定义的Update SQL中乐观锁不生效。MP插件只对自带的方法生效。在自定义XML的SQL中手动添加版本条件WHERE id#{id} AND version#{version}并在更新时SET versionversion1。在事务方法中重试后仍然失败。可能是事务隔离级别的问题。在“可重复读”级别下同一事务内多次查询可能读到相同的旧版本快照。1. 将重试逻辑放在事务方法外部如通过Spring的Transactional传播特性。2. 或在重试查询时使用SELECT ... FOR UPDATE强制读最新数据慎用影响性能。与逻辑删除字段TableLogic冲突。更新时MP会自动附加逻辑删除条件如WHERE deleted0。一般无冲突两者是并列条件。确保你的逻辑删除字段和版本字段设计合理即可。一个典型的排查流程当怀疑乐观锁失效时第一件事是开启MP的SQL日志输出配置mybatis-plus.configuration.log-implorg.apache.ibatis.logging.stdout.StdOutImpl观察实际执行的SQL语句。一看WHERE子句里有没有AND version?二看SET子句里是不是version?1。日志会告诉你一切。6. 扩展思考乐观锁的适用边界与替代方案乐观锁并非银弹理解它的边界才能更好地运用它。最适合的场景读多写少冲突发生的概率较低。业务逻辑允许重试更新失败后有友好的处理方式如提示用户、自动重试不会造成恶劣的业务后果。响应时间要求高不希望引入悲观锁带来的线程挂起和等待开销。需要谨慎或避免的场景写多读少冲突频繁例如高频更新的计数器。大量请求会因冲突而失败重试性能可能反而不如精心设计的悲观锁或原子操作。业务状态复杂重试成本高更新失败后需要回滚一系列复杂的周边操作重试的副作用很大。对数据绝对一致性要求极高且无法接受“请重试”提示某些金融核心交易场景。替代或补充方案悲观锁在冲突频繁且业务简单的场景下SELECT ... FOR UPDATE可能更直接有效。但务必控制好锁的粒度尽量基于索引和持有时间。分布式锁在分布式环境下要跨服务保证对同一资源的互斥访问可以使用基于Redis或ZooKeeper的分布式锁。这通常用于更粗粒度的控制如“整个下单流程”。数据库原子操作像UPDATE table SET count count - 1 WHERE id 1 AND count 0这样的SQL利用数据库自身的原子性保证安全性能极高适合简单的计数器场景。状态机/版本号扩展有时单纯的数值版本号不够用。可以引入更复杂的状态字段或者使用业务逻辑上的“更新时间戳”、“操作流水号”作为并发判断依据实现更灵活的乐观并发控制。在我经历过的不少项目中乐观锁是处理并发更新的首选方案因为它简单、非阻塞并且与MP的结合几乎是无缝的。它的价值在于用很小的开发成本为系统在常规并发下提供了可靠的数据一致性保障。真正的技巧不在于如何使用它而在于如何判断何时该用它以及当它失效冲突时你的业务层是否有优雅的降级或处理策略。把这套机制想明白设计到你的业务流程里才是“娴熟运用”的关键。