文章目录
秒杀是互联网业务里压力最大的场景之一:百万用户在毫秒级时间窗口内涌入,争抢有限的库存。任何一个环节扛不住,要么超卖、要么宕机。Redis 在这里几乎是标配,但要把它用对,需要理解秒杀的本质瓶颈和 Redis 在每个环节的角色。
秒杀的特点与挑战
秒杀和普通业务完全不同:
- 流量瞬时极高:平时 QPS 几千的系统,秒杀瞬间可能冲到几十万。
- 请求高度集中:所有请求都打在同一个商品的库存 key 上。
- 有效请求很少:库存只有 1000,但可能有 100 万人来抢,99% 的请求是注定失败的。
- 不能超卖:无论并发多高,卖出的商品数不能超过库存。
- 公平性要求:先到先得,不能让后来的请求抢先。
秒杀的几个阶段
完整的秒杀流程通常分为四个阶段:
1. 秒杀前
用户访问商品详情页,刷新等待开抢。这个阶段流量大但都是只读请求。
Redis 角色:缓存商品详情、活动信息。所有静态数据走 CDN 和本地缓存,Redis 只兜底。
2. 秒杀进行中
开抢瞬间,前面三件事密集发生:
- 用户提交订单请求
- 校验是否有资格(白名单、限购)
- 扣减库存
Redis 角色:库存计数器、用户去重、限流。
3. 下单付款
抢到的用户进入下单页面,最终完成支付。这个阶段的并发量已经远小于第二阶段(只有库存数量的用户)。
Redis 角色:临时订单存储、支付状态。
4. 秒杀后
发货、售后等。和普通业务无异。
性能最关键的就是第二阶段。
Redis 在秒杀中的核心作用
库存预扣减
最核心的诉求:保证不超卖。
错误做法:在数据库做扣减。MySQL 的行锁会让所有请求排队,瞬间打爆数据库。
正确做法:把库存放在 Redis,用原子命令扣减。
# 初始化
SET stock:item_1001 1000
# 扣减
DECR stock:item_1001
# 返回值 >= 0 表示扣减成功
# 返回值 < 0 表示已经卖完
DECR 是原子操作,一万个客户端同时执行也不会超卖。Redis 的吞吐能轻松支撑 10 万 QPS。
但这种简单方案有个问题:返回值 < 0 时还是扣减了一次,需要再补回去:
DECR stock:item_1001
# 如果 < 0,做 INCR 补回
INCR stock:item_1001
中间这个补偿操作不是原子的,并发下可能让某些 key 短暂为负。
Lua 脚本:扣减 + 去重
更稳妥的方案是用 Lua 脚本,把"判断库存 + 扣减 + 记录用户"打包成原子操作:
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock <= 0 then
return 0
end
local exists = redis.call('SISMEMBER', KEYS[2], ARGV[1])
if exists == 1 then
return -1 -- 已经买过
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1
调用:
EVAL "..." 2 stock:item_1001 buyers:item_1001 user_8888
返回值约定:
1:抢购成功0:库存已空-1:用户已经抢过(防重复)
一次脚本调用搞定多个判断,避免来回网络。
用户限购
不同业务限购规则不同,常见的:
- 每人只能抢 1 件:Set 记录已购用户。
- 每人最多 N 件:Hash 记录每个用户的购买数量。
- 特定用户群体可抢:白名单 Set。
这些都是 Redis 的强项。
切片集群下的秒杀
单实例 Redis 能扛 10 万级 QPS,但更大的活动可能瞬间百万 QPS。这时需要切片集群。
但秒杀场景下切片集群有个棘手问题:所有请求都打到同一个 key,路由到同一个节点,集群的负载完全不均衡——一个节点忙死,其他节点闲着。
应对思路:库存分桶。把商品的库存拆成多个 key 分散到不同节点:
原方案:stock:item_1001 = 1000
新方案:stock:item_1001:0 = 100
stock:item_1001:1 = 100
...
stock:item_1001:9 = 100
请求来了,按用户 ID hash 选一个桶:
bucket = user_id % 10
key = f"stock:item_1001:{bucket}"
这样能把负载分散到 10 个节点。代价是逻辑变复杂:
- 某个桶卖完了,那部分用户就抢不到,即使其他桶还有库存。
- 需要更精细的桶分配策略。
实际工程上,桶数和总库存的关系要权衡。库存 1000 拆 10 个桶还行,但库存 100 拆 10 个桶就变成"每桶 10 件",公平性受影响。
异步下单:削峰填谷
秒杀第二阶段的目标是快速判断有没有抢到,至于真正的订单生成、扣减真实库存(数据库)、支付链路启动,都可以异步进行。
典型架构:
用户请求
↓
Redis 扣库存(原子)
↓
抢到的用户 → 投递到消息队列
↓
后端慢慢消费 → 创建订单 → 扣 DB 库存 → 发短信
Redis 在前端挡住瞬时高并发,消息队列在中间削峰,后端按自己的速度消费。这是秒杀架构的经典模式。
防止恶意刷单
秒杀场景下黑产极活跃,常见手段:
- 同一 IP / 用户 ID 高频请求。
- 用脚本提前请求接口。
- 多账号协同。
Redis 在限流层面可以做:
# IP 限流:1秒内不超过 10 次
INCR rate_limit:1.2.3.4
EXPIRE rate_limit:1.2.3.4 1
# 如果 > 10 直接拒绝
业务层还要配合:
- 验证码(图形 / 滑块)。
- 接口签名校验。
- 用户风控等级。
实践建议
- 核心扣减必须用 Lua,简单
DECR容易出现库存为负的瞬态。 - 库存分桶应对超大流量,但要权衡公平性。
- 削峰必须有消息队列,Redis 不是万能的。
- 预热缓存:开抢前把商品详情、用户信息预加载到 Redis。
- 限流前置:在 Nginx 或网关层就开始限流,别让所有请求都打到 Redis。
- 降级方案:极端情况 Redis 也可能挂,备一套静态降级页面。
- 监控关键指标:库存 key 的 QPS、Lua 脚本耗时、抢到与未抢到的比例。
秒杀不是一个单点能解决的问题,是从前端、网关、应用、缓存、队列、数据库的全链路设计。Redis 在中间是关键一环,但不是全部。理解 Redis 的能力边界(高吞吐、低延迟、原子操作),把它用在最合适的位置——库存预扣、用户去重、限流计数——就能撑起绝大多数秒杀场景。

1012

被折叠的 条评论
为什么被折叠?



