Skip to content

设计一个电商秒杀系统

提出问题

秒杀是电商系统中最具挑战性的场景之一。双 11 秒杀、限量抢购、限时折扣——这些活动的共同特征是:瞬间涌入大量用户,但商品数量极其有限(比如 1000 台 iPhone 卖 1999 元,10 万人同时抢)。面试官出这道题,核心考察三个维度:库存扣减的并发安全(怎么保证不多卖)、流量削峰(怎么让系统不被瞬间冲垮)、高可用兜底(Redis 挂了怎么办)。这是面试中出现频率最高的系统设计题,没有之一。

生产上,秒杀系统是"不能出错的系统"——多卖了要赔钱,少卖了要背锅,系统挂了则整场活动归零。所以这道题不是考你背方案,而是看你有没有真正处理过百万级并发写。

漏斗架构:流量逐层过滤

秒杀系统的核心设计思想是"漏斗"——流量从入口到最终下单,每一层都在削峰减量。典型的四层漏斗:

用户请求 (10万 QPS)


┌─────────────────────────────┐
│  接入层: Nginx 限流 + 验证码  │   → 过滤到 ~1万 QPS
└─────────────────────────────┘


┌─────────────────────────────┐
│  应用层: 令牌桶 + 去重        │   → 过滤到 ~5000 QPS
└─────────────────────────────┘


┌─────────────────────────────┐
│  扣库存层: Redis Lua 原子扣减  │   → 只放行真正扣到库存的请求
└─────────────────────────────┘


┌─────────────────────────────┐
│  持久化层: MQ 异步写 DB + 对账  │   → 最终 DB 写 ~1000 条
└─────────────────────────────┘

秒杀 1000 件商品,1 秒 10 万请求进来,经过四层过滤后,最终落到 DB 的写操作只有 1000 条。每一层都把上一层的流量再砍掉一个数量级。

接入层细节

Nginx 层面做两件事:

  • IP 维度的单机限流limit_req_zone $binary_remote_addr zone=flash:10m rate=1r/s,每个 IP 每秒只能发 1 个请求
  • 验证码/UUID 准入:前端在秒杀前 1 秒才展示验证码,防止机器提前刷到接口
Nginx 配置示例:
    limit_req_zone $binary_remote_addr zone=flaship:10m rate=1r/s;
    limit_req zone=flaship burst=5 nodelay;

踩坑记录:某次秒杀活动,Nginx 的 limit_req 配置了 burst=0,结果大量正常用户因为瞬间并发被拦截,投诉率飙升。生产上 burst 至少设为 5-10,配合 nodelay 使用,让短时间内的突发请求在限流速率内尽快通过,而不是排成队列。

库存扣减:原子的 Redis DECR 还不够

库存扣减是秒杀中最敏感的环节。先看错的方案:

java
// ❌ 错误做法:先查再扣,非原子操作
int stock = redisTemplate.opsForValue().get("stock:iphone16");
if (stock > 0) {
    redisTemplate.opsForValue().decrement("stock:iphone16");
    // 并发竞争:两个线程同时读到 stock=1,都执行 decrement,库存变 -1
}

正确的做法是用 Redis 原子操作:

java
// ✅ 正确做法:Lua 脚本保证原子性
// 注意:Redis 集群模式下,KEYS 必须都在同一个 slot
// 否则会报 CROSSSLOT 错误
String luaScript = """
    local stock = redis.call('GET', KEYS[1])
    if not stock or tonumber(stock) <= 0 then
        return 0  -- 库存不足
    end
    redis.call('DECR', KEYS[1])
    return 1  -- 扣减成功
""";
Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    Collections.singletonList("stock:iphone16")
);

为什么单机 Redis 只用 DECR 就够了? Redis 的 DECR 命令本身是原子的(单线程模型),但生产环境基本不会用单机 Redis,集群模式下 GETDECR 是两个网络往返,非原子。所以必须用 Lua 脚本将两个操作打包成一次执行。

踩坑:CROSSSLOT 错误

ERR CROSSSLOT Keys in request don't hash to the same slot

在 Redis Cluster 中使用 Lua 脚本时,KEYS 数组里的所有 key 必须落在同一个 hash slot。解决方案:用 {hashtag} 强制 key 到同一个 slot:

java
redisTemplate.execute(script, 
    Collections.singletonList("stock:{iphone16}"), // 用 {xxx} 保证同 slot
    args
);

双写 + 对账是生产必选项

Redis 扣减成功后,将扣减记录写入 MQ,消费端异步写 DB 订单。完整流程:

用户请求 → Redis Lua 扣库存(同步)
    ├─ 成功 → 发送 MQ 消息(异步)
    │         └─ 消费端写 DB 订单表
    └─ 失败 → 返回"已售罄"
