跳到正文

秒杀系统设计 ​

一句话:秒杀把瞬时流量挡在核心资源之前,用多层过滤把「抢」变成「排队」,代价是异步结果、最终一致和一套对账机制。

适用:Redis 7.x、RocketMQ 5.x、MySQL 8.0+。文中的容量数字只用于说明过滤比例,不是基准测试结果。

为什么需要 ​

平时 5000 QPS 的系统,活动开始瞬间可能涌进 100 万请求,而库存只有 1000 件。绝大多数请求注定失败,但如果不加拦截,它们会一起打到数据库,把连接池、行锁和磁盘全部拖垮。

秒杀设计的目标不是「让所有人都成功」,而是:只让库存数量的请求进入下单链路,其余请求尽早、便宜地失败,并且任何情况下不超卖。

核心机制 ​

层层过滤 ​

一个典型的过滤比例(示例假设):

层作用示例流量
CDN/前端静态化、按钮防重、随机丢弃100 万
网关令牌桶、黑名单、验证码10 万
业务服务参数校验、用户维度限流5 万
RedisLua 原子扣库存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) 实现更简单,适合中等规模。

防超卖的四道防线 ​

  1. Lua 脚本内「检查库存 + 扣减」原子执行。
  2. Redis 单线程执行脚本,脚本内部不会被其他请求插入。
  3. 数据库 UPDATE stock SET stock = stock - 1 WHERE id = ? AND stock >= 1,用 affected rows 判断是否扣到。
  4. 订单表 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 挂了要切数据库并同步降级限流,活动结束必须对账。整个设计的目标不是快,而是在高并发下不超卖、可解释、可恢复。

把一门技术讲透到既懂也会用