设计一个秒杀系统
一句话回答
秒杀的流量特点是瞬时高峰、读多写少、绝大部分请求注定失败(几十万人抢一千件),所以核心思路是层层削峰、尽早拦截:前端静态化、按钮置灰、验证码把请求挡在源头并打散;网关按用户和 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:
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 脚本:
-- 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
下单服务消费消息时的事务:
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 轮
这道题你掌握了吗?
选一个最接近的状态,没掌握的题会出现在"我的进度 · 待复习"里。
学习记录暂存在本机浏览器。登录后自动同步到账号,换设备也能看到。