☰
高并发库存扣减实战:强一致锁方案选型与JMeter压测验证
2026/9/30 8:56:22 网站建设 项目流程

库存超卖这种事,只要做过电商或者任何带库存体系的业务,多少都碰到过。商品详情页显示还剩 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% Line90% 请求响应时间比平均值更能反映真实体验

实际压测里,错误率可能不一定是 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 未回滚增加对账任务,数据库成功后才提交,失败补偿回滚
压测时出现 5xxRedis 连接数超限或线程池满检查 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 和数据库的库存差。数据对得上,吞吐量才有意义,不然数字再好看也是自欺欺人。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询