一句话回答
秒杀系统的核心是把远大于库存的瞬时流量挡在核心链路之外,通过资格校验、限流和缓存快速失败,用 Redis 或专用库存服务原子预扣库存,再通过消息队列异步创建订单,并依靠唯一约束、幂等、超时释放和对账保证不超卖、不少卖与最终一致。
面试考察点
- 能否先定义流量、库存、延迟和一致性目标。
- 是否理解秒杀优化的重点是让失败请求尽早结束。
- 能否区分 Redis 预扣库存与数据库最终库存。
- 是否知道消息、订单和库存之间存在部分失败。
- 能否处理重复请求、恶意流量、超时未支付和数据对账。
- 是否具备容量评估、降级、容灾和压测思维。
第一步:明确需求和容量
设计之前先确认:
- 商品数量和每人限购数量。
- 活动时间、持续时长和参与用户数。
- 峰值请求 QPS 与预期成功订单数。
- 是否允许排队,以及用户能接受的响应时间。
- 库存是否必须绝对不超卖。
- 下单后支付时限和未支付库存释放规则。
- 是否支持多个机房、多个地区和多种商品。
- 活动失败时的降级和补偿策略。
例如库存只有 1 万件、峰值请求 100 万 QPS,真正需要进入下单链路的请求不到 1%。架构目标不是让数据库处理 100 万 QPS,而是快速、准确地淘汰绝大多数失败请求。
总体架构
flowchart TD
U["用户请求"] --> CDN["CDN 与边缘防护"]
CDN --> G["网关鉴权与限流"]
G --> S["秒杀接入服务"]
S --> R{"Redis 原子预扣"}
R -->|"库存不足"| F["快速失败"]
R -->|"预扣成功"| MQ["消息队列削峰"]
MQ --> O["订单服务"]
O --> DB[("MySQL 订单与库存")]
DB --> P["支付 / 超时关单"]
P --> C["库存补偿与对账"]
用户端
↓ 静态资源/CDN
边缘防护:验证码、风控、IP 限流
↓
API Gateway:鉴权、用户限流、活动校验
↓
秒杀接入服务:本地售罄标记、资格校验
↓
Redis / 库存服务:原子预扣、去重
↓
Kafka / RocketMQ:削峰、异步下单
↓
订单服务 + MySQL:最终订单、唯一约束
↓
支付与超时关单
↓
库存释放、对账和补偿
前端和 CDN 层
秒杀页面、图片和脚本全部静态化并通过 CDN 分发,避免活动开始时流量进入应用服务器。
前端可以:
- 活动开始前显示倒计时,但服务端时间才是最终判断依据。
- 按钮点击后立即置灰,减少用户连续点击。
- 请求携带一次性秒杀令牌。
- 对轮询订单结果设置退避,避免固定高频查询。
前端限制只能改善体验,不能作为安全边界,所有校验必须在服务端重复执行。
接入层流量治理
1. 身份和资格校验
校验登录状态、活动范围、用户等级、地区、黑名单和购买资格。资格可以在活动前预计算并缓存,避免高峰期查询复杂数据库条件。
2. 多维度限流
按 IP、设备、账号、活动和接口共同限流。只按 IP 会误伤公司或校园网络,只按账号无法抵御批量账号。
3. 验证码和动态路径
活动开始前通过验证码增加自动化成本,验证成功后发放短时令牌或动态请求路径。它不能完全阻止脚本,但能减少无效流量直接冲击库存接口。
4. 快速失败
活动未开始、已结束、用户无资格、重复请求和本地已售罄都应在接入层直接返回,不进入库存服务和数据库。
本地售罄标记
秒杀服务可以缓存商品是否售罄。一旦库存服务返回库存为 0,就把该商品标记为售罄,后续请求在进程内快速失败。
本地标记只能用于减少流量,不能作为库存真值。若发生库存回补,需要通过广播事件或短 TTL 清除标记。
库存为什么不能直接打数据库
数据库行锁能通过条件更新防超卖:
UPDATE sku_stock
SET available = available - 1
WHERE sku_id = ? AND available > 0;
这在普通流量下正确,但秒杀瞬时并发会让大量请求竞争同一行,形成锁等待、连接池耗尽和数据库抖动。应先在高吞吐的库存层筛选出有限成功请求,再异步进入数据库。
Redis 原子预扣库存
使用 Lua 脚本把重复校验和库存扣减放在一个原子操作中:
local userKey = KEYS[1]
local stockKey = KEYS[2]
if redis.call('EXISTS', userKey) == 1 then
return -2
end
local stock = tonumber(redis.call('GET', stockKey) or '-1')
if stock <= 0 then
return -1
end
redis.call('DECR', stockKey)
redis.call('SET', userKey, '1', 'EX', ARGV[1])
return stock - 1
脚本返回:
-2:用户已请求成功或正在处理中。-1:库存不足。- 非负数:预扣成功后的剩余库存。
脚本必须控制执行时间,不能在 Redis 中做复杂遍历。热点商品的单 Key 仍可能成为 Redis 热点,需要容量验证和专用集群隔离。
Redis 库存从哪里来
活动开始前通过发布流程把数据库库存装载到 Redis,并记录活动版本。装载必须可重复执行且有校验:
数据库活动库存
↓ 审批和冻结
生成库存版本
↓
写 Redis 库存与活动元数据
↓
校验数量、过期时间和分片
↓
活动进入 READY 状态
不能由应用实例启动时随意覆盖库存,否则重启可能把已经扣减的库存重置。
异步下单和消息队列
预扣成功后发送下单消息,快速返回“排队中”。消息中至少包含:
- 全局请求 ID。
- 活动 ID、商品 ID 和库存版本。
- 用户 ID 和购买数量。
- 预扣时间。
- 价格快照或价格版本。
消息队列削平瞬时流量,让订单服务按数据库承载能力消费。队列不是无限缓冲,必须评估积压量、保留时间和消费恢复能力。
Redis 扣减成功但消息发送失败怎么办
这是关键一致性窗口。可选方案:
方案一:Redis Stream / 库存与事件同存储原子写
Lua 脚本在扣库存时同时写入 Redis Stream 或待发送集合,由可靠任务投递 MQ。减少扣减与事件之间的窗口,但增加 Redis 数据治理复杂度。
方案二:请求记录状态机
扣减前创建请求记录,记录 INIT → RESERVED → SENT → ORDERED,后台扫描长期停留状态并补发或释放库存。
方案三:专用库存服务日志
库存服务把扣减事件写入可恢复日志,再异步发布。复杂度更高,适合超大规模核心活动。
无论采用哪种方案,都需要定期对账,不能假设每一步永远成功。
订单服务如何防重复
同一请求可能因 MQ 重试被消费多次。订单表建立业务唯一约束:
UNIQUE KEY uk_activity_user_sku
(activity_id, user_id, sku_id)
或者以请求 ID 建立唯一索引。消费时在数据库事务中创建订单和库存流水;唯一键冲突后查询已有订单并返回成功,不再次扣减。
如果允许同用户购买多件或多个订单,唯一键需要包含购买批次或资格 ID,不能机械复制。
数据库最终扣减怎么做
Redis 预扣限制进入订单链路的数量,数据库仍应使用条件更新或唯一库存流水作为最终防线:
UPDATE activity_stock
SET sold = sold + 1, version = version + 1
WHERE activity_id = ?
AND sold < total
AND version = ?;
若数据库扣减失败,订单事务回滚,并触发 Redis 库存补偿。补偿操作必须携带请求 ID 并幂等,防止重复加库存。
不超卖和不少卖
- 不超卖:成功订单数不能超过活动库存,是强约束。
- 不少卖:系统异常释放了本应成功的库存,或扣减后没有形成订单,属于可用性和最终一致性问题。
核心策略通常是优先保证不超卖,再通过补偿、对账和库存回补减少少卖。
未支付订单怎么释放库存
订单创建后进入待支付状态,并写入延迟消息或时间轮。到期后执行条件关单:
UPDATE orders
SET status = 'CLOSED'
WHERE id = ? AND status = 'PENDING_PAYMENT';
只有成功从待支付转为关闭的事务才能释放库存。支付回调与关单可能并发,需要状态机和数据库条件更新决定唯一结果。
库存释放后是否重新开放秒杀取决于业务:活动仍进行时可回补 Redis 并清除售罄标记;活动临近结束时也可以不再回补,避免复杂波动。
用户如何查询结果
秒杀接口返回请求 ID 和排队状态,用户通过结果查询接口获取:
- 排队中。
- 抢购成功,返回订单号。
- 库存不足。
- 资格不符。
- 系统异常,可重试或退款。
查询结果可放 Redis,前端轮询必须指数退避并设置最长时间。更复杂场景可通过 WebSocket 或推送通知,但仍要保留查询兜底。
热点 Key 和 Redis 容灾
单个爆款库存 Key 无法简单通过普通分片提高单 Key 写并发。可选策略:
- 使用独立 Redis 集群隔离秒杀流量。
- 把库存拆成多个桶,请求按用户哈希选择桶。
- 桶售罄后尝试其他桶,但要控制重试次数。
- 使用本地资格券进一步减少访问 Redis 的请求。
库存分桶提高吞吐,但会增加余量碎片、跨桶回补和对账复杂度。只有单 Key 实测成为瓶颈时才引入。
数据库和 MQ 容量保护
- 订单消费者使用有界线程池。
- 数据库连接池设置合理上限,避免连接风暴。
- MQ 消费速率与数据库写能力匹配。
- 下游支付、营销和通知通过独立事件异步处理。
- 非核心字段延后填充,缩短订单事务。
- 监控 MQ Lag、创建订单速率和数据库锁等待。
防刷和安全
- 用户、设备、IP 和行为风控。
- 秒杀令牌绑定用户、活动和短过期时间。
- 请求参数签名与防重放 Nonce。
- 限制接口只能在活动窗口访问。
- 服务端重新计算价格,不能相信客户端金额。
- 审计异常成功率、相同地址和设备聚集。
降级策略
当依赖异常或容量达到阈值:
- 关闭次要活动和推荐内容。
- 所有新请求返回排队或稍后再试。
- 暂停库存回补,优先保证不超卖。
- 只保留订单创建核心链路,通知异步延后。
- Redis 或 MQ 不可用时快速失败,不直接把全量流量切到数据库。
降级开关必须预先实现并演练,事故中临时开发来不及。
对账和补偿
至少对比:
初始库存
- Redis 成功预扣
- 数据库成功订单
- 已关闭订单
+ 已回补库存
= 理论剩余库存
按请求 ID 核对预扣记录、MQ 事件、订单、支付和库存流水。差异进入补偿队列或人工审核,所有补偿动作幂等并留审计日志。
压测方案
压测不能只测平均 QPS,应覆盖:
- 活动开始瞬间的阶跃流量。
- 库存远小于请求量。
- 热点用户和单商品。
- Redis、MQ、数据库延迟上升。
- 消费者重启和消息重复。
- 支付与关单并发。
- 单机房故障和实例扩缩容。
关注 P99、错误率、Redis 延迟、MQ Lag、数据库连接和锁等待,以及最终库存与订单对账结果。
常见误区
- 使用前端按钮防止重复下单。
- 所有请求直接进入数据库条件更新。
- Redis 扣减成功就认为订单一定成功。
- MQ 可以无限堆积,不需要容量上限。
- 只保证不超卖,不做少卖和丢消息对账。
- 分布式锁包住整个秒杀流程。
- Redis 故障时自动降级为直接扣数据库。
核心考点清单
- 秒杀核心是失败请求尽早结束,保护有限的核心资源。
- 接入层限流、资格校验和本地售罄标记减少进入库存层的请求。
- Redis Lua 原子完成用户去重和库存预扣。
- MQ 削峰并异步下单,订单服务通过唯一约束幂等。
- 数据库条件更新是最终不超卖防线。
- 预扣、消息、订单和支付之间需要状态机、补偿与对账。
- 容灾时不能把秒杀全量流量直接打到数据库。
高频追问与参考回答
追问 1:Redis 扣库存怎么保证不超卖?
使用 Lua 在单次原子执行中检查库存和扣减,避免并发读改写;数据库创建订单时仍使用条件更新或库存流水作为最终防线。
追问 2:Redis 扣成功但下单失败怎么办?
请求状态记录会停留在预扣状态,后台补偿任务根据超时和订单结果幂等释放库存;同时对账发现无订单预扣并修复。
追问 3:为什么不用分布式锁?
单商品全局锁会把请求串行化,锁获取和续期也增加开销。Lua 原子操作更适合短小库存判断;跨系统流程则依靠状态机和最终一致,不应持有长锁。
追问 4:如何解决 Redis 热点 Key?
先通过接入层过滤和本地售罄标记减少请求;单 Key 确实达到瓶颈时,可隔离集群或库存分桶,但要处理余量碎片和回补复杂度。
追问 5:MQ 消息重复会不会重复下单?
订单表以请求 ID 或活动、用户、商品建立唯一约束,消费者重复执行时查询已有订单并返回成功,不重复扣减库存。
追问 6:用户支付和超时关单并发怎么办?
两者都使用订单状态条件更新,只有一个能从待支付状态迁移成功。关单成功才释放库存,支付成功则不能再关单。
追问 7:怎么证明没有超卖和少卖?
通过预扣流水、MQ 事件、订单、支付和库存流水按请求 ID 对账,验证成功订单不超过初始库存,并对无订单预扣、重复释放等差异执行幂等补偿。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| Redis Lua/Stream | 预扣与待投递证据 |
| 订单 UNIQUE(request_id) | 最终不超卖裁决 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
peak=1_000_000QPS; stock=10_000; db_write=3_000/s; queue>=success_peak
阶跃百万请求并注入 Redis、MQ、消费者和关单故障,按 request_id 对账。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「各层淘汰率」为主基线,记录值应满足「记录活动/稳态基线」;同时保存 边缘到库存各层淘汰率、Redis Lua P99,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「Redis Lua/Stream」确认请求确实进入「预扣与待投递证据」对应的实现,再沿「订单 UNIQUE(request_id)」观察「最终不超卖裁决」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「库存分桶后余量碎片导致少卖」,并把单一变量逐级放大,直到「各层淘汰率」越过「超过容量或 SLO」。随后再分别验证「Redis 故障时全量降级到数据库」和「补偿重复执行把库存加多」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「失败请求尽早结束」,确认它能控制影响范围;第二轮应用「预扣+事件可恢复记录」,验证核心链路恢复;最后落实「数据库唯一/条件更新收口」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「各层淘汰率」回到「记录活动/稳态基线」、「端到端 P99」回到「小于预算」、「状态差异」回到「0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 各层淘汰率 | 记录活动/稳态基线 | 超过容量或 SLO | 触发降级 |
| 端到端 P99 | 小于预算 | 突破预算 | 停止扩量 |
| 状态差异 | 0 | 任意非零 | 补偿并对账 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:库存预扣成功但消息发布失败
高峰时 MQ 客户端超时,接口已扣 Redis 库存却没有订单事件。Lua 同时写待投递 Stream/状态记录,后台扫描补发;超过恢复窗口仍无订单的请求才幂等回补库存。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 库存分桶后余量碎片导致少卖 | 边缘到库存各层淘汰率 | 失败请求尽早结束 |
| Redis 故障时全量降级到数据库 | Redis Lua P99 | 预扣+事件可恢复记录 |
| 补偿重复执行把库存加多 | MQ Lag/净消费速率 | 数据库唯一/条件更新收口 |
发布与回滚检查点
- 发布前:确认「Redis Lua/Stream」对应实现和上述配置在目标版本仍然有效,并保存「各层淘汰率」基线。
- 灰度中:同时观察 边缘到库存各层淘汰率、Redis Lua P99、MQ Lag/净消费速率;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「失败请求尽早结束」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「库存分桶后余量碎片导致少卖」没有再次出现,才关闭变更观察窗口。
设计边界与工程取舍
秒杀首先保证不超卖和核心链路存活,再通过补偿减少少卖;任何降级都不能把被挡住的洪峰直接转移到数据库。