一句话回答

秒杀系统的核心是把远大于库存的瞬时流量挡在核心链路之外,通过资格校验、限流和缓存快速失败,用 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/净消费速率;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「失败请求尽早结束」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「库存分桶后余量碎片导致少卖」没有再次出现,才关闭变更观察窗口。

设计边界与工程取舍

秒杀首先保证不超卖和核心链路存活,再通过补偿减少少卖;任何降级都不能把被挡住的洪峰直接转移到数据库。