设计秒杀系统

一句话速记

秒杀核心矛盾:瞬时超高并发(10 万+ QPS)vs 有限库存(100 件)。解法:流量漏斗(层层过滤,让尽量少的请求打到数据库)+ 库存预减(Redis 原子操作提前扣减,避免超卖)+ 异步下单(MQ 削峰,订单异步创建)+ 幂等(重复请求只处理一次)。

设计要求分析

功能需求:
  - 商品详情展示(秒杀前)
  - 秒杀按钮(倒计时)
  - 秒杀请求(按钮点击后)
  - 订单查询(秒杀结果)

非功能需求:
  - 高并发:100 件商品,100 万用户抢,瞬时 10 万+ QPS
  - 不超卖:库存 100,最多只能卖 100
  - 防刷:单用户只能买 1 件
  - 高可用:单个组件故障不能整体崩溃
  - 低延迟:用户操作要有快速反馈

架构设计

1)流量漏斗(层层拦截)

用户(100万) → CDN(静态资源缓存)
                → Nginx(限流:单IP速率限制)
                  → API Gateway(用户维度限流:每用户每秒1次)
                    → 秒杀服务(令牌桶,防止穿透)
                      → Redis 库存预减(原子,快速失败)
                        → MQ(只有少量成功请求入队)
                          → 订单服务(异步创建订单)
                            → 数据库(少量 TPS)

目标:100万请求 → 数据库只处理 100 个

2)商品详情页(读多,CDN 缓存)

秒杀开始前:
  商品信息(图片、描述、价格)→ CDN 缓存(全球边缘节点)
  秒杀状态:NOT_STARTED / STARTED / SOLD_OUT → Redis 缓存(TTL=秒杀剩余时间)
  
秒杀倒计时:
  前端 JS 计时(本地时间)
  避免所有人同时在 00:00 刷新(服务端时间校准,允许 ±1s 误差)

秒杀按钮控制:
  状态=NOT_STARTED → 按钮灰色不可点
  状态=STARTED → 按钮可点
  状态=SOLD_OUT → 按钮变灰"已售罄"

3)秒杀请求处理

用户身份验证 + 防刷

// 1. 登录校验(JWT/Session)
// 2. 用户维度限流(Redis SETNX,1秒内只处理1次)
String userLimitKey = "seckill:user:" + userId + ":" + itemId;
if (!redis.setnx(userLimitKey, "1", 1, TimeUnit.SECONDS)) {
    return Response.fail("操作太频繁,请稍后重试");
}
// 3. 用户是否已购买(已秒杀成功不允许重复)
if (redis.sismember("seckill:bought:" + itemId, userId)) {
    return Response.fail("您已参与过本次秒杀");
}

Redis 库存预减(原子操作,防超卖)

-- Lua 脚本(原子:查询+扣减 不可分割)
local stock = redis.call('get', KEYS[1])
if not stock or tonumber(stock) <= 0 then
    return -1  -- 库存不足
end
-- 原子扣减
redis.call('decrby', KEYS[1], 1)
-- 记录已购用户(防重复购买)
redis.call('sadd', KEYS[2], ARGV[1])  -- KEYS[2]=bought:itemId, ARGV[1]=userId
return 1  -- 成功
Long result = redis.eval(LUA_SCRIPT, 
    Arrays.asList("seckill:stock:" + itemId, "seckill:bought:" + itemId),
    Arrays.asList(userId.toString()));
 
if (result == -1L) {
    return Response.fail("库存不足,秒杀结束");
}
 
// 4. 放入 MQ 异步下单
String orderNo = generateOrderNo();  // 预生成订单号
mqProducer.send("seckill_order", new SeckillMessage(userId, itemId, orderNo));
 
return Response.success("秒杀成功,订单处理中", orderNo);

异步下单(MQ 削峰)

