☰
Redis+Lua秒杀库存扣减实战:从超卖到12万QPS调优
2026/10/20 4:00:26 网站建设 项目流程

1. 秒杀超卖的本质:库存扣减的并发窗口

秒杀场景下超卖问题的根源,其实就一句话:读库存和扣库存之间存在时间窗口,多个请求在这个窗口内都读到了"还有库存"的状态。很多人第一反应是"加锁不就行了",但加锁的位置、粒度、时机不同,结果天差地别。

我见过最常见的错误写法是这样的:先从Redis查出库存,在应用层判断stock > 0,然后再去减库存。这个逻辑在单线程下没问题,但在并发环境下,两个请求可能同时读到stock = 1,然后各自都执行了扣减,最终库存变成-1,超卖就发生了。

正确的做法是把"判断"和"扣减"合并成一个原子操作。Redis本身是单线程处理命令的,所以只要我们把逻辑写在一个命令或者一段Lua脚本里,就能天然保证原子性。这也是为什么秒杀场景下Lua脚本几乎是标配——不是因为它高级,而是因为它把"读-判断-写"三步压缩成了一个不可分割的执行单元。

理解了这个本质,后面所有的方案设计都是围绕"如何保证原子性"和"如何在高并发下减少无效请求"这两个方向展开的。

1.1 为什么数据库层面的扣减扛不住秒杀流量

有人会问:数据库不是有行锁吗?用UPDATE stock SET count = count - 1 WHERE count > 0不也能保证不超卖?

能保证不超卖,但扛不住流量。秒杀瞬间可能有几万甚至几十万请求同时打到数据库,每个请求都要竞争行锁,数据库连接池瞬间被打满,大量请求排队等待,响应时间飙升到几秒甚至十几秒。更糟糕的是,这些请求里绝大多数最终都会因为库存不足而失败,但它们已经消耗了数据库的连接资源和锁竞争开销。

这就是典型的"无效流量击穿数据库"。Redis的价值在于它把库存判断前置到了内存层,用极高的吞吐量快速过滤掉绝大部分无效请求,只让少量真正有机会抢到的请求走到后续流程。这个思路叫做"层层过滤",后面我会详细展开。

1.2 超卖和少卖是两回事,别搞混了

超卖是卖出去了但库存不够,少卖是库存还有但没卖出去。很多人只关注超卖,忽略了少卖。少卖通常发生在异常处理不当的时候——比如Redis扣减成功了,但后续订单创建失败,库存没有回滚,导致库存被"吃掉"了但实际没有成交。

这两个问题的处理策略完全不同。超卖靠原子性解决,少卖靠补偿机制解决。在实际项目里,我建议把这两个问题分开考虑,不要试图用一套逻辑同时解决,否则代码会变得非常复杂且容易出bug。

2. Lua脚本扣减库存:从能用到好用

Lua脚本是Redis实现原子扣减的核心手段。但"能跑"和"跑得好"之间差距很大,我踩过的坑基本都集中在脚本的健壮性和边界处理上。

2.1 一段经过实战检验的扣减脚本

先看基础版本,这是最核心的逻辑:

-- KEYS[1]: 库存key -- ARGV[1]: 扣减数量 -- ARGV[2]: 用户标识(用于幂等) local stock = redis.call('GET', KEYS[1]) if not stock then return -1 -- 库存key不存在 end stock = tonumber(stock) local deduct = tonumber(ARGV[1]) if stock < deduct then return 0 -- 库存不足 end redis.call('DECRBY', KEYS[1], deduct) return 1 -- 扣减成功

这段脚本看起来简单,但有几个细节值得说。第一,GET之后要判断not stock,因为如果key过期或者被删了,tonumber(nil)会报错,整个脚本执行失败。第二,返回值用数字区分状态,调用方根据返回值做不同处理,比返回字符串更高效也更不容易出错。

调用方式(Java + Jedis为例):