java
// 服务端完整流程
public boolean flashSale(Long userId, Long productId) {
    // 1. 去重(用户只能抢一次)
    String userKey = "flash:users:" + productId;
    if (Boolean.TRUE.equals(redisTemplate.opsForSet().isMember(userKey, userId.toString()))) {
        return false; // 已抢过,直接返回
    }

    // 2. Redis Lua 原子扣库存
    Long result = executeStockLua(productId);
    if (result == 0) {
        return false; // 库存不足
    }

    // 3. 记录用户(防重复,同时用于后续释放未支付库存)
    redisTemplate.opsForSet().add(userKey, userId.toString());

    // 4. 发送 MQ 消息,异步写订单
    FlashSaleMessage msg = new FlashSaleMessage(userId, productId);
    kafkaTemplate.send("flash-sale-order", JSON.toJSONString(msg));

    return true;
}

对账脚本:每隔 5 分钟,对比 Redis 剩余库存 + DB 已售数量 = 总库存。不一致则触发告警。对账脚本的核心逻辑:

sql
-- 对账查询
SELECT COUNT(*) as sold_count FROM flash_sale_order 
WHERE product_id = 1001 AND status = 'PAID' AND create_time > '2026-07-23 00:00:00';

-- 获取 Redis 剩余库存
redis> GET stock:{1001}
-- 判断:sold_count + redis_stock == total_stock ?

踩坑:对账脚本查 DB 时,如果不加时间范围,会把历史已售数据也统计进去,导致一直对不平。必须加 create_time > 活动开始时间 过滤。

流量削峰:MQ 异步 + 限流四件套

削峰不止是"用 MQ"这么简单,需要组合拳:

java
// 1. 令牌桶限流(Guava RateLimiter)
RateLimiter limiter = RateLimiter.create(1000); // 每秒最多 1000 个令牌

// 2. 秒杀请求入队(Kafka/RocketMQ)
public boolean tryFlashSale(Long userId, Long productId) {
    // 限流检查
    if (!limiter.tryAcquire()) {
        return false; // 直接返回"抢光了"
    }
    // 去重检查(同一用户只能抢一次)
    Boolean existed = redisTemplate.opsForSet().isMember("sale:iphone16:users", userId.toString());
    if (Boolean.TRUE.equals(existed)) {
        return false;
    }
    // 发送到 MQ,消费端异步处理
    kafkaTemplate.send("flash-sale-order", userId + ":" + productId);
    return true;
}

// 3. 消费端限速:逐个处理,不过载 Redis
@KafkaListener(topics = "flash-sale-order", containerFactory = "singleRecordFactory")
public void consume(ConsumerRecord<String, String> record) {
    // 扣库存(Redis DECR)→ 写订单(DB)
    // 失败则重试 3 次,仍失败入死信队列人工处理
}

消费端限速:最容易忽略的一环

错误的消费端配置

yaml
# 默认配置:一次拉取 500 条,并发处理
spring.kafka.consumer.max-poll-records: 500

这样配置后,500 个扣库存请求同时打到 Redis,Redis 单线程 CPU 直接飙到 100%,延迟从 1ms 升到 100ms+,雪崩。

正确的配置

yaml
spring.kafka.consumer.max-poll-records: 1
spring.kafka.listener.concurrency: 1

RocketMQ 对应设置 consumeMessageBatchMaxSize=1。让消费端逐个处理,而不是批量处理。

四种限流方案对比

方案适用场景优点缺点生产推荐度
Nginx limit_reqIP 维度接入层配置简单,不侵入代码只能按 IP,无法按用户⭐⭐⭐⭐
Guava RateLimiter单机应用层轻量,零依赖不适用于分布式,多节点不准确⭐⭐⭐
Sentinel分布式应用层支持集群流控、熔断降级、实时监控需要额外部署 dashboard⭐⭐⭐⭐⭐
Redis 计数器分布式全局流控精确,能按用户/商品维度高并发下 Redis 压力大⭐⭐⭐

生产推荐:Sentinel + Nginx 双层限流。Nginx 挡大头,Sentinel 做精细控制。

三级库存与"静默期"预热

P7/P8 级别的生产方案,需要区分三级库存

库存类型定义存储位置更新方式典型值
展示库存用户看到的"还剩 X 件"Redis异步更新,允许秒级延迟每 2 秒同步一次
冻结库存秒杀期间预扣的Redis秒杀预扣时实时更新预扣数量 = 下单数量
实际库存仓库真实库存DB订单支付后扣减扣减完成后写入

为什么需要三级库存? 没有三级库存时,一个用户秒杀成功但 15 分钟内未支付,这 15 分钟内商品显示"已售罄",其他用户无法购买,造成库存浪费。三级库存的解耦思路:

秒杀成功 → 冻结库存 +1(Redis)
    └─ 15 分钟内支付 → 冻结库存 -1,实际库存 -1(DB)
    └─ 15 分钟后未支付 → 冻结库存 -1,释放到展示库存(Redis)

定时释放脚本(每 30 秒执行一次)

sql
-- 查询超时未支付的订单
UPDATE flash_sale_order 
SET status = 'TIMEOUT' 
WHERE status = 'CREATED' AND create_time < NOW() - INTERVAL 15 MINUTE;

