秒杀库存扣减对缓存与计算资源的占用,本质上是热点写操作与有限资源的对抗,解决思路是“宁可排队慢一点,也不要让缓存瞬间被打穿”。 简单说,一个秒杀活动里,几十万人同时点“立即购买”,如果每个人都直接对着库存扣减逻辑发起请求,无论是Redis还是应用服务器,都会在几秒内耗尽所有连接、线程和内存,把扣减动作拆成“预扣缓存 + 异步落地”,才是大多数高并发秒杀系统的通用解法。
秒杀库存扣减对缓存与计算资源的占用是怎样发生的?
很多团队第一次做秒杀,习惯性地把库存放在MySQL里,用UPDATE语句扣减,等到上线当天,数据库连接池瞬间被占满,紧接着Redis也扛不住了因为为了减轻数据库压力,大家会先把库存预热到Redis,然后每次请求先读Redis判断有没有货,这看似合理,但忽略了一个关键问题:Redis虽然快,它的CPU和网络带宽同样有上限。
一次秒杀请求的“惊涛骇浪”
假设一个商品有5000件库存,活动开始后10秒内涌入100万请求,每个请求都要执行以下逻辑:
- 查询Redis中的库存值(读操作)
- 判断库存是否大于0(计算操作)
- 扣减库存并写回Redis(写操作)
- 同步给下游订单服务(额外请求)
每秒10万QPS的读写压力,Redis单实例能扛住读,但写操作要加锁保证原子性,一旦用上WATCH或事务,性能直接下降一个数量级,更棘手的是,这100万请求里,绝大多数都集中在同几个商品key上这就是典型的“热点key”,热点key会让Redis的某个分片CPU打满,而其他分片空闲,形成木桶效应。
缓存资源被谁悄悄吃掉了?
缓存不只包含Redis内存,还包含应用服务器上的本地缓存(如Caffeine),但秒杀场景下,库存数据变化极快,本地缓存几乎无法使用你不想让用户看到“还有10件”,点进去却被告知“已售罄”,于是所有读请求都打到Redis,连接数、内存带宽、网卡流量都被这一个key吃满,业内专家指出,单热点key的Redis访问量一旦超过每秒5万次,延迟就会从0.1ms飙升到5ms以上,这个延迟对于秒杀来说意味着大量超时重试,进一步放大资源占用。
计算资源为什么总是最先告急?
应用服务器上的计算压力往往比缓存更早暴露,因为每次扣减前要校验用户资格、限购数量、幂等标记等,这些逻辑需要CPU做大量序列化、反序列化、字符串匹配操作,再加上Tomcat线程池默认200个线程,每个线程处理一次秒杀请求的平均耗时如果从2ms上升到20ms,吞吐量就直接掉到十分之一。

计算资源稀缺的本质是“无效计算太多”比如很多请求在扣减前就要查一遍用户订单历史,这一查就是一次数据库或缓存访问,占用的CPU和I/O完全不亚于库存扣减本身。
秒杀系统库存扣减方案对比:哪种更适合高并发?
把不同方案放在一起看,你会发现没有完美方案,只有适合场景的取舍,下面用表格对比最常见的三种实现路径。
| 方案 | 核心机制 | 资源占用特点 | 典型适用场景 |
|---|---|---|---|
| 数据库乐观锁扣减 | UPDATE stock SET count=count-1 WHERE id=? AND count>0 |
数据库行锁竞争大,CPU和I/O高,但数据绝对一致 | 秒杀并发量低于1万,内部活动 |
| Redis原子扣减 | DECR或Lua脚本 |
单key热点压力大,但扣减速度快,内存占用高 | 秒杀并发量超过10万,库存预热 |
| 异步队列扣减 | 先入MQ,worker批量扣减 | 缓存压力小,计算资源平稳,但存在最终一致性延迟 | 电商大促,允许短暂超卖 |
秒杀库存扣减对缓存与计算资源的占用在异步方案里如何缓解?
异步方案的精髓是把“同步扣减库存”变成“同步预占额度”,用户请求进来后,只往Redis里写入一个“预占标记”,比如用SETNX占一个坑位,同时发送消息到RabbitMQ或Kafka,真正扣减库存的操作由后端worker从队列里拉取消息后批量执行,执行时再检查Redis中的实际库存,这样一来,Redis承受的写QPS从100万降到几千,计算资源也从“为每个请求做完整校验”变为“批量处理消息”,整体资源占用曲线变得平滑。
纯Redis扣减:当心内存和网络被“自我放大”
很多人觉得用DECR就是最优解,但忽略了重试机制带来的放大效应,客户端超时后会自动重试,一个用户可能发5个DECR请求,库存扣了5次,虽然最后可以通过事务回滚,但Redis的CPU和网络已经被白白消耗了5倍,这种情况下,更推荐用Lua脚本把“判断库存 + 扣减 + 记录用户”封装成原子操作,服务端直接返回成功或失败,客户端不需要重试,资源占用立刻降低大半。

