ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

悲观锁还是乐观锁?库存扣减场景下的混合并发控制策略实战

悲观锁还是乐观锁?库存扣减场景下的混合并发控制策略实战 之前在业务系统做库存扣减和订单支付对账时反复在一个问题上纠结同一份数据同时被多个线程读写到底该用悲观锁还是乐观锁网上资料大多把两者对立起来讲好像选了乐观锁就不能碰悲观锁一样。实际上生产环境里真正稳定高效的方案往往是“悲观点设计、乐观点重试”的融合策略。这篇文章会用一套库存扣减的完整案例把悲观锁、乐观锁以及两者组合使用的思路全部拆开讲清楚同时附上完整代码、SQL 和常见问题排查方案。这套思路适合有 Java 基础、正在做并发、交易、订单、秒杀类业务的后端开发也适合刚接触分布式锁和数据库锁的初学者。跟着文章走完你能理解两种锁的本质差异学会在什么场景用悲观锁、什么场景用乐观锁以及为什么有些团队会把二者融合成一种“pessimistically optimistic”的落地方案。1. 背景与核心概念1.1 并发下的数据竞争问题先看一个最常见的业务场景电商系统扣减库存。假设商品表里有一个stocks字段用户下单时执行UPDATE product SET stocks stocks - 1 WHERE id 100;单机单线程下没有任何问题但一旦进入高并发环境多个请求同时读到stocks 5然后都执行减 1最终结果可能就是 3、2 甚至负数而不是理论上的 1。这就是数据竞争本质上是“读-改-写”这三个步骤不是原子的。解决数据竞争核心手段就是加锁。根据“锁的持有心态”业界把数据库并发控制分成两类悲观锁认为冲突大概率发生因此操作前先锁定资源操作完成后再释放。乐观锁认为冲突是少数情况不加锁直接更新更新时校验版本冲突则重试或失败。1.2 什么是 “Pessimistically optimistic”“Pessimistically optimistic” 是一个很形象的工程表达翻译过来就是“悲观的乐观主义者”也可以理解为一种混合锁策略在可能发生严重冲突、代价极高的核心链路按悲观思路提前做好锁保护和兜底。在冲突概率较低、重试成本可控的普通链路按乐观思路做版本校验和快速失败。再往上综合一层把两种策略组合成“乐观 CAS 悲观兜底重试”的闭环。举个例子。秒杀场景里前 10 分钟热点商品的并发冲突必然非常高这时用SELECT ... FOR UPDATE或 Redis 分布式锁来悲观地串行化请求比反复重试乐观锁更稳定。而在普通商品加购、积分扣减等低频操作里用版本号做乐观控制冲突概率低重试成本也可接受没必要长时间锁行。所以理解“pessimistically optimistic”的关键不是选边站而是搞清楚什么时候悲观点什么时候乐观点以及两者怎么衔接。1.3 适用场景与边界维度悲观锁乐观锁混合策略冲突概率高低动态可切换性能表现锁等待大、并发吞吐低无锁竞争、吞吐高兼顾二者实现复杂度较低中较高典型场景秒杀扣减、金融转账文章点赞、配置更新交易系统、库存中心实际项目中不存在“万能锁”。掌握混合策略本质上是在掌握两种机制的原理后针对流量特征去设计一种有边界的并发控制方案。2. 环境准备与版本说明本文示例使用 Java Spring Boot MySQL 环境涉及的关键组件如下。组件说明JDK1.8Spring Boot2.x 或 3.x 均可本文以 2.7 系列为例MySQL5.7 / 8.xMyBatis-Plus3.5.x用于简化 ORM 操作Maven3.6版本需要根据你的项目实际情况调整本文示例以常见环境为例重点演示配置思路。示例项目的目录结构建议如下pessimistically-optimistic-demo ├── pom.xml ├── src/main/java/com/example/demo │ ├── DemoApplication.java │ ├── controller/StockController.java │ ├── service/StockService.java │ ├── mapper/ProductMapper.java │ └── entity/Product.java └── src/main/resources └── application.yml如果本地没有 Spring Boot 项目可以先通过 Spring Initializr 快速创建然后复制下面的配置。3. 悲观锁与乐观锁原理拆解3.1 悲观锁先锁后做悲观锁的核心思想是“我怀疑别人会改数据所以在我操作期间谁都不许动”。在 MySQL InnoDB 中悲观锁通常依靠SELECT ... FOR UPDATE实现。比如BEGIN; SELECT stocks FROM product WHERE id 100 FOR UPDATE; -- 业务校验 stocks 0 UPDATE product SET stocks stocks - 1 WHERE id 100; COMMIT;事务 A 执行SELECT ... FOR UPDATE后会锁定id 100这一行。其他事务再执行相同锁查询时必须等待事务 A 提交或回滚。在 Java 层面悲观锁也可以表现为synchronized、ReentrantLock或 Redis 分布式锁但它们的作用域不同数据库悲观锁作用于数据库行记录。JVM 锁作用于单个应用进程内的线程。Redis 锁作用于多实例跨进程的互斥。3.2 乐观锁不加锁但校验版本乐观锁的核心思想是“我赌冲突不会发生但更新时要确认数据没被别人改过”。经典实现是用版本号字段UPDATE product SET stocks stocks - 1, version version 1 WHERE id 100 AND version 5;在更新时WHERE 条件带上旧版本号。如果更新影响行数为 0说明版本已经被其他事务改过了需要重新查询当前版本或者放弃更新。这种方式没有FOR UPDATE数据库锁竞争少并发吞吐更高但它有两个前提业务上允许一部分请求失败并重试。冲突率不能过高否则重试成本会反超锁等待成本。3.3 成本模型把两种方案放在同一个模型里对比悲观锁成本 锁等待时间 锁竞争损耗乐观锁成本 正常更新成本 冲突概率 × 重试成本当冲突概率低时乐观锁优势明显当冲突概率高时悲观锁更可靠。这就是为什么“乐观”和“悲观”不应该被当成静态选择而应该被视为在不同负载条件下的动态策略。4. 完整实战案例库存扣减的混合锁实现下面用一个库存扣减案例把乐观锁、悲观锁、混合策略逐一实现出来。4.1 创建数据表CREATE TABLE product ( id bigint(20) NOT NULL AUTO_INCREMENT, product_name varchar(128) NOT NULL COMMENT 商品名称, stocks int(11) NOT NULL DEFAULT 0 COMMENT 库存, version int(11) NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT商品库存表; INSERT INTO product (id, product_name, stocks, version) VALUES (100, 测试商品, 100, 0);这里的version字段就是乐观锁核心字段。使用悲观锁时version不参与强校验但依然保留方便后续切换策略。4.2 添加依赖在pom.xml中引入 Spring Boot Web 和 MyBatis-Plus 相关依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency在application.yml中配置数据源spring: datasource: url: jdbc:mysql://localhost:3306/demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver4.3 编写实体类和 Mapper// 文件路径src/main/java/com/example/demo/entity/Product.java package com.example.demo.entity; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baomidou.mybatisplus.annotation.Version; import java.time.LocalDateTime; TableName(product) public class Product { TableId(type IdType.AUTO) private Long id; private String productName; private Integer stocks; Version private Integer version; private LocalDateTime createTime; private LocalDateTime updateTime; // getter / setter 省略 }MyBatis-Plus 的Version注解用于乐观锁插件自动生成版本号更新 SQL但对于自定义 SQL我们仍然可以手动编写UPDATE ... WHERE version ?。// 文件路径src/main/java/com/example/demo/mapper/ProductMapper.java package com.example.demo.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.example.demo.entity.Product; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Update; public interface ProductMapper extends BaseMapperProduct { Update(UPDATE product SET stocks stocks - #{num}, version version 1 WHERE id #{id} AND version #{version}) int deductStockByVersion(Param(id) Long id, Param(num) Integer num, Param(version) Integer version); Update(UPDATE product SET stocks stocks - #{num} WHERE id #{id} AND stocks #{num}) int deductStockByCondition(Param(id) Long id, Param(num) Integer num); }注意deductStockByCondition用stocks num做条件判断也能防止库存被扣成负数但它不属于严格的乐观锁因为缺少版本校验。4.4 Service 层三种策略实现4.4.1 纯乐观锁方案// 文件路径src/main/java/com/example/demo/service/StockService.java package com.example.demo.service; import com.example.demo.entity.Product; import com.example.demo.mapper.ProductMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import javax.annotation.Resource; Service public class StockService { Resource private ProductMapper productMapper; /** * 乐观锁扣减读取版本号更新时校验版本失败则返回 false */ public boolean deductByOptimisticLock(Long productId, Integer num) { // 1. 查询当前版本 Product product productMapper.selectById(productId); if (product null) { throw new RuntimeException(商品不存在); } // 2. 执行带版本号的更新 int rows productMapper.deductStockByVersion(productId, num, product.getVersion()); return rows 0; } /** * 乐观锁 有限重试 */ public boolean deductByOptimisticLockWithRetry(Long productId, Integer num, int maxRetry) { for (int i 0; i maxRetry; i) { boolean success deductByOptimisticLock(productId, num); if (success) { return true; } } return false; } }这段代码中deductByOptimisticLockWithRetry在冲突时最多重试maxRetry次。重试需要注意两点每次重试都要重新查询最新版本。重试之间建议加入短暂休眠避免冲突风暴时高频刷新数据库。4.4.2 纯悲观锁方案/** * 悲观锁扣减使用 SELECT ... FOR UPDATE 锁定行 */ Transactional(rollbackFor Exception.class) public boolean deductByPessimisticLock(Long productId, Integer num) { // 1. 行锁查询 Product product productMapper.selectByIdForUpdate(productId); if (product null) { throw new RuntimeException(商品不存在); } // 2. 业务校验 if (product.getStocks() num) { throw new RuntimeException(库存不足); } // 3. 更新库存 int rows productMapper.deductStockByCondition(productId, num); return rows 0; }selectByIdForUpdate需要自己写在 Mapper 中Select(SELECT id, product_name, stocks, version FROM product WHERE id #{id} FOR UPDATE) Product selectByIdForUpdate(Param(id) Long id);这里有一个关键点Transactional必须开启事务SELECT ... FOR UPDATE才有效。事务提交或回滚时行锁才会释放。4.4.3 混合策略Pessimistically Optimistic 实现混合策略的核心流程可以概括为先用乐观锁尝试更新。如果成功直接返回。如果失败说明冲突发生。根据当前系统负载或业务类型判断是继续重试还是切换为悲观锁走兜底。悲观锁兜底时设置合理的锁等待超时避免请求长时间堆积。/** * 混合策略乐观锁优先冲突严重时悲观锁兜底 */ Transactional(rollbackFor Exception.class) public boolean deductByHybridStrategy(Long productId, Integer num, int optimisticRetry) { // 阶段一乐观锁快速尝试 for (int i 0; i optimisticRetry; i) { boolean success deductByOptimisticLock(productId, num); if (success) { return true; } } // 阶段二悲观锁兜底 Product product productMapper.selectByIdForUpdate(productId); if (product null) { throw new RuntimeException(商品不存在); } if (product.getStocks() num) { throw new RuntimeException(库存不足); } int rows productMapper.deductStockByCondition(productId, num); return rows 0; }细心的读者会发现一个问题在同一个事务里deductByOptimisticLock本身也查询了一次product然后再执行版本更新。MySQL 默认事务隔离级别是REPEATABLE READ在同一事务中第一次SELECT会建立一致性视图后续读取是快照读。而SELECT ... FOR UPDATE是当前读始终读取最新已提交数据。因此在混合策略中如果乐观锁阶段失败直接进入悲观锁阶段时需要重新执行selectByIdForUpdate而不是复用之前的Product对象。否则你拿到的可能还是旧快照无法准确判断库存是否充足。4.5 Controller 层// 文件路径src/main/java/com/example/demo/controller/StockController.java package com.example.demo.controller; import com.example.demo.service.StockService; import org.springframework.web.bind.annotation.*; import javax.annotation.Resource; import java.util.HashMap; import java.util.Map; RestController RequestMapping(/stock) public class StockController { Resource private StockService stockService; PostMapping(/optimistic) public MapString, Object optimistic(RequestParam Long productId, RequestParam Integer num) { boolean success stockService.deductByOptimisticLock(productId, num); return result(success, 乐观锁扣减); } PostMapping(/pessimistic) public MapString, Object pessimistic(RequestParam Long productId, RequestParam Integer num) { boolean success stockService.deductByPessimisticLock(productId, num); return result(success, 悲观锁扣减); } PostMapping(/hybrid) public MapString, Object hybrid(RequestParam Long productId, RequestParam Integer num) { boolean success stockService.deductByHybridStrategy(productId, num, 3); return result(success, 混合策略扣减); } private MapString, Object result(boolean success, String mode) { MapString, Object map new HashMap(); map.put(success, success); map.put(mode, mode); map.put(message, success ? 扣减成功 : 扣减失败请重试); return map; } }4.6 运行验证启动 Spring Boot 项目后用 curl 模拟请求curl -X POST http://localhost:8080/stock/optimistic?productId100num1 curl -X POST http://localhost:8080/stock/pessimistic?productId100num1 curl -X POST http://localhost:8080/stock/hybrid?productId100num1正常情况下的返回结果是{success:true,mode:乐观锁扣减,message:扣减成功}如果想模拟并发场景可以用 JMeter 或简单的多线程脚本同时压测这三个接口。从结果上可以观察乐观锁接口在高并发下会返回部分success: false。悲观锁接口的响应时间会明显变长因为请求在等待锁释放。混合策略接口在低冲突时表现接近乐观锁在高冲突时能通过悲观锁兜底保证最终成功。4.7 结果说明通过压测会发现没有绝对最优的锁。乐观锁的失败率会随着并发量上升而上升悲观锁的响应时间会随着锁竞争上升而恶化。混合策略的意义在于核心数据不丢、不超卖同时把大部分正常请求维持在乐观锁的高吞吐路径上。5. 常见问题与排查思路5.1 常见问题汇总问题现象常见原因解决思路乐观锁频繁更新失败并发冲突率过高增加重试次数或切换悲观锁兜底SELECT ... FOR UPDATE不生效方法没有事务注解在方法上加上Transactional接口响应超时锁等待时间过长设置 innodb_lock_wait_timeout或在 SQL 中减少锁持有时间数据库死锁多个资源按不同顺序加锁统一加锁顺序减少长事务库存扣成负数没有校验stocks num在 UPDATE 条件中带上库存判断重试导致重复扣减没有幂等控制增加业务唯一键或操作流水表5.2 死锁排查出现死锁报错时可以在 MySQL 中执行SHOW ENGINE INNODB STATUS;重点看LATEST DETECTED DEADLOCK部分里面会列出两个事务当前持有的锁和等待的锁。常规预防手段多个事务访问多张表时保持相同的访问顺序。避免在一个事务里执行耗时的远程调用。控制事务粒度尽量只更新必要的数据。5.3 锁等待超时如果大量请求卡在锁等待上可以先查看当前锁等待情况SELECT * FROM information_schema.INNODB_TRX;也可以尝试调大锁等待时间但更推荐优化 SQL缩短事务执行时间# MySQL 配置文件 my.cnf innodb_lock_wait_timeout 5生产环境不建议把锁等待时间设置得过大否则请求堆积会把数据库连接池耗尽。5.4 乐观锁大量失败的优化乐观锁失败率高时不要急着无限重试优先检查以下几点是不是锁粒度太大比如多行作为同一个版本控制实际只需要锁某一行。是不是业务设计导致热点集中比如所有用户都在抢同一个商品这是天然热点乐观锁效果有限。是不是重试逻辑没有退避连续高频重试会加重数据库压力建议使用指数退避。6. 最佳实践与工程建议6.1 锁的选择不能脱离业务场景在写代码之前先回答三个问题冲突概率高不高秒杀、抢购、热点数据冲突概率高偏向悲观锁或 Redis 分布式锁。失败代价大不大支付、转账、订单金额失败代价大需要更严格的保护。重试成本高不高如果重试涉及外部 API 调用成本高不如一开始就用悲观锁。6.2 事务尽量短小不管用哪种锁事务里不应该包含跨网络的慢操作。数据库连接、行锁、分布式锁都是珍贵资源长时间占用会让整个系统的吞吐量下降。一个常见优化方式是把耗时操作放到事务外执行先校验业务合法性。再开启事务执行锁操作。提交后异步发送消息、通知下游。6.3 幂等设计比锁更重要防止重复扣减锁只是一个手段。真正可靠的做法是引入幂等控制。比如增加一张stock_operation_log流水表CREATE TABLE stock_operation_log ( id bigint(20) NOT NULL AUTO_INCREMENT, request_id varchar(64) NOT NULL COMMENT 请求唯一ID, product_id bigint(20) NOT NULL, num int(11) NOT NULL, status tinyint(4) NOT NULL COMMENT 1-成功 2-失败, create_time datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY uk_request_id (request_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;扣减库存前先尝试插入request_id。如果插入成功说明这是新请求可以继续扣减如果插入冲突说明是重复请求直接返回成功或者忽略。6.4 监控锁的指标生产环境至少要关注这些指标锁等待次数和平均等待时间。乐观锁更新失败率。数据库活跃连接数。死锁告警。通过监控数据可以进一步判断当前锁策略是否需要调整。比如失败率超过 20%说明乐观锁已经不太适用需要把流量切到悲观锁或升级为分布式锁。6.5 灰度发布与回滚锁策略变更和代码发布一样必须有灰度方案先用小流量测试乐观锁重试次数的合理性。再逐步放开并发压测。观察监控指标异常时通过配置中心动态切换策略。不要把锁策略写死在代码里建议通过配置项控制# 策略开关 stock.lock.strategyhybrid stock.lock.optimistic-retry3 stock.lock.pessimistic-timeout500这样可以在不发布新版本的情况下快速调整锁策略。6.6 安全与权限涉及数据库变更、锁表操作时必须有最小权限意识生产库账号只授权业务所需的最小SELECT、UPDATE、INSERT、DELETE权限。不推荐直接使用root账号跑应用。执行 DDL 语句前先在测试环境演练确认不影响线上数据。对库存扣减这类写操作保留审计日志便于问题回溯。7. 总结与学习路线这篇文章主要围绕“pessimistically optimistic”这个思路展开核心收获可以归纳成三条第一悲观锁和乐观锁不是互斥选项而是应对不同冲突概率的手段。悲观锁通过锁等待保证强一致性乐观锁通过版本控制提升并发吞吐。第二工程上更合理的做法是设计一种混合策略优先乐观锁快速执行冲突达到阈值后切换悲观锁兜底。这样既避免了频繁失败的挫败感也避免了一上来就锁表把请求变成串行。第三锁永远只是并发控制的一部分。配合幂等机制、短事务、监控告警和配置化开关才是一套完整的生产级方案。下一步可以按顺序深入这些方向动手改论文中的库存案例分别压测三种策略记录不同并发量下的吞吐和失败率。学习 Redis 分布式锁和 Redisson 的实现原理搞清楚单机锁与分布式锁的边界。研究 MySQL InnoDB 的锁机制包括记录锁、间隙锁、临键锁理解锁的底层表现。结合消息队列和本地事务表实践最终一致性方案理解锁之外的柔性事务思路。如果你在实际项目里也遇到过锁升级、死锁、超卖相关问题欢迎在评论区留下具体现象和报错一起讨论。收藏本文备用下次设计并发控制方案时可以少走弯路。
返回列表