-- 已释放的订单,Redis 中补回展示库存(异步通知)

静默期预热

秒杀开始前 10 分钟,将商品详情、库存快照推入 Redis,活动开始时所有流量只走 Redis,不碰 DB。预热阶段要做的事:

  1. 商品缓存预热:Redis 写入 product:{1001} = { "name":"iPhone16", "price":1999, "stock":1000 }
  2. 库存预热:Redis 写入 stock:{1001} = 1000
  3. 预热队列建立:前 10 分钟用户可点击"提醒我",减轻瞬时流量
  4. CDN 静态资源预热:秒杀页面的 HTML/CSS/JS 推送到 CDN 边缘节点
java
// 预热脚本
@Scheduled(cron = "0 0 10 * * ?") // 每天 10:00 检查是否有秒杀活动
public void warmUp() {
    List<FlashSaleActivity> activities = flashSaleMapper.getTodayActivities();
    for (FlashSaleActivity activity : activities) {
        // 预热商品缓存
        String productKey = "product:" + activity.getProductId();
        redisTemplate.opsForValue().set(productKey, JSON.toJSONString(activity));
        // 预热库存
        String stockKey = "stock:" + activity.getProductId();
        redisTemplate.opsForValue().set(stockKey, String.valueOf(activity.getTotalStock()));
        // 设置过期时间(活动结束后自动清理)
        redisTemplate.expire(productKey, 24, TimeUnit.HOURS);
        redisTemplate.expire(stockKey, 24, TimeUnit.HOURS);
    }
}

生产踩坑实录

踩坑 1:Redis 主从切换导致库存回弹

某次秒杀活动,Redis 主节点宕机触发 Sentinel 切换。新主节点是从节点升上来的,而 DECR 命令在从节点上默认不执行(从节点只读),导致主从切换期间的 2-3 秒内,库存扣减请求全部失败。切换完成后,新主节点上的库存值还是活动开始时的值,等于之前 3 秒的扣减全部丢失,库存"回弹"了。

解决方案:对账脚本在活动结束后,根据 DB 实际订单数修正 Redis 库存。同时在 Redis 侧做 WAIT 命令确保写操作同步到至少一个从节点:

redis> SET stock:1001 1000
redis> WAIT 1 1000  -- 等待至少 1 个从节点确认,超时 1000ms

踩坑 2:用户重复请求放大

秒杀页面被用户反复刷新,前端虽做了按钮置灰,但部分用户通过 F12 禁用 JS 或直接 CURL 请求接口。解决方案:前端 + 后端双重去重

前端下发一个 UUID 令牌(通过 SSR 注入到页面),每次请求携带该令牌,后端验证令牌是否已被消费。令牌有效期 10 秒,用 Redis INC 的返回值判断是否重复消费。

踩坑 3:库存扣完了但订单还在写

Redis 库存扣到 0 后,MQ 中还有大量未消费的消息。这时消费端扣库存 Redis 返回 0,订单写入失败,但这些消息会进入重试队列,重复尝试 3 次,最终进入死信队列。死信队列积累几万条消息,人工处理耗时 2 小时。

解决方案:消费端先 check Redis 库存,如果为 0 则直接丢弃消息(不重试),不进入死信队列:

java
if (redisTemplate.opsForValue().get("stock:" + productId) <= 0) {
    log.warn("库存已售罄,丢弃消息: {}", message);
    return; // 不抛异常,避免重试
}

面试回答策略

面试官问"设计秒杀系统"时,不要一上来就画架构图,答案要有层次:

  1. 先说约束:QPS 量级(10 万?100 万?)、商品数量(1000 件?1 万件?)、库存精度要求(允许超卖 1%?)
  2. 再说核心挑战:库存扣减并发安全、流量削峰、高可用
  3. 然后说方案:四层漏斗架构,每层具体怎么实现
  4. 最后说踩坑:Redis 主从回弹、消费端限速、死信队列堆积

面试话术示例:"我们某次秒杀活动 1000 台手机,10 万人同时抢。采用四层漏斗架构:Nginx 做 IP 限流,每 IP 每秒 1 次;应用层用 Redis Lua 脚本原子扣减库存,扣减成功后通过 RocketMQ 异步写订单;消费端设置 consumeMessageBatchMaxSize=1,逐个处理,避免 Redis 过载;活动结束后定时对账脚本保证 Redis 与 DB 库存一致。Redis 挂了怎么办?我们有 Redis 主从 + RDB 持久化,同时 DB 侧有对账机制,最坏情况下回滚未支付的订单,保证实际库存不超卖。曾经踩过一个坑:Redis 主从切换时库存回弹,导致最后超卖了 3 件,后来在活动结束后加了对账修正脚本才解决。"

参考:淘宝秒杀系统架构演进、Redis 秒杀 Lua 脚本设计、Sentinel 限流文档、某电商 618 秒杀复盘

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。