主题
秒杀系统设计
一句话:秒杀把瞬时流量挡在核心资源之前,用多层过滤把「抢」变成「排队」,代价是异步结果、最终一致和一套对账机制。
适用:Redis 7.x、RocketMQ 5.x、MySQL 8.0+。文中的容量数字只用于说明过滤比例,不是基准测试结果。
为什么需要
平时 5000 QPS 的系统,活动开始瞬间可能涌进 100 万请求,而库存只有 1000 件。绝大多数请求注定失败,但如果不加拦截,它们会一起打到数据库,把连接池、行锁和磁盘全部拖垮。
秒杀设计的目标不是「让所有人都成功」,而是:只让库存数量的请求进入下单链路,其余请求尽早、便宜地失败,并且任何情况下不超卖。
核心机制
层层过滤
一个典型的过滤比例(示例假设):
| 层 | 作用 | 示例流量 |
|---|---|---|
| CDN/前端 | 静态化、按钮防重、随机丢弃 | 100 万 |
| 网关 | 令牌桶、黑名单、验证码 | 10 万 |
| 业务服务 | 参数校验、用户维度限流 | 5 万 |
| Redis | Lua 原子扣库存 | 5 万 |
| 消息/队列 | 只保留扣减成功的请求 | 1000 |
| 数据库 | 创建订单、最终扣减 | 1000 |
每往下一层,流量应该少一个量级。如果某一层没有明显收敛,说明这一层没有起作用。
Redis Lua 原子扣库存
查库存、判断限购、扣减、记录用户必须在一个 Lua 脚本里完成,Redis 单线程执行脚本期间不会插入其他命令:
lua
-- KEYS: {seckill:1001}:stock / :users / :orders
-- ARGV: uid, goodsId
local stock = tonumber(redis.call('GET', KEYS[1]) or '0')
if stock <= 0 then return 0 end
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -1 end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
redis.call('XADD', KEYS[3], '*', 'uid', ARGV[1], 'goods', ARGV[2])
return 1三个 key 用 {seckill:1001} 哈希标签,保证在 Redis Cluster 中落在同一个 slot。
XADD 只是把消息写进 Redis Stream,它不是 MQ。 如果文章里写 LPUSH,那也只是写了一个 Redis 列表,同样不是 MQ。Redis 在这里承担的是「扣减成功的持久化收件箱」;还需要一个独立的搬运进程,用消费者组读取 Stream,再投递到 RocketMQ/Kafka:
text
伪代码(搬运进程):
loop:
msgs = XREADGROUP GROUP relay c1 COUNT 100 BLOCK 2000 \
STREAMS {seckill:1001}:orders >
for m in msgs:
mq.send("seckill-order", m)
XACK {seckill:1001}:orders relay m.id先投递 MQ 再 XACK;如果中间崩溃,消息会重复投递,由下单消费者按 uid + goodsId 幂等。也可以用 XPENDING/XCLAIM 处理长时间未确认的消息。
异步下单与结果查询
Redis 扣减成功只代表「抢到了名额」,不代表订单已创建。接口立即返回 QUEUED + token,前端轮询结果;消费者创建订单、写数据库、扣减数据库库存,成功后把 token 标记为 SUCCESS。
消费失败时不能一律回滚 Redis 库存:先查订单是否已经落库,只有确认未落库才 INCR 库存并 SREM 用户记录。否则会出现「订单已创建、库存又被加回去」的超卖。
订单超时取消:延迟队列
抢到名额后通常有支付窗口,超时未支付要取消订单并回补库存。用 Redis ZSET 按到期时间排序:
bash
ZADD delay:order 1760000000000 order:123
ZRANGEBYSCORE delay:order 0 1760000060000 LIMIT 0 100
ZREM delay:order order:123多个消费者同时取任务会重复处理,取任务和删除要用 Lua 原子完成,或者用 ZPOPMIN 配合到期判断。任务量很大时用时间轮(插入和触发 O(1))或 RocketMQ 5.x 的定时/延迟消息;ZSET 的 O(log N) 实现更简单,适合中等规模。
防超卖的四道防线
- Lua 脚本内「检查库存 + 扣减」原子执行。
- Redis 单线程执行脚本,脚本内部不会被其他请求插入。
- 数据库
UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock >= 1,用 affected rows 判断是否扣到。 - 订单表
UNIQUE(user_id, goods_id)防重复购买。
前两道挡住 Redis 层的并发,后两道是数据库层的最终防线。Redis 和数据库都要有,不能只信一边。
数据库库存分桶
单行 UPDATE 会把所有请求串行到同一把行锁上。把库存拆成 100 个桶,扣减时随机选桶,可以把行锁竞争分散到 100 行:
sql
CREATE TABLE seckill_stock_bucket (
goods_id BIGINT NOT NULL,
bucket_id INT NOT NULL,
stock INT NOT NULL,
PRIMARY KEY (goods_id, bucket_id)
);
UPDATE seckill_stock_bucket
SET stock = stock - 1
WHERE goods_id = ? AND bucket_id = ? AND stock >= 1;理论上限会提高,但实际还受连接池、磁盘和事务开销限制,不要把「5000 × 100」当成保证值。
降级
Redis 不可用时切到数据库扣减,必须同时把网关限流阈值降下来。数据库的行锁上限远低于 Redis,只切数据源不降流量,等于把数据库直接打爆。
最小上手
准备库存并用脚本扣减:
bash
redis-cli SET '{seckill:1001}:stock' 1000
redis-cli --eval seckill.lua \
'{seckill:1001}:stock' '{seckill:1001}:users' '{seckill:1001}:orders' , \
20001 1001
# 返回 1 表示抢到,0 表示库存不足,-1 表示重复购买
redis-cli XLEN '{seckill:1001}:orders'验证:并发执行同一 uid 两次,第二次应返回 -1;库存扣到 0 后应返回 0;XLEN 应与成功次数一致。
生产动作
- 预热:活动前把库存写入 Redis,并和数据库对账;不要在活动开始瞬间才初始化。
- Stream 长度:
XADD可加MAXLEN ~ N做近似裁剪,但不要裁掉未确认消息;监控XPENDING和消费延迟。 - 消费者:按
uid + goodsId幂等;失败重试要有上限;死信要告警。 - 对账:活动结束后核对 Redis 剩余库存、数据库订单数、支付数;差异要能定位到具体 uid。
- 监控:库存余量、Stream 积压、下单成功率、数据库
affected rows=0的比例、网关限流命中数。 - 压测:重点压 Lua 脚本和下单消费者,不要只压 HTTP 入口。
边界
- 普通流量不需要秒杀架构;为了一个日常接口引入异步下单,只会增加复杂度。
- Redis 不是库存的最终真相,数据库和对账才是;只靠 Redis 扣减迟早会出现不一致。
- 过滤层不能误伤真实用户;验证码、黑名单和随机丢弃都要有申诉或兜底通道。
- MQ 只能削峰,不能提升下游处理能力;消费者长期追不上,积压只会变成延迟。
- 库存分桶会增加「查总库存」和「回滚」的复杂度,库存很小时不值得。
讲清楚
秒杀的本质是流量远大于库存,所以先过滤、再扣减、最后异步落库。前端和网关挡掉绝大多数请求,Redis 用 Lua 原子地检查库存、扣减、记录用户,再把成功请求写进一个持久化收件箱;这个收件箱不是 MQ,需要搬运进程投递到真正的消息队列。下单消费者幂等创建订单,数据库用条件更新和唯一索引做最后防线。Redis 挂了要切数据库并同步降级限流,活动结束必须对账。整个设计的目标不是快,而是在高并发下不超卖、可解释、可恢复。