// 消费者
@KafkaListener(topics = "seckill_order")
public void handleSeckillOrder(SeckillMessage msg) {
    // 幂等检查(消息可能重复消费)
    if (orderDao.existsByOrderNo(msg.getOrderNo())) return;
    
    // 数据库扣库存(二次校验,防止 Redis 数据误差)
    int affected = itemDao.decreaseStock(msg.getItemId(), 1);
    if (affected == 0) {
        // 数据库库存也不够(极少数情况)
        // 发送失败通知给用户(MQ 补偿通知)
        notifyUser(msg.getUserId(), "很遗憾,秒杀失败");
        return;
    }
    
    // 创建订单
    Order order = new Order(msg.getOrderNo(), msg.getUserId(), msg.getItemId());
    orderDao.save(order);
    
    // 通知用户(短信/App 推送)
    notifyUser(msg.getUserId(), "恭喜秒杀成功!订单号:" + msg.getOrderNo());
}

4)超卖防护(双重保险)

防护 1(Redis):Lua 原子扣减,Redis 库存 ≥ 1 才允许
防护 2(DB):    UPDATE items SET stock=stock-1 WHERE id=? AND stock>0
                  affected_rows=0 → 超卖(极少发生,Redis 已拦截大部分)
防护 3(唯一索引):(user_id, item_id) 唯一索引 → 防止同一用户多次下单

5)系统初始化

秒杀开始前(提前 10 分钟):
  1. Redis 预热:SET seckill:stock:{itemId} 100(库存写入 Redis)
  2. 清空 bought 集合:DEL seckill:bought:{itemId}
  3. 预生成秒杀 token 池(可选,进一步过滤流量)
  4. CDN 预热:商品详情页图片和静态资源推到 CDN

6)订单支付超时处理

秒杀成功 → 创建订单 → 用户需在 15 分钟内支付
超时未支付 → 取消订单 → 释放库存

实现:
  Redis Sorted Set(score=过期时间戳)
  定时任务每秒扫描 score < now() 的订单 → 取消
  或:延迟消息(RocketMQ 延迟级别,15分钟后触发取消)
  
  取消时:
    UPDATE orders SET status=CANCELLED WHERE order_no=?
    Redis INCR seckill:stock:{itemId}  // 归还库存
    Redis SREM seckill:bought:{itemId} userId  // 允许用户再次秒杀(可选)

关键数字

场景:100 件商品,100 万用户
目标:QPS 峰值 10 万

流量漏斗各层处理量:
  CDN:10 万 QPS(静态资源直接返回)
  API Gateway 限流:过滤 90%,剩 1 万 QPS
  用户去重(Redis SETNX):过滤 99%,剩 100-1000 QPS
  Redis 库存扣减:成功 100 次,其余快速返回"库存不足"
  MQ:入队 100 条消息
  DB:写入 100 条订单记录

延伸追问

  • Q:Redis 扣完库存但 MQ 发送失败,怎么处理? → 本地事务消息:Redis 扣减成功 + 本地 DB 写 outbox 消息(同一 Redis 事务无法做到,但可以先扣 Redis,MQ 发失败则重试 3 次,3 次失败则归还库存)。更可靠:用 Redis INCR 归还,用幂等 ID 保证重试不重复扣减。
  • Q:如果只有一台 Redis,Redis 挂了怎么办? → Redis 集群(主从+哨兵或 Cluster)保证高可用。极端情况 Redis 全挂:降级到数据库(SELECT FOR UPDATE 悲观锁),但 QPS 大幅下降(只能承受几百 TPS),同时触发限流保护 DB。

我的记法

  • 核心:流量漏斗 + Redis 原子扣减 + MQ 异步下单
  • 防超卖:Lua 原子操作(查+减不可分)+ DB where stock>0(兜底)
  • 防刷:用户维度限流(SETNX 1s)+ bought 集合去重
  • 初始化:秒杀前预热 Redis 库存
  • 一句话:「CDN 挡静态,限流挡重复,Redis Lua 原子扣库存,MQ 异步创订单」

状态

  • 已背速记
  • 能画完整数据流
  • 能写 Redis Lua 扣减脚本
  • 能解释防超卖的双重保险