库存超卖这种事,只要做过电商或者任何带库存体系的业务,多少都碰到过。商品详情页显示还剩 500 件,1000 个人同时点抢购,最后订单却下了 700 单,后台库存直接变成负数。这种事故一出,运营着急、开发背锅、老板拍桌子。这个问题的本质就是高并发下的库存争抢,也就是标题里面说的“强一致锁”要解决的问题。
这篇东西我结合自己做过的实际项目来写,核心围绕“高并发下如何保证库存扣减不超卖、不丢单、不拖垮服务”这一条主线展开。会先拆解库存争抢的底层原理,再对比几种常见锁方案的优劣,然后给出可落地的实现代码和参数设计,最后用 JMeter 压测来验证方案行不行。如果你正在做秒杀、抢购、预约、票务这类高并发扣减场景,或者正在给自己的服务做性能验证,这篇文章可以直接拿来当参考。
1. 库存争抢的技术本质:超卖是怎么产生的?
1.1 先从一次典型事故说起
之前我负责过一套积分商城的兑换接口,活动规则很简单:上线 1000 份商品,每人限兑 1 份。结果活动开始 30 秒,后台订单量飙升到 1400 多单,库存表被扣成了负数。查日志发现,扣减库存的 SQL 长这样:
UPDATE goods_stock SET stock = stock - 1 WHERE goods_id = #{goodsId};这就是最典型的“先查后扣”或者“直接扣减不带条件”的写法。在高并发下,两个线程同时读到 stock = 1,各自执行stock - 1,最后库存变成 0,但订单却生成了两笔。再极端一点,库存只剩 1 件,50 个并发请求同时进来,全部扣减成功,库存直接变成 -49。
库存扣减跟普通的数据更新不一样,它有两个硬性要求:第一,扣减操作必须串行化,同一件商品同一时刻只允许一个请求真正扣减成功;第二,扣减必须满足原子性,要么扣成功,要么不扣,中间不能出现“查到库存足够但扣减失败”这种半吊子状态。这也就是标题里说的“强一致”的含义。
1.2 库存争抢本质上是并发写冲突
库存数据是典型的“单行热点数据”。不管底层用的是 MySQL 还是 Redis,所有请求最终都要落到同一行库存记录上做修改。并发量一上来,这一行数据就成了全链路最热的点,所有请求都在这里排队等锁。
这里要理解两个核心概念:
- 竞态条件(Race Condition):多个线程同时读写共享数据,最终结果取决于线程的执行顺序。库存超卖就是典型的竞态条件导致的结果错乱。
- 临界区(Critical Section):操作库存的代码段就是临界区,必须保证同一时刻只有一个线程能进入。锁的作用就是把临界区保护起来。
很多人一开始想的是“加个 synchronized 不就行了”,其实没这么简单。synchronized 锁的是单机 JVM 内部的线程,如果服务部署了多个实例,每个实例各有一把锁,请求被负载均衡到不同实例上,锁就完全失效了。这也是为什么分布式场景必须引入跨进程的分布式锁。
1.3 强一致锁要解决的核心矛盾
强一致锁解决的核心矛盾,是“并发写的冲突”和“系统可用性”之间的平衡。CAP 理论里,在分区(P)存在的前提下,C(一致性)和 A(可用性)往往需要取舍。但库存扣减的场景比较特殊,宁可拒绝请求,也不能让数据错乱。所以这里必须偏向一致性,通过锁机制牺牲部分并发吞吐,换取不超卖的结果。
这里顺便说一个实用观点:强一致锁不等于全局锁。库存争抢的强一致,是针对单个商品的。我不需要锁住全表,只需要对goods_id这一行或者对应的 Redis Key 加锁。锁的粒度越细,并发能力越高,这也是后面选型的重要原则。
注意:设计锁方案时,一定要先问自己三个问题:锁的粒度是什么?锁的持有时间多长?锁失效了怎么办?这三个问题想不清楚,方案上线必出事故。
2. 强一致锁方案选型:先把账算清楚再动手
2.1 四种主流方案对比
我见过不少团队上来就写 Redis 分布式锁,根本不评估自己的业务形态。这里把主流的方案摆在一起对比一下,再讲讲我的选择逻辑。
| 方案 | 实现思路 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 数据库乐观锁 | UPDATE ... SET stock = stock - 1 WHERE stock > 0 | 实现简单,无额外中间件依赖 | 并发高时数据库压力大,频繁更新冲突 | 低并发、小规模系统 |
| 数据库悲观锁 | SELECT ... FOR UPDATE | 强一致,安全 | 行锁阻塞严重,吞吐量天花板低 | 后台管理、订单处理 |
| Redis 分布式锁 | SETNX + 过期时间 / Redisson 看门狗 | 性能好,适合高并发 | 锁过期、主从切换可能丢锁 | 秒杀、抢购、热点扣减 |
| ZooKeeper / etcd 锁 | 临时顺序节点 + Watch | 强一致、可靠性高 | 性能比 Redis 低,依赖额外组件 | 对一致性要求极高的场景 |
2.2 为什么高并发库存争抢优先选 Redis 分布式锁
库存争抢的 QPS 通常很高,数据库行锁在这种量级下很难扛。Redis 是单线程模型,所有命令串行执行,天然具备原子性,而且读写性能极高,单实例 QPS 可以到 10 万级别。用 Redis 做分布式锁,就是用它的高吞吐特性,把库存扣减的并发压力从数据库转移到 Redis 上。
但 Redis 锁有两个非常容易踩的坑:
第一个是锁过期时间设置不当。业务执行时间超过锁的过期时间,锁自动释放,第二个请求就能拿到锁,导致并发进入临界区。解决办法是用 Redisson 的看门狗机制,在锁快过期时自动续期。
第二个是主从切换丢锁。Redis 主节点宕机后,锁数据还没来得及同步到从节点,从节点升级为主节点,锁就丢了。这种极端场景下,如果业务不能容忍,可以考虑 RedLock 红锁方案,但 RedLock 本身也有争议,实现复杂,我一般只在强一致要求极高时才考虑。
2.3 选择合适的锁粒度
锁粒度直接影响系统并发能力。我见过有人用分布式锁锁一个全局字符串,比如lock:all,结果所有商品的扣减都串行执行,稍微有点流量就直接把服务打垮。正确做法是按商品维度加锁,锁的 Key 设计成lock:stock:12345,其中12345是商品 ID。
如果单商品仍然有极高的并发,比如一个爆款商品单秒几十万请求,那单把锁也会成为瓶颈。这时候可以考虑“库存分片”思路:把 1000 件库存拆分到 10 个独立的库存桶中,每个桶 100 件,每把锁锁一个桶,请求随机分配到不同的桶上。这样锁的粒度更细,并发能力成倍提升。代价是要引入库存桶的分配和汇总逻辑,增加了复杂度。
实操心得:如果你的业务不是真正的大流量爆点,不要一上来就做库存分片。先把单商品粒度做好,压测,再分析是否需要分片。过早优化是架构设计的大忌。
3. 库存扣减核心实现:从锁到事务的完整链路
3.1 方案架构设计
在我的实际项目中,库存扣减走了“Redis 锁 + Redis 库存预扣 + 数据库最终扣减”的链路,分为三个层级:
- L1 层,接口入口:先走 Redis 分布式锁,保证同一商品同一时刻只有一个请求能进入扣减流程。
- L2 层,Redis 库存扣减:使用 Lua 脚本原子性地完成“检查库存是否充足并扣减”,这一步能挡掉绝大部分无效请求。
- L3 层,数据库持久化扣减:在事务里执行 UPDATE 条件扣减,生成订单,确保数据最终落库。
这样做的好处是把热点请求拦截在 Redis 层,数据库只处理真正扣减成功的请求,压力大幅下降。
3.2 Redis 分布式锁的完整实现
直接看代码。这里用 Java 和 Redisson 实现,Redisson 的看门狗机制可以解决锁自动续期的问题,是我比较推荐的方式。
@Autowired private RedissonClient redissonClient; public boolean deductStock(Long goodsId, Integer count) { String lockKey = "lock:stock:" + goodsId; RLock lock = redissonClient.getLock(lockKey); try { // 尝试加锁,最多等待 2 秒,锁自动释放时间 30 秒(看门狗会自动续期) boolean locked = lock.tryLock(2, 30, TimeUnit.SECONDS); if (!locked) { // 拿不到锁,直接返回失败 return false; } // 1. Redis 原子扣减 Long remain = redisStockService.deductStock(goodsId, count); if (remain == null || remain < 0) { // 库存不足,回滚 Redis 中的扣减 redisStockService.rollbackStock(goodsId, count); return false; } // 2. 数据库扣减并生成订单 return dbStockService.deductStockWithTx(goodsId, count); } finally { // 释放锁(Redisson 会校验线程持有关系) lock.unlock(); } }这里有几个细节大家容易忽略:
tryLock的等待时间不要设太长,库存争抢场景下 1~2 秒足够,超过就直接返回失败或排队提示,避免请求堆积。- 拿到锁和释放锁要放在同一个线程里,Redisson 默认对锁做了线程持有关系校验,会防止误删别人的锁。
finally里释放锁是必须的,否则一旦数据库操作异常,锁会一直不被释放。
3.3 Redis 库存扣减 Lua 脚本
为什么扣减库存用 Lua 脚本而不是用 Redis 的DECR命令?因为DECR只能做减一操作,没办法在扣减前判断库存是否充足。如果先GET判断再DECR,这两个操作不是原子的,仍然存在并发问题。
Lua 脚本可以一次性完成检查和扣减,Redis 会保证脚本整体原子执行。脚本如下:
-- KEYS[1]: 库存 Key -- ARGV[1]: 扣减数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then return -1 end redis.call('DECRBY', KEYS[1], ARGV[1]) return stock - tonumber(ARGV[1])对应 Java 里的调用:
public Long deductStock(Long goodsId, Integer count) { String stockKey = "stock:goods:" + goodsId; Long result = redisTemplate.execute( new DefaultRedisScript<>(STOCK_LUA_SCRIPT, Long.class), Collections.singletonList(stockKey), String.valueOf(count) ); return result; }库存回滚的脚本类似,把DECRBY换成INCRBY即可。有一点要注意,回滚操作只能在当前线程持锁期间执行,不能别的事务来回滚,否则容易把正常扣减的库存也回滚掉。
3.4 数据库最终扣减与事务边界
Redis 扣减成功之后,最终还是要把订单和库存变更落到数据库。这里的核心 SQL 是条件更新:
UPDATE goods_stock SET stock = stock - #{count}, version = version + 1 WHERE goods_id = #{goodsId} AND stock >= #{count};这条 SQL 执行后,如果返回值是 1,说明扣减成功;如果返回值是 0,说明库存不足或者行已经被其他请求修改,需要回滚 Redis 中的预扣数据。在分布式系统里,Redis 扣减和数据库落库之间可能出现不一致,比如数据库扣减失败,但 Redis 已经扣了。目前的主流做法是引入对账任务,定期比对 Redis 和数据库的差异并校正,我后面单独讲。
事务边界方面,事务要尽量短:只在数据库扣减和订单插入这一段加事务,不要把 Redis 操作、锁操作都包进事务里。跨资源的事务很难保证,而且会拉长锁的持有时间,降低并发能力。
3.5 库存预热与缓存设计
库存数据放在 Redis 里,不是上线后自动从数据库同步的。需要在活动开始前,把数据库里的库存预加载到 Redis:
public void prepareStock(Long goodsId) { Integer dbStock = goodsStockMapper.getStock(goodsId); redisTemplate.opsForValue().set("stock:goods:" + goodsId, String.valueOf(dbStock)); }预热操作必须用定时任务或者活动发布流程,在活动开始前完成。否则活动一开始,请求杀到 Redis,发现没有库存 Key,Lua 脚本会返回 -1,全部请求都失败。
注意:预热时建议用脚本一次性加载多个商品的库存,不要用 for 循环逐条 SET,大量 IO 会导致预热耗时过长。
这里推荐一个压测中比较实用的思路,库存预热完成后,再用 JMeter 模拟一批并发请求。这样能同时验证预热脚本和扣减链路是否正常。
4. 高并发压测:用 JMeter 验证方案到底扛不扛得住
4.1 压测场景设计
方案写完了,到底行不行,不能靠嘴说,要压。我习惯用 JMeter 做高并发测试,配置不复杂,结果也比较有说服力。
假设场景是:一件商品库存 500 件,模拟 1000 个用户同时抢购。那 JMeter 的线程组设计是这样的:
- 线程数:1000
- Ramp-Up Period:1 秒(所有用户基本同时启动)
- 循环次数:1
- HTTP 请求:指向下单接口
- 断言:校验返回结果是否包含成功标识
我还会加一个“集合点”组件。有些压测场景需要让所有请求尽可能同时到达服务器,模拟瞬时流量高峰。做法是在线程组里加入“同步定时器(Synchronizing Timer)”,设定等待超时时间比如 2 秒,收集到 1000 个请求后同时释放。
4.2 关键压测指标怎么看
压测完成后,主要看 JMeter 聚合报告里的几个指标:
| 指标 | 含义 | 我的经验合理值 |
|---|---|---|
| Samples | 请求总数 | 与线程数一致 |
| Average | 平均响应时间 | 抢购场景 300ms 以内可接受 |
| Error % | 错误率 | 应小于 1%,业务主动拒绝除外 |
| Throughput | 吞吐量 | 越高越好,但在预期范围内 |
| 90% Line | 90% 请求响应时间 | 比平均值更能反映真实体验 |
实际压测里,错误率可能不一定是 0,因为库存扣完了之后主动返回“已售罄”是正常业务逻辑,不算系统错误。所以断言那块要区分开:网络异常、超时、5xx 才算错误;“库存不足”的返回是预期行为。
4.3 压测结果分析:几种典型曲线
如果方案有问题,压测曲线会有明显特征。我总结了几类常见情况:
- 吞吐量先升后降:说明系统达到瓶颈,可能瓶颈在数据库连接池或者 Redis 连接池。
- 平均响应时间陡增:可能出现了锁等待堆积,大量请求阻塞在 Redis 锁的
tryLock上。 - Error 率出现但数据库库存没扣完:说明有请求没有正常走完扣减链路,大概率是 Redis 异常或者线程池拒绝。
- 数据库库存出现负数:这是最严重的,直接说明强一致锁方案没生效,立刻查锁的粒度和释放逻辑。
4.4 压测过后的数据对账
压测结束以后,一定要做数据对账。这是很多团队忽略的环节。对账逻辑很简单:数据库的商品库存 + Redis 的剩余库存 = 预热时的初始库存。
如果对账不平,说明 Redis 和数据库之间出现了不一致。我之前遇到过一种情况:Redis 扣减成功,但数据库事务提交超时回滚了,Redis 库存没有回滚,最后导致库存数据不准确。解决办法是写一个定时对账任务,每隔一段时间扫描 Redis 和数据库的差异,以数据库为准校正 Redis。这也是分布式系统里比较实际的做法,不可能保证百分之百一致,但要保证最终一致。
5. 从单节点 K8s 迁到云 ECS:强一致锁在迁移中的注意事项
5.1 为什么会有这次迁移
之前有一个项目,微服务整套环境跑在单节点的 K8s 集群上,节点一挂,全部服务不可用,而且存储用的是本地盘,数据安全也没有保障。后来决定把这套若依微服务环境整体迁移到阿里云 ECS 上,要求尽量不停服、不丢数据,迁移完成后用 JMeter 压测云环境的承载能力。
这个迁移本身不复杂,关键点是在迁移过程中,所有强一致锁相关的状态都不能丢。这句话看着简单,实际操作中很多人就踩坑了。
5.2 迁移过程中哪些数据不能丢
强一致锁链路里,有三个层面的数据需要特别关注:
- Redis 里的库存数据和分布式锁状态。如果 Redis 数据没迁移过去,新环境没有库存 Key,所有扣减请求都会失败。
- 数据库里的订单和库存表。这个一般通过主从同步或者数据导入工具迁移。
- 定时任务的状态。比如库存预热的定时任务,如果迁移后没有重新触发,活动开始了 Redis 里还没有库存数据。
我当时的做法是,Redis 用持久化加数据导入的方式迁移,可以先用SAVE生成 RDB 文件,然后在目标环境导入,但要注意在导入前停掉写入操作。如果做不到全程停止写入,就需要采用增量同步的方案。
5.3 不停服迁移的策略
要做到不停服迁移,我的经验是“灰度切换 + 流量染色”。先把新环境搭建好,数据同步到位,然后通过网关或者 Nacos 注册中心,把一部分测试流量切到新环境,验证稳定后再逐步切全量流量。整个过程外面看起来服务一直没有停止,实际上是流量在多个节点间流动。
这套策略里有一个细节值得专门强调:新环境启动时,Redis 分布式锁使用的 Key 前缀和密码要跟旧环境保持一致,否则切换之后,锁数据不互通,可能出现两个环境同时持有同一把锁的情况。一旦出现这种问题,超卖就会瞬间发生。
实操心得:迁移云环境后,先跑一遍 JMeter 压测脚本,我习惯把压测结果跟原来的 K8s 环境对比。如果单机吞吐量下降超过 20%,就要检查 ECS 的配置、网络带宽和 Redis 实例规格,很多时候问题出在 Redis 连接数限制上。
6. 生产环境常见问题排查实录
这一节把我在实际项目中踩过的坑,按照“现象 — 原因 — 解决”的方式整理成速查表,方便大家直接对照排查。
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 库存被扣成负数 | 锁粒度设置错误或锁未生效 | 检查锁 Key 是否按商品维度拆分,检查 Redisson 是否配置正确 |
| 锁长时间不释放 | 业务代码异常退出,finally 中未释放锁 | 确保锁的释放放在 finally 中,并配置锁超时自动释放 |
| 大量请求阻塞超时 | 锁的等待时间太长或锁粒度太大 | 缩短 tryLock 等待时间,细化锁粒度,必要时做库存分片 |
| 数据库连接池打满 | 数据库 UPDATE 是热点,连接被占满 | 前置 Redis 过滤无效请求,扩大连接池或引入 MQ 削峰 |
| Redis 扣减成功但数据库没扣 | 数据库事务回滚但 Redis 未回滚 | 增加对账任务,数据库成功后才提交,失败补偿回滚 |
| 压测时出现 5xx | Redis 连接数超限或线程池满 | 检查 Redis 最大连接数配置,调整线程池参数 |
| 活动前大量请求失败 | 库存未在 Redis 中预热 | 增加库存预热任务,活动前自动加载库存数据 |
6.1 一个典型的锁误删问题
这里分享一个我印象特别深的排查过程。某次压测时发现,库存扣减偶尔会超卖。代码逻辑看起来没问题,锁也加了。后来查了很久才发现,是因为业务里有一个异步任务,在事务提交后主动释放了锁。而异步任务的线程和取得锁的线程不是同一个,Redisson 本来会校验线程持有关系,但代码里用了强行释放的 API,直接就把别人持有的锁给释放了。结果就是:A 线程的锁被异步线程释放,B 线程马上拿到锁进入临界区,两个请求同时扣减库存,超卖就出现了。
这个问题的教训是:锁的获取和释放必须在同一个线程里,没有特殊情况不要跨线程操作锁。Redisson 默认的校验机制就是防着这种操作,你强行绕过,就是在玩火。
6.2 锁过期导致的并发问题
另一种容易忽略的情况是锁过期。假设业务操作平均耗时 100ms,但偶尔有慢查询耗时 3 秒,而锁的过期时间设的是 1 秒。一旦触发了慢查询,锁就自动释放了,其他请求就能进入临界区,并发问题立刻出现。Redisson 的看门狗机制会自动续期,但如果你用的是手写的 SETNX 锁,就完全没有续期能力。
如果你坚持用手写锁,建议把过期时间设成远大于业务最慢耗时的值,比如 10 秒或者 30 秒。但也要注意,过期时间太长,如果服务宕机锁一直不被释放,会造成后续所有请求都失败。折中方案是,给锁的 Value 设置一个唯一的请求 ID,释放时先比对,是自己的锁才释放,防止删错别人的锁。
6.3 热 Key 导致的 Redis 压力
库存场景天然有热 Key 问题。一个爆款商品的库存 Key 会被超高频率访问,达到 Redis 单实例处理能力的上限之后,所有请求都会变慢。常规的解决方案有:
- 本地缓存:在 JVM 里缓存一部分热 Key 的库存状态,减少对 Redis 的读取。但要注意本地缓存有数据滞后问题,只能用于读多写少的场景。
- 热 Key 复制:把同一个 Key 的读请求分散到多个副本 Key 上,写的时候同步更新所有副本。这个方案复杂度较高,用于极端场景。
- 库存分片:把单个商品的库存拆到多个 Key 上,天然解决热 Key 问题。
从我个人的项目经验来看,电商大促场景下,最优的实践是“库存分片 + Redis 锁 + Lua 脚本”。虽然实现复杂度偏高,但吞吐量提升非常明显。
6.4 压测工具 JMeter 本身的坑
最后顺便提一下压测工具有时候也会坑人。JMeter 做高并发压测时,如果负载机配置不够,JMeter 本身可能成为瓶颈,导致请求还没全部发出去,测试就结束了。我之前遇到过压测结果吞吐量一直提不上去,排查半天发现是负载机的线程数限制了 JMeter 的并发能力。解决办法是多台负载机分布式压测,或者调大 JMeter 的堆内存。
另一个常见问题是,JMeter 的 HTTP 请求默认没有超时设置,如果服务端处理慢,请求一直挂着,会导致线程越积越多。建议在 HTTP Request 的 Timeout 里设置连接超时 2000ms,响应超时 5000ms,这样压测结果才更接近真实场景。
6.5 对账脚本的简单实现
我再分享一个实际生产里非常有用的对账脚本思路。定时任务每分钟跑一次,扫描最近一分钟有扣减记录的商品,比对 Redis 和数据库的剩余库存。
SELECT goods_id, stock FROM goods_stock WHERE update_time >= DATE_SUB(NOW(), INTERVAL 1 MINUTE);然后逐条和 Redis 的stock:goods:{id}做比对,如果不一致,以数据库为准回写 Redis。这样即使出现了极端情况下的数据不一致,也能在一分钟内自动恢复。
这里提到的“最终一致”策略是高并发分布式系统的兜底方案。强一致锁能拦截掉大部分并发问题,但分布式环境下故障是常态,对账和补偿机制必须有。
最后再聊两句
库存争抢这个场景,表面上看是一个并发问题,实际上考验的是对整个技术栈的理解:从 JVM 锁到分布式锁,从 Redis 单线程模型到数据库事务,从代码实现到压测验证,最后还要加上运维层面的迁移和数据一致性保障。我踩了这么多坑之后的体会是,没有银弹方案。每个项目都要根据并发量、一致性要求、成本和团队维护能力来选型。
如果你现在正要做高并发库存扣减,我的建议很简单:先把方案画清楚,锁的粒度细化到商品维度,压测之前先想好怎么对账,然后再动手写代码。这四步顺序不能乱,乱一步后面全是坑。
最后再分享一个小技巧:压测的时候别只盯着吞吐量。跑完一版 JMeter,先查数据库库存是否为负数,再比 Redis 和数据库的库存差。数据对得上,吞吐量才有意义,不然数字再好看也是自欺欺人。