如何设计一个秒杀功能?
秒杀的核心矛盾是 极高并发读 + 少量库存写,要在短时间内扛住流量,同时 不超卖、少击垮 DB。面试按 澄清 → 分层削峰 → 库存扣减 → 异步下单 → 兜底 答;缓存与 MQ 见 缓存与消息队列;Redis 原子扣减见 Redis;限流见 限流熔断与降级;幂等见 分布式系统基础。
Step 1:澄清需求
| 问题 | 影响 |
|---|---|
| 库存量级? | 100 件 vs 10 万件,热点 key 策略不同 |
| 峰值 QPS? | 网关、Redis、MQ 容量 |
| 是否允许「排队中」? | 同步下单 vs 异步结果页 |
| 一人一单 / 限购? | 用户维度去重、Redis Set |
| 支付超时释放库存? | 延迟队列、关单回滚 |
| 作弊 / 脚本刷? | 验证码、风控、令牌 |
默认假设: 单 SKU 库存有限;峰值 10 万+ QPS;不能超卖;用户最多成功 1 单;未支付 15 分钟释放库存。
Step 2:粗算与瓶颈
| 项 | 假设 |
|---|---|
| 峰值点击 | 10 万 QPS |
| 库存 | 5000 件 |
| 有效下单尝试 | 绝大多数应被 前置挡在 Redis 外 |
| DB 可承受写 | 通常 数百~数千 TPS,不能直接抗 10 万 |
结论:流量必须层层过滤;只有「抢到资格」的少量请求才落库。
Step 3:总体架构(分层防御)
用户
↓
CDN / 静态页(活动页、按钮静态化,少打动态接口)
↓
API 网关:限流、鉴权、WAF
↓
秒杀服务
├─ 读:商品/活动信息 Redis 缓存
├─ 写:Redis 预扣库存(原子)+ 发 MQ
└─ 失败快速返回「已售罄 / 请重试」
↓
Kafka / RocketMQ(削峰)
↓
订单服务:创建订单、扣 DB 库存(最终一致)
↓
支付 → 超时关单 → 库存回滚
Step 4:核心流程
4.1 活动开始前
| 动作 | 说明 |
|---|---|
| 库存预热 | stock:1001 = 5000 写入 Redis |
| 页面静态化 | 详情、规则走 CDN;倒计时前端本地 |
| 秒杀令牌(可选) | 开场前接口发放 seckill_token,下单必带,挡裸刷 |
4.2 秒杀请求(同步路径要短)
POST /seckill/{activityId}
1. 登录态 + 风控(验证码 / 设备指纹)
2. 网关限流(用户/IP/全局限额)—— [限流熔断与降级](/docs/backend/限流熔断与降级)
3. 校验活动已开始、token 有效
4. 一人一单:Redis SETNX user:{uid}:act:{id}
5. Redis Lua 原子:判断 stock>0 → DECR → 返回成功/售罄
6. 成功则发 MQ {userId, skuId, requestId}
7. 立即返回「排队中 / 处理中」(202 或业务码),勿同步等 DB
4.3 Redis 原子扣库存(防超卖第一道)
用 Lua 保证「判断 + 扣减」原子(单 key 单线程也可,但 Lua 更清晰):
-- KEYS[1]=stock_key ARGV[1]=qty(通常1)
local s = tonumber(redis.call('GET', KEYS[1]) or '0')
if s <= 0 then return 0 end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
| 点 | 说明 |
|---|---|
| 热 key | 单 SKU 全站一个 key;可 分段库存 stock:1001:0~:9 随机扣,减热点 |
| 预扣 vs 真扣 | Redis 是预扣;DB 是权威库存,异步对齐 |
| 失败快速 | 99% 请求在 Redis 层返回售罄,保护下游 |
4.4 异步创建订单(MQ 削峰)
Consumer 拉消息
→ 幂等:requestId / 唯一索引 (user_id, activity_id)
→ DB 事务:INSERT order + UPDATE stock WHERE stock>0
→ 成功:通知用户支付;失败:Redis 回补 + 删一人一单标记(或进补偿)
| 手段 | 作用 |
|---|---|
| 幂等键 | 重复消费不重复下单 —— 分布式系统基础 |
| 乐观锁 | UPDATE ... SET stock=stock-1, version=version+1 WHERE id=? AND stock>0 AND version=? |
| 唯一约束 | (user_id, activity_id) 防一人多单 |
DB 扣减失败(Redis 已扣)→ 对账补偿:回补 Redis、告警、人工介入。
Step 5:防超卖(多层兜底)
| 层 | 机制 |
|---|---|
| 1. Redis 预扣 | 原子 DECR,库存为 0 即拒 |
| 2. 一人一单 | Redis + DB 唯一索引 |
| 3. DB 条件更新 | stock > 0 才 UPDATE |
| 4. 对账任务 | 定时比对 Redis、DB、订单数 |
不要只靠应用层 if (stock > 0) stock-- 无锁更新——并发必超卖。
Step 6:库存回滚(未支付)
订单创建成功 → 待支付(15min)
→ 超时关单(延迟 MQ / 定时扫描)
→ DB stock+1 + Redis INCR 回补(注意幂等)
→ 释放一人一单标记(若允许再次抢)
回补也要 幂等,避免重复加库存。
Step 7:读路径优化
| 手段 | 说明 |
|---|---|
| 活动页 CDN | 静态资源、HTML 边缘缓存 |
| 库存展示 | 开场前可读 Redis;显示「剩余」可模糊化,防脚本盯库存 |
| 按钮灰化 | 本地时间 + 服务端时间校准;未开始不调秒杀 API |
Step 8:安全与公平
| 风险 | 手段 |
|---|---|
| 脚本刷 | 限流、验证码、秒杀令牌、行为风控 |
| 黄牛 | 实名、收货地址、设备限制 |
| 缓存穿透 | 活动不存在也走快速失败 |
| 恶意 MQ 堆积 | 消费限速、死信、监控 lag |
扩展追问
| 追问 | 方向 |
|---|---|
| 多 SKU 同时秒 | 每 SKU 独立 stock key;网关按活动分桶限流 |
| 分库分表订单 | 按 user_id 分片;全局活动配置放 Redis |
| 跨机房 | Redis Cluster / 单元化;库存分片绑单元 |
| 和购物车区别 | 秒杀要 极短同步路径 + 强库存语义 |
常见坑
| 坑 | 说明 |
|---|---|
| 全链路同步写 DB | DB 瞬间被打挂 |
| 只 Redis 不 DB 对账 | 不一致、无法财务对账 |
| 无一人一单 | 库存被单人扫光 |
| 热 key 单点 | Redis 单 key QPS 顶满 |
| 关单不回补 | 库存泄漏,可售变少 |
面试参考答法(90 秒)
秒杀我会先确认库存和峰值 QPS。核心是层层削峰:CDN 静态页、网关限流,绝大多数请求在 Redis 层用 Lua 原子扣减库存,扣成功才发 MQ,同步立刻返回处理中,不要等 DB。
订单服务异步消费:幂等键防重复,DB 用条件更新和唯一索引防超卖、一人一单。Redis 是预扣,DB 是权威,失败要回补 Redis。未支付订单超时关单,延迟消息把库存加回。
单 SKU 热 key 可分段库存;安全上限流、验证码和秒杀令牌。全程监控 MQ 堆积和 Redis/DB 库存对账。
延伸阅读
| 篇 | 关系 |
|---|---|
| Redis | 原子扣减、热 key |
| 缓存与消息队列 | 削峰、异步 |
| Kafka | 订单异步 |
| 限流熔断与降级 | 入口保护 |
| 分布式系统基础 | 幂等、一致性 |
面试速记
- 矛盾: 高并发 vs 少库存;DB 不能扛全量写。
- 路径: 限流 → Redis 原子预扣 → MQ 异步下单 → DB 条件更新。
- 防超卖: Redis Lua + 一人一单 + DB
stock>0+ 对账。 - 体验: 同步快速返回;库存回滚靠超时关单 + 幂等回补。
- 热点: 分段 stock key、静态页 CDN、令牌防刷。