跳转至

经典案例:电商秒杀与高并发库存系统 (Flash Sale System)

电商秒杀(如淘宝双十一、小米手机首发抢购、12306 春运抢票)是系统设计面试中高并发、高可用与高一致性结合的最高频考察场景之一。

其核心挑战在于:在极短的数秒内,数百万甚至上千万人同时涌入,争抢极少量的商品库存(例如 100 万人抢 100 件商品)。如果系统设计不当,会瞬间引发“数据库连接池打满崩溃”、“库存超卖(卖出负数)”或“少买(商品没卖完却显示售罄)”等严重事故。


1. 初学者视角:为什么普通电商架构在秒杀时必死无疑?

在平时的电商系统中,用户下单扣库存的逻辑通常非常直观:

graph LR
    A[用户点击下单] --> B[应用服务集群]
    B --> C[(MySQL 数据库)]
    C -->|执行事务| D["UPDATE goods SET stock = stock - 1 WHERE id = 1001;"]

如果平时每秒只有几十单,这个逻辑完全没有问题。但在秒杀场景下:

  1. 行级锁严重争用与锁等待堆积:
  2. 关系型数据库(如 MySQL InnoDB)在执行 UPDATE 时会对该行记录加上排他锁(X 锁,行锁);
  3. 10 万个并发请求同时争抢同一行数据的行锁,99,999 个线程全部陷入阻塞排队等待;
  4. 数据库连接池(通常仅几百个连接)在 0.1 秒内被全部占满,导致整个数据库乃至整个电商网站的其他正常业务(浏览、支付、登录)全部瘫痪!
  5. 超卖漏洞(Overselling):
  6. 如果并发读取库存时发现 stock = 1,多个线程同时判断“库存充足”,随后各自执行扣减,库存就会变成 -5,导致实际卖出的商品远超库存总量。

2. 需求分析与系统范围 (Requirements & Scope)

2.1 功能性需求

  1. 秒杀详情页展示:展示商品信息、倒计时,秒杀未开始时按钮置灰;
  2. 秒杀资格校验:限制单个用户只能抢购 1 件,防范黄牛与机器脚本;
  3. 高并发库存扣减:瞬间承受海量请求,严格保证绝对零超卖;
  4. 异步下单与支付流转:扣减成功后生成订单,用户进入支付环节;
  5. 超时库存自动回补:若用户在 15 分钟内未完成支付,系统自动释放预占库存,允许其他人继续抢购。

2.2 非功能性需求

  1. 极高写入吞吐 (High Write Throughput):支撑 \(100,000+\text{ QPS}\) 瞬时写入峰值;
  2. 绝对强一致性 (Strong Consistency for Stock):库存不能超卖,也不能少卖;
  3. 服务高可用与隔离性 (High Availability & Isolation):秒杀活动的流量浪涌绝对不能影响主站正常电商交易。

3. 核心架构设计思想:分层漏斗与流量削峰 (Layered Funnel)

面对每秒 100 万次请求,真正最终能够成功扣减库存的只有 100 个人。因此,系统设计的核心哲学是:分层拦截,层层过滤,逐级将 99.99% 的无效流量在到达数据库之前彻底挡在外面。

graph TD
    User["1,000,000 用户点击抢购"] --> CDN["第 1 层: CDN 与客户端缓存 (拦截 80% 读流量)"]
    CDN --> Gateway["第 2 层: API 网关 (限流 + 验证码 + 黑名单, 拦截 90% 恶意流量)"]
    Gateway --> Redis["第 3 层: Redis + Lua 脚本 (原子扣减内存库存, 只放行 100 个成功者)"]
    Redis --> MQ["第 4 层: 消息队列 Kafka/RocketMQ (削峰填谷, 异步排队)"]
    MQ --> OrderService["第 5 层: 订单服务 (平稳消费 MQ, 仅 100 QPS)"]
    OrderService --> MySQL[("第 6 层: MySQL 数据库 (落库生成正式订单)")]

    style Redis fill:#ff9999,stroke:#333,stroke-width:2px
    style MQ fill:#99ccff,stroke:#333,stroke-width:2px

3.1 各层拦截手段与落地实现

第 1 层:动静分离与 CDN 缓存

  • 秒杀商品详情页的 HTML、CSS、JS、商品主图等全部推送到 CDN 边缘节点;
  • 倒计时时间由服务端统一下发时间戳基准(避免用户修改本地电脑时间提前抢购);
  • 详情页静态化,秒杀未开始时,客户端不发送任何服务端请求。

第 2 层:API 网关安全防御与动态化 URL

  • 动态秒杀 URL(防提前偷跑):秒杀开始前,秒杀接口的真实地址不公开。只有在开始前 1 秒,客户端才通过一次极轻量的接口获取随机生成的接口哈希串(如 /api/seckill/a8f7c9b2);
  • 行为验证码 / 答题:将并发请求在时间轴上均匀打散(不同用户答题完成时间不同,原本 0.1 秒的脉冲流量被拉平为 3~5 秒);
  • 网关防刷限流 (Rate Limiting):针对单个 user_id 和 IP 限制每秒最多发起 1 次请求,直接拦截脚本机。