String script = "local stock = redis.call('GET', KEYS[1]) ..."; Object result = jedis.eval(script, Collections.singletonList("seckill:stock:1001"), Arrays.asList("1", "user_12345")); int code = ((Long) result).intValue(); if (code == 1) { // 扣减成功,进入下单流程 } else if (code == 0) { // 库存不足,直接返回失败 } else { // 库存key不存在,可能是活动未开始或已结束 }

2.2 幂等性:同一个用户重复请求怎么办

秒杀场景下,同一个用户可能因为网络抖动或者手速太快,短时间内发出多个请求。如果不做幂等控制,一个用户可能抢到多件商品,这既不公平也可能超出限购规则。

我的做法是在Lua脚本里加入用户去重逻辑。用Redis的Set结构记录已经抢到的用户,每次扣减前先检查用户是否已经在集合里:

-- KEYS[1]: 库存key -- KEYS[2]: 已抢用户集合key -- ARGV[1]: 扣减数量 -- ARGV[2]: 用户标识 local exists = redis.call('SISMEMBER', KEYS[2], ARGV[2]) if exists == 1 then return -2 -- 重复请求 end local stock = redis.call('GET', KEYS[1]) if not stock then return -1 end stock = tonumber(stock) if stock < tonumber(ARGV[1]) then return 0 end redis.call('DECRBY', KEYS[1], ARGV[1]) redis.call('SADD', KEYS[2], ARGV[2]) return 1

这里有个需要注意的点:SADD和DECRBY必须在同一个脚本里,否则又会出现原子性问题。另外,已抢用户集合需要设置过期时间,否则会一直占用内存。通常设置为活动结束后一段时间即可。

注意:如果你的秒杀规则允许一个用户抢多件,那幂等逻辑要改成记录用户已抢数量,而不是简单的Set去重。

2.3 脚本缓存:别每次都传完整脚本

EVAL命令每次都要把完整的Lua脚本传给Redis,脚本大了会浪费带宽。更好的做法是用SCRIPT LOAD先加载脚本拿到SHA1值,后续用EVALSHA调用:

String sha = jedis.scriptLoad(script); // 后续调用 Object result = jedis.evalsha(sha, keys, args);

但这里有个坑:如果Redis重启了,脚本缓存会丢失,EVALSHA会报NOSCRIPT错误。所以生产环境必须做好降级处理——捕获NOSCRIPT异常后重新SCRIPT LOAD再执行。这个细节很多教程不会提,但在实际运维中非常关键。

3. 层层过滤:让无效请求死在最外层

Lua脚本解决了原子性问题,但如果几十万请求全部打到Redis,Redis虽然比数据库能扛,也不是没有上限的。真正成熟的秒杀系统会在Redis之前再加几层过滤,把无效流量挡在最外层。

3.1 前端限流:按钮置灰和答题机制

最外层是前端。用户点击"立即抢购"后,按钮立刻置灰,防止重复点击。这个看似简单的操作能过滤掉相当一部分重复请求。更进一步的做法是加入答题环节——用户需要回答一道简单题目才能进入抢购流程。这不是为了刁难用户,而是通过增加几秒的思考时间,把瞬间并发的峰值削平,同时也能挡住大部分脚本自动化的请求。

我实测过一个场景:不加答题机制时,峰值QPS在开抢后0.5秒内达到8万;加了答题后,峰值被拉平到3秒左右,峰值QPS降到2万左右。效果非常明显。

3.2 网关层限流:令牌桶的实际配置

网关层通常用令牌桶算法做限流。以常见的网关组件为例,配置大概是这样的思路:令牌桶容量设为库存量的2到3倍,令牌生成速率根据压测结果调整。

为什么是库存量的2到3倍?因为要允许一定程度的"陪跑"请求进入后端,否则用户体验太差——所有人都被限流挡在外面,连"没抢到"的反馈都看不到。但也不能放太多,否则Redis压力太大。这个倍数需要根据实际压测结果微调。

# 网关限流配置示例(伪配置,根据实际网关组件调整) rate_limit: enabled: true bucket_capacity: 3000 # 假设库存1000,容量设为3倍 refill_rate: 500 # 每秒补充500个令牌 strategy: token_bucket

3.3 Redis层预减库存:快速失败

经过前端和网关过滤后,到达Redis的请求量已经大幅下降,但仍然可能超过库存量。这时候Lua脚本的快速失败机制就发挥作用了——库存不足直接返回0,不产生任何后续操作。这个判断在内存中完成,耗时通常在毫秒级以下。

我建议在Redis层之上再加一个本地缓存标记。当Redis库存降到0时,在应用本地内存里设置一个标志位,后续请求直接读本地标志返回失败,连Redis都不用访问。这个优化在极端高并发下能进一步降低Redis压力。但要注意本地标志的更新延迟问题——多个应用实例之间的本地标志可能不一致,所以它只能作为"快速失败"的辅助手段,不能替代Redis的原子判断。

4. 异步下单:把同步链路砍到最短

秒杀的核心思路是"快进快出"——Redis扣减成功后,不要在主线程里做创建订单、写数据库、发消息等耗时操作,而是把任务丢到消息队列里异步处理,主线程立刻返回"抢购成功,订单处理中"。

4.1 消息队列的选型考量

消息队列的选择主要看几个维度:吞吐量、可靠性、延迟、运维成本。在高并发场景下,Kafka的吞吐量优势明显,但延迟相对较高;RocketMQ在延迟和吞吐之间比较均衡,且支持事务消息;RabbitMQ延迟低但吞吐量相对有限。

我的经验是:如果秒杀规模在每秒几万级别,RocketMQ或Kafka都够用;如果对延迟极其敏感(比如要求用户几乎立刻看到订单),可以考虑RocketMQ。但无论选哪个,核心原则是消息必须可靠投递,不能出现Redis扣减成功但消息丢失的情况。

4.2 消息丢失的三种情况和应对

消息丢失可能发生在三个环节:生产者发送失败、Broker存储丢失、消费者处理失败。

生产者发送失败的处理方式是本地消息表+重试。在Redis扣减成功后,先在本地数据库写一条消息记录(状态为"待发送"),然后发送消息到MQ。发送成功后更新状态为"已发送"。如果发送失败,后台定时任务扫描"待发送"的记录重新发送。这个方案牺牲了一点实时性,但保证了可靠性。

Broker存储丢失靠MQ本身的持久化机制解决,比如Kafka的acks=all配置、RocketMQ的同步刷盘。消费者处理失败则靠重试机制和死信队列兜底。

4.3 订单创建失败后的库存回滚

异步下单最大的风险是:Redis扣减成功了,但订单创建失败(比如数据库写入异常),这时候库存需要回滚。回滚逻辑要格外小心,因为回滚本身也可能失败。

我的做法是:消费者处理失败后,不直接回滚Redis库存,而是把失败消息投入重试队列。重试3次仍然失败后,才执行库存回滚,并记录详细日志人工介入。为什么要这样设计?因为很多失败是瞬时的(比如数据库连接超时),重试就能成功。如果一失败就回滚,反而可能导致"少卖"——库存回滚了但订单实际上创建成功了。

回滚操作同样要用Lua脚本保证原子性:

-- KEYS[1]: 库存key -- ARGV[1]: 回滚数量 local stock = redis.call('GET', KEYS[1]) if not stock then redis.call('SET', KEYS[1], ARGV[1]) return 1 end redis.call('INCRBY', KEYS[1], ARGV[1]) return 1

5. 压测中暴露的真实问题和调优记录

理论说再多,不如一次真实压测来得实在。我记录了一次模拟秒杀压测中遇到的几个典型问题,以及对应的调优过程。

5.1 连接池被打满:Jedis vs Lettuce

第一次压测时,QPS刚到5000就开始大量报错,日志里全是"Could not get a resource from the pool"。排查发现是Jedis连接池配置太小,默认只有8个连接。调大maxTotal和maxIdle后有所改善,但QPS到1万左右又出现瓶颈。

后来换成了Lettuce,它是基于Netty的异步客户端,多个线程可以共享一个连接,不需要为每个请求分配独立连接。切换后同样的硬件条件下QPS提升到了2.5万左右。这个经验告诉我:高并发场景下,连接模型比连接数量更重要。

5.2 热Key问题:单个库存Key的压力

所有请求都操作同一个库存Key,这个Key就是热Key。Redis虽然是单线程处理命令,但热Key会导致所有请求排队等待,成为瓶颈。

解决方案是库存分片。把1000件库存拆成10个分片,每个分片100件,请求根据用户ID哈希到不同分片。这样并发压力分散到10个Key上,吞吐量理论上提升10倍。但分片也带来了新问题:某个分片卖完了但其他分片还有库存,用户可能被误判为"库存不足"。解决办法是在分片库存不足时,尝试从其他分片"借"库存,这个逻辑同样用Lua脚本实现。

5.3 压测数据对比

优化阶段配置峰值QPS超卖情况平均响应时间
初始版本Jedis默认连接池,无分片5000无120ms
连接池调优Jedis maxTotal=20010000无85ms
切换LettuceLettuce共享连接25000无45ms
库存分片10个分片80000无22ms
本地快速失败分片+本地标志120000无8ms

这张表是我实际压测的粗略记录,硬件环境是4核8G的云服务器,Redis单实例。数据不是绝对精确,但趋势很清晰:每一步优化都有明确的收益,但收益递减。从5千到1万靠连接池,从1万到2.5万靠连接模型,从2.5万到8万靠分片,从8万到12万靠本地缓存。越往后优化成本越高,需要根据实际业务规模决定优化到哪一步。

6. 几个容易忽略的边界情况

6.1 活动开始前的库存预热

库存Key不能等到活动开始才设置,否则第一个请求进来时Key不存在,Lua脚本返回-1,用户看到的是"活动未开始"而不是"库存不足"。正确做法是在活动开始前几分钟就把库存写入Redis,并设置合理的过期时间。

过期时间怎么定?我的经验是设置为活动结束时间加上1到2小时的缓冲。太短了活动还没结束Key就过期了,太长了浪费内存。另外,如果活动可能延期,要有手动续期或者重新预热的机制。

6.2 Redis主从切换时的数据一致性

如果Redis做了主从架构,主节点写入后异步同步到从节点。在主从切换的瞬间,可能有少量写入还没同步到从节点,导致库存数据不一致。对于秒杀场景,这种不一致可能导致超卖。

严格的解决方案是使用Redis的WAIT命令,强制等待数据同步到指定数量的从节点。但这会显著增加延迟,降低吞吐量。我的建议是:如果业务对超卖零容忍,可以用WAIT;如果能接受极小概率的超卖(比如百万分之一),可以不用,但要做好事后对账和补偿。

6.3 库存扣减成功但用户未支付

用户抢到了但15分钟内没支付,库存需要释放。这个逻辑通常用延时消息实现:扣减成功后发送一条延时15分钟的消息,消费者收到后检查订单状态,如果未支付则回滚库存。

这里有个细节:回滚库存时要判断当前库存是否已经超过了原始总量。比如原始库存1000,卖出800,回滚100,库存变成900,这是正常的。但如果因为某些异常导致回滚了多次,库存可能超过1000。所以回滚脚本里要加一个上限判断:

local stock = tonumber(redis.call('GET', KEYS[1]) or 0) local maxStock = tonumber(ARGV[2]) local rollback = tonumber(ARGV[1]) if stock + rollback > maxStock then redis.call('SET', KEYS[1], maxStock) else redis.call('INCRBY', KEYS[1], rollback) end return 1

这个上限值就是活动的总库存量,需要作为参数传入。

7. 从单机到集群:规模上去之后的新问题

单机Redis优化到12万QPS已经不错了,但如果秒杀规模到了百万级别,单机就不够了。集群化是必然选择,但集群带来的问题比单机多得多。

7.1 Redis Cluster下的Lua脚本限制

Redis Cluster要求Lua脚本操作的所有Key必须在同一个槽(slot)上。库存Key和用户去重Key如果不在同一个槽,脚本会直接报错。解决办法是用Hash Tag强制它们落在同一个槽:

seckill:stock:{1001} seckill:users:{1001}

大括号里的内容相同,Redis就会把这两个Key分配到同一个槽。这个技巧在集群环境下非常实用,但要注意不要滥用——如果所有Key都用同一个Hash Tag,那就等于退化成单机了,失去了集群的意义。

7.2 集群下的库存分片策略

单机时分片是为了分散热Key压力,集群时分片还要考虑槽的分布。如果10个库存分片恰好落在同一个节点上,那分片就白做了。所以分片Key的设计要保证它们能均匀分布到不同节点。

我的做法是用一致性哈希或者直接根据分片编号取模来分配Key,确保分片均匀。同时监控每个节点的QPS和内存使用,如果发现倾斜,及时调整分片策略。

7.3 跨集群的数据同步

如果秒杀系统部署在多个区域,每个区域有自己的Redis集群,那就涉及到库存数据如何在集群间同步的问题。强一致性方案是只在一个中心集群扣减库存,其他区域远程调用,但延迟高。最终一致性方案是每个区域独立扣减,异步同步,但可能超卖。

我的建议是:秒杀场景下,库存扣减必须强一致,所以采用中心化扣减。其他区域可以通过CDN和边缘计算优化用户体验,但库存判断统一走中心集群。这样虽然增加了几十毫秒的网络延迟,但保证了不超卖。

8. 监控与告警:上线只是开始

秒杀系统上线后,最怕的是出了问题不知道。监控体系要覆盖几个关键指标:Redis库存余量、扣减成功率、消息队列积压量、订单创建成功率、接口响应时间。

库存余量监控要设置阈值告警,比如库存低于10%时提醒运营准备补货或者结束活动。扣减成功率突然下降可能意味着Redis出了问题。消息队列积压量持续增长说明消费者处理不过来,需要扩容。

我特别想强调的是对账机制。每天活动结束后,要对Redis库存、数据库订单、消息队列消息进行三方对账。Redis库存减少了多少,数据库里就应该有多少订单,消息队列里就应该有多少条消息。任何一方对不上,都要立即排查。这个机制帮我发现过好几次隐蔽的bug,比如某次消息重复消费导致订单多创建了几条,对账时立刻暴露了出来。

提示:对账脚本建议用独立的低峰期定时任务执行,不要和秒杀主流程抢资源。

9. 我踩过的三个印象最深的坑

第一个坑是Lua脚本里的类型转换。有一次脚本里写了redis.call('DECRBY', KEYS[1], ARGV[1]),ARGV[1]传的是字符串"1",DECRBY能正常处理。但后来改成先GET再比较时,忘了tonumber,导致字符串比较出现意外结果——"10" 和 "9" 比较时,字符串比较认为 "10" 小于 "9"。这个bug在测试环境没发现,因为测试时库存都是个位数。上线后库存到两位数才暴露,排查了半天。

第二个坑是消息重复消费。消费者处理完消息后,还没来得及提交offset就宕机了,重启后消息重新消费,导致订单重复创建。解决办法是在消费者端做幂等——用订单号或者用户ID+活动ID作为唯一键,插入数据库时做唯一约束,重复插入直接忽略。

第三个坑是Redis内存淘汰策略。默认的noeviction策略在内存满时会导致写入失败,秒杀直接不可用。改成allkeys-lru后,内存满时会淘汰最近最少使用的Key,但库存Key可能被淘汰掉,导致库存数据丢失。最终的方案是给库存Key设置独立的Redis实例或者单独的内存区域,不和其他缓存混用,同时设置maxmemory-policy为noeviction并做好内存监控和扩容。

这三个坑的共同点是:测试环境很难复现,只有真实流量才能触发。所以我的建议是,秒杀系统上线前一定要做全链路压测,尽可能模拟真实流量的各种异常情况。压测不只是测性能,更是测异常处理能力。

10. 关于方案选型的一点个人看法

市面上秒杀方案的资料很多,有简单的也有复杂的。我的看法是:不要为了技术而技术,根据实际业务规模选择方案。

如果秒杀规模在每秒几千,一个Lua脚本加数据库乐观锁就够了,不需要消息队列和分片。如果规模到了几万,加上消息队列异步下单和连接池优化。如果到了几十万甚至百万,才需要考虑集群、分片、本地缓存这些复杂方案。

我见过一些团队,业务量明明不大,却搭了一套非常复杂的架构,结果运维成本极高,出了问题排查半天。技术方案要和业务规模匹配,过度设计和设计不足都是问题。

另外,秒杀系统的核心指标不是QPS有多高,而是不超卖、不少卖、用户体验可接受。在这三个前提下,越简单的方案越好。每增加一个组件,就多一个故障点,多一份运维负担。我在实际项目中的体会是,先把单机方案做到极致,确实扛不住了再考虑分布式,这个顺序不能反。

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

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

立即咨询