秒杀库存扣减怎么实现才能不拖垮缓存和CPU?
这里给出可落地的操作路径,每一步都有明确的资源控制意图。
用Lua脚本把扣减逻辑“焊死”在Redis里
不要用DECR加“再判断是否小于0”这种两步走,而是写一个Lua脚本一次性完成三个动作:
-- KEYS[1]: 库存key
-- ARGV[1]: 扣减数量
-- ARGV[2]: 用户id
if tonumber(redis.call('get', KEYS[1])) <= 0 then
return 0 -- 没库存了
end
redis.call('decrby', KEYS[1], ARGV[1])
redis.call('sadd', 'sold_users', ARGV[2]) -- 记录已购买用户
return 1
脚本在Redis内部执行,不产生任何网络往返,也天然具备原子性,相比分开的GET和DECR,CPU耗时可以减少60%以上,网络I/O也只剩一次,多数情况下,这个脚本就能让单台Redis扛住每秒3万以上的秒杀请求。
减少计算资源浪费的三个细节
- 提前过滤无效请求:在进入秒杀逻辑前,用布隆过滤器拦截未参与活动的用户,避免对每个请求都做完整的Redis查询。
- 本地缓存标记“已售罄”:一旦Redis中库存归零,服务端立刻在本地缓存写入一个短暂的“售罄标记”(比如30秒),后续请求直接返回“已抢光”,不再访问Redis。
- 限制用户维度的并发:用
SETNX user_lock限制同一用户只能发起一次扣减请求,从源头减少重复计算。
多级缓存兜底:别让Redis孤军奋战
在Redis前面加一层本地缓存,但注意只缓存“活动元数据”(比如秒杀开始时间、商品名称),不缓存库存,真正扣减时,本地缓存可以帮你挡掉90%的“开始时间未到”或“活动不存在”的请求,这些请求根本不需要触达Redis,实际压测数据显示,加了这一层后,Redis的CPU使用率可以从80%降到30%左右。
高并发秒杀场景下的资源压测与调优关键点
压测的时候不要只看QPS,要盯着三个具体指标:Redis的instantaneous_ops_per_sec、used_memory变化曲线、应用服务器的GC频率。
压测时重点观察哪些数据?
- Redis的CPU核数:如果单核CPU打满,说明热点key太集中,需要把库存拆分为多个子key或改用本地缓存拦截。
- 网络带宽占用:一次秒杀请求如果Redis通信数据量超过500字节,会严重影响吞吐,精简返回值。
- 线程等待时间:用
jstack抓线程状态,如果大量线程处于BLOCKED状态,说明锁竞争严重,考虑改用异步扣减。

常见容量误区
- Redis是内存数据库,加内存就能解决问题,其实热点key的瓶颈在CPU和单线程处理能力,加内存毫无帮助。
- 数据库换成Redis就够了,没有异步层保护,Redis一样会被打满,只是从“慢死”变成“快死”。
- 库存拆分为多个key可以完全分散压力,拆分后每个子key相当于独立库存,但“总和剩余库存”要知道,你就得额外维护一个总key,反而增加计算负担,只有配合Lua脚本做预扣减才划算。
秒杀库存扣减对缓存与计算资源的占用:Q&A
秒杀库存扣减用Redis还是MySQL?
看并发量,低于每秒1万,直接用MySQL的乐观锁扣减最省事,配合数据库索引可以保证正确性,超过每秒5万,必须用Redis做第一道防线,然后把扣减记录异步同步回MySQL,行业共识认为,秒杀场景下Redis定位是“流量闸门”,MySQL定位是“最终账本”,两者缺一不可。
库存扣减失败了怎么回滚?
如果是Redis扣减成功但后续下单失败,需要使用“库存回补”机制,推荐在Lua脚本中同时记录一个“扣减流水key”,比如STOCK_LOG:{userId},回滚时先判断流水是否存在,存在则执行INCR并删除流水,这里有个陷阱:回滚操作和订单状态之间需要保证幂等性,否则用户重复取消订单会导致库存被多次加回。
缓存穿透和击穿在秒杀场景有什么区别?
穿透是请求了一个根本不存在的商品key,秒杀中很少见,因为活动商品id是确定的。击穿是热点key失效的一瞬间大量请求打到数据库,在秒杀中危害极大,解决方法是把热点库存key的TTL设为-1(永不过期),手动在库存为0时更新为特殊值,用互斥锁只允许一个请求去重建缓存,也能有效缓解击穿压力。
说到底,秒杀库存扣减对缓存与计算资源的占用,本质上是用稀缺的共享资源去服务一批突发且短促的需求,把扣减动作拆成“预占 + 异步”,再用Lua脚本压缩单请求的资源消耗,让缓存只做最核心的原子操作,让计算资源专注于业务校验,这套组合拳才是应对百万级秒杀的稳妥姿势。