设计一个秒杀系统

深入系统设计场景题高频约 10 分钟读完

一句话回答

秒杀的流量特点是瞬时高峰、读多写少、绝大部分请求注定失败(几十万人抢一千件),所以核心思路是层层削峰、尽早拦截:前端静态化、按钮置灰、验证码把请求挡在源头并打散;网关按用户和 IP 限流;秒杀服务用 Redis + Lua 原子地预扣库存并校验限购,扣成功的请求才进入消息队列异步下单,数据库按自己的能力匀速消费。数据库用条件更新 stock > 0 防超卖、唯一索引防重复下单,作为最后一道防线;超时未支付的订单回补库存;秒杀独立部署、隔离热点,活动结束后对账。

详细解析

第一步:澄清需求

  • 场景:限量商品(假设 1000 件)定时开抢,每人限购 1 件,抢到后 15 分钟内(假设)支付,否则释放库存
  • 核心约束:不能超卖;同一用户不能重复下单;不能拖垮主站的其他业务
  • 体验:可以先返回"排队中",结果稍后告知;抢不到要尽快返回
  • 量级估算(假设值):
    • 100 万人参与,开始后 5 秒内集中点击,每人平均点 2 次:200 万 ÷ 5 = 40 万 QPS
    • 只有 1000 个请求能成功:1000 ÷ 200 万 = 0.05%,超过 99.9% 的请求最终都是"已抢完"
    • 数据库只需要处理约 1000 次下单,绝不能让 40 万 QPS 直接到达数据库

第二步:整体架构

文本
用户 ──> CDN:静态页面、倒计时以服务端时间为准(入口约 40 万 QPS,假设值)
  │  到点按钮才亮;先答题或输入验证码,拿到一次性的秒杀令牌,请求被打散到更长的时间里
  ▼
网关:按用户和 IP 限流、黑名单、校验令牌(剩下数万 QPS)
  ▼
秒杀服务(独立部署)
  1 本地售罄标记:已售罄直接返回,不再访问 Redis
  2 Redis Lua:限购检查 + 预扣库存,一次原子完成
  3 扣减成功 → 发"创建订单"消息 → 返回"排队中"(总共只有约 1000 条)
  ▼
消息队列 ──> 下单服务(按数据库的能力匀速消费)
  4 事务:插入订单(唯一索引防重复)+ 条件更新库存(防超卖)
  5 发一条 15 分钟后的延时消息,检查是否支付
  ▼
前端轮询下单结果:成功 → 去支付;失败 → 提示已抢完

消息队列在这里起削峰作用:入口的流量再大,下单服务也只按固定速度消费,见为什么要用消息队列。

第三步:核心模块和数据模型

Redis(活动开始前预热写入):

  • sk:{1001}:stock:String,剩余库存
  • sk:{1001}:users:Set,已经抢到的用户
  • 两个 key 用同一个 hash tag {1001},Redis Cluster 下才会落在同一个槽,才能在一个 Lua 脚本里同时操作

MySQL:

SQL
CREATE TABLE seckill_stock (
  activity_id BIGINT PRIMARY KEY,
  stock       INT NOT NULL
);
CREATE TABLE seckill_order (
  order_no    BIGINT PRIMARY KEY,
  activity_id BIGINT NOT NULL,
  user_id     BIGINT NOT NULL,
  status      TINYINT NOT NULL,         -- 0 待支付,1 已支付,2 已取消
  created_at  DATETIME NOT NULL,
  UNIQUE KEY uk_activity_user (activity_id, user_id)  -- 一人一单,取消后也不能再抢
);

库存扣在哪里:

方案 做法 优点 缺点 适用场景
数据库直接扣 UPDATE ... SET stock = stock - 1 WHERE stock > 0 简单,强一致 所有请求争同一行的行锁 普通抢购,并发不高
Redis 预扣 + 异步下单 Lua 原子扣减,成功后发消息,消费时再扣数据库 扛得住高并发,数据库只处理成功的请求 Redis 和数据库会短暂不一致,要对账 秒杀(本文采用)
库存分桶 库存拆成多行或多个 key,各扣各的 把一个热点分散开 实现复杂,某个桶扣完要换桶重试 库存大、并发极高

本地售罄标记:某个实例从 Redis 得到"已售罄"后,在进程内存里记下来,之后的请求直接返回。库存卖完后的绝大部分请求都在这里结束,Redis 的压力会骤降。有库存回补时,通过广播清除这个标记。

第四步:关键难点

防超卖:

  • Redis 层:"判断库存"和"扣减"必须是原子的。先 GET 判断再 DECR,两个请求可能同时看到库存为 1,都扣成功。Lua 脚本在 Redis 中原子执行,见 Pipeline、事务和 Lua 脚本
  • 数据库层:UPDATE ... WHERE stock > 0 是最后一道防线,即使 Redis 出了问题(比如主从切换丢了扣减),也不会真的超卖

防重复下单:Lua 脚本里用 Set 先拦一次;数据库的唯一索引兜底。消息队列是至少一次投递,同一条"创建订单"消息可能被消费多次,插入订单时违反唯一索引就当作已经处理过,见重复消费与幂等。