第 3 层:Redis + Lua 脚本原子预扣减(核心防线)

  • 数据库永远不直接面对 10 万 QPS 的并发写!
  • 秒杀开始前,通过后台管理系统将商品库存预热到 Redis 缓存中:
    SET seckill:stock:1001 100
    
  • 当请求到达时,使用 Lua 脚本 在 Redis 中一次性完成“判断库存是否足够 + 扣减库存”的原子操作。

4. 核心实现:Redis Lua 脚本原子扣库存

4.1 为什么必须用 Lua 脚本?

在 Redis 中,如果先执行 GET stock 判断是否大于 0,再执行 DECR stock,由于这两步操作非原子性,高并发下依然会发生超卖。 Redis 保证单个 Lua 脚本以原子方式执行,执行期间不会插入任何其他客户端命令。

-- Lua 脚本:seckill_deduct.lua
-- KEYS[1]: 存储商品库存的 Key (如 seckill:stock:1001)
-- KEYS[2]: 记录已抢购成功的用户 Set (如 seckill:users:1001)
-- ARGV[1]: 当前用户 ID

local stock_key = KEYS[1]
local users_key = KEYS[2]
local user_id = ARGV[1]

-- 1. 检查该用户是否已经抢购过 (单人限购一件)
if redis.call("SISMEMBER", users_key, user_id) == 1 then
    return -1 -- 重复下单错误码
end

-- 2. 获取当前剩余库存
local current_stock = tonumber(redis.call("GET", stock_key))
if current_stock == nil or current_stock <= 0 then
    return 0 -- 库存售罄
end

-- 3. 原子扣减库存并记录用户
redis.call("DECR", stock_key)
redis.call("SADD", users_key, user_id)

return 1 -- 秒杀成功!

5. 核心难点深度剖析 (Deep Dive)

5.1 库存扣减时机对比(下单扣减 vs 支付扣减)

在设计库存系统时,面试官必问:“你在哪个环节扣减库存?”

graph TD
    A[下单扣减] --> A1[优点: 绝对不超卖, 用户体验好, 抢到了就确保有货<br>缺点: 恶意刷单 '占坑不买', 导致正常用户买不到]
    B[付款扣减] --> B1[优点: 杜绝恶意占坑<br>缺点: 严重超卖! 100个人抢到了, 支付时发现没货了, 体验极差]
    C["预占库存 + 超时释放 (推荐)"] --> C1["下单时原子锁定库存 (预占), 启动 15 分钟支付倒计时<br>支付成功则真正核销; 超时未付款则系统自动释放回滚库存"]

工业界标准做法:采用 预占库存 (Reserve Stock) + 超时自动释放 模式。


5.2 数据库最终兜底:乐观锁与防超卖 SQL

虽然 Redis 挡住了 99.99% 的请求,只有 100 个成功请求会投递到 MQ,再由订单服务写入 MySQL。但为了防止分布式系统的极端异常(如运维失误),MySQL 本身也必须具备绝对防超卖的能力:

错误写法(严禁使用):

-- 错误:先查后改,存在严重并发超卖 Bug
SELECT stock FROM goods WHERE id = 1001;
-- 应用程序判断 stock > 0 ...
UPDATE goods SET stock = stock - 1 WHERE id = 1001;

正确写法(单条原子更新):

-- 正确:利用数据库自身的行锁特性与条件约束
UPDATE goods 
SET stock = stock - 1 
WHERE id = 1001 AND stock > 0;
- 只要在 WHERE 条件中带上 stock > 0,MySQL 在加行排他锁更新时,若发现当前 stock 已经被其他事务减到 0,则返回受影响行数为 0(rows affected = 0),应用程序据此直接判定扣减失败,万无一失!


5.3 超时未支付库存如何自动回滚?

若用户抢到了商品,但在设定的 15 分钟内未付款,如何自动将库存还回去?

sequenceDiagram
    participant User as 抢购用户
    participant Svc as 秒杀订单服务
    participant MQ as 延迟消息队列 (RocketMQ)
    participant Redis as Redis 缓存
    participant DB as MySQL 数据库

    User->>Svc: 抢购成功, 创建待支付订单 (ID: 9988)
    Svc->>MQ: 发送 15 分钟延迟消息 (Delay Level)
    Note over Svc,MQ: 消息在队列中静默 15 分钟

    MQ-->>Svc: 15 分钟到期, 触发消费消息
    Svc->>DB: 查询订单 9988 的支付状态
    alt 订单已支付
        Note over Svc: 流程结束, 正常出库
    else 订单仍为"待支付"
        Svc->>DB: 关闭订单 (状态置为 CANCELLED)
        Svc->>DB: UPDATE goods SET stock = stock + 1 WHERE id = 1001;
        Svc->>Redis: Redis 执行 INCR seckill:stock:1001 (回滚缓存库存)
        Svc->>Redis: Redis 移除已抢购用户记录 SREM
    end

延迟队列选型:

  1. RocketMQ 延时消息 (首选):原生支持级别延时(如 1s, 5s, 1m, 15m),性能极高且具备持久化与重试机制;
  2. Redis Sorted Set (ZSET) 轮询:以 当前时间戳 + 15分钟 作为 score,后台定时任务 ZRANGEBYSCORE 0 now 扫描过期订单;
  3. RabbitMQ TTL + 死信队列 (DLX):为队列设置消息存活时间,过期后自动投递至死信交换机进行消费。