Redis 扣成功了,后面的步骤失败:

  • 发消息失败:重试几次,仍然失败就回滚 Redis(库存加回、用户移出集合),告诉用户失败
  • 消费失败:消息队列重试,多次失败进死信队列,人工处理或回补
  • 原则:宁可少卖,不能超卖。Redis 先扣、数据库后扣,中间的步骤出错只会让 Redis 的库存偏少;少卖的部分靠对账发现后回补

超时未支付,回补库存:到期的延时消息执行 UPDATE seckill_order SET status = 2 WHERE order_no = ? AND status = 0,影响 1 行才回补:数据库库存加 1,Redis 库存加 1。条件更新保证"超时取消"和"支付成功回调"只有一个生效。延时任务的几种实现见延时任务系统,支付回调见订单和支付系统。

热点隔离与降级:

  • 秒杀服务、Redis、数据库都和主站分开部署,秒杀被打满也不影响正常购物
  • 商品详情、活动信息提前预热到本地缓存;库存 key 是一个典型的热 Key,见大 Key 和热 Key
  • 活动期间关闭推荐、评论等非核心功能;Redis 不可用时暂停活动(提示"活动太火爆"),不能退回到直接扣数据库,降级和熔断的区别见熔断、降级、限流

对账:活动结束后核对四个数:Redis 已售数量(初始库存 − 剩余库存)、Redis 里已抢到的用户数、订单表的订单数、数据库库存的减少量。不一致说明有消息丢失、消费失败或回补遗漏,按用户逐条比对 Redis 集合和订单表来修复。

第五步:扩展与优化

  • 结果通知:轮询改成 WebSocket 推送,减少轮询请求
  • 改变玩法:业务允许时用"预约 + 抽签"代替拼手速,从根本上消除瞬时高峰
  • 压测和预案:全链路压测确定每一层的限流阈值,准备好降级开关和扩容方案

代码示例

Redis 预扣库存的 Lua 脚本:

Lua
-- KEYS[1]:库存,如 sk:{1001}:stock;KEYS[2]:已抢到的用户集合,如 sk:{1001}:users
-- ARGV[1]:用户 ID
-- 返回 1 成功,0 已售罄,-1 重复抢购
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
  return -1
end
local stock = tonumber(redis.call('GET', KEYS[1])) -- key 不存在时 GET 返回 false,tonumber 得到 nil
if stock == nil or stock <= 0 then
  return 0 -- 没有预热也按售罄处理,不能放行
end
redis.call('DECR', KEYS[1])
redis.call('SADD', KEYS[2], ARGV[1])
return 1

下单服务消费消息时的事务:

SQL
BEGIN;
-- 重复消息会违反唯一索引 uk_activity_user,捕获后按"已处理"返回
INSERT INTO seckill_order (order_no, activity_id, user_id, status, created_at)
VALUES (?, ?, ?, 0, NOW());
-- 热点行的更新放在事务最后:行锁要到提交时才释放,越晚加锁,持有时间越短;影响行数为 0 说明已售罄,回滚
UPDATE seckill_stock SET stock = stock - 1 WHERE activity_id = ? AND stock > 0;
COMMIT;

面试官可能追问

为什么不直接在数据库里扣库存?

所有请求都更新同一行,InnoDB 的行锁让它们串行执行,大量请求排队等锁,很快占满数据库连接,主站也跟着受影响(见 InnoDB 的锁)。而且绝大部分请求注定失败,没必要让它们到达数据库。流量不大的普通抢购,直接用 stock > 0 的条件更新就够了,不必上整套方案。

Redis 主从切换,丢了一部分扣减记录怎么办?

Redis 主从复制是异步的,切换后新主节点上的库存可能比实际多,已抢用户的集合也可能少了几个人。这时 Redis 会多放行一些请求,但数据库的 stock > 0 和唯一索引会把它们拦下来,不会真的超卖,只是这些用户先看到"排队中"、最后收到失败。活动后的对账也能发现这类不一致。

库存很多(比如 10 万件),数据库的库存行成了瓶颈怎么办?

一是把库存拆成多行(分桶),每行一部分库存,扣减时随机或按用户哈希选一行,某一行扣完了再换一行;二是消费者攒一批订单合并成一次扣减,SET stock = stock - N WHERE stock >= N。Redis 侧的库存 key 也可以按同样的思路拆成多个 key,分到不同节点上。

怎么防止黄牛用脚本抢?

秒杀接口的地址在开始时才下发,并带上一次性令牌,提前写好的脚本拿不到;验证码或答题既挡机器,又把请求在时间上打散;风控按账号等级、设备指纹、同一设备多账号识别异常;网关按用户和 IP 限流,见用 Redis 实现限流。完全杜绝很难,目标是提高刷的成本。

易错点

  • 先 GET 判断库存、再 DECR,两步之间有并发窗口,会超卖
  • 只靠 Redis 防超卖,数据库更新不加 stock > 0 条件
  • 限购只在 Redis 里判断,没有唯一索引,消息重复消费时生成重复订单
  • 秒杀和主站共用 Redis 和数据库,活动一开始主站跟着变慢

AI 模拟面试官

用自己的话回答,AI 对照参考答案打分、指出遗漏,再追问,最多 3 轮

登录后就可以和 AI 面试官对练,面试记录也会保存下来。登录

这道题你掌握了吗?

选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。

学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。