面试官常怎么问
- “秒杀系统怎么设计?怎么保证不超卖?”
- “Redis 扣库存和数据库扣库存,谁说了算?两边对不上怎么查?”
- “为什么不用一把分布式锁把商品锁住?”
原理
一句话总括:秒杀要解决的不是“并发怎么写锁”,而是“瞬时流量远大于系统容量时,怎么让请求逐层递减,并让库存扣减这一步具备原子性、让失败的那一步具备补偿能力”。
难点有三个来源。第一是流量形态:下单请求集中在开抢后的极短窗口,峰值 QPS 通常是稳态的几十上百倍,任何一层按峰值容量建设都不经济。第二是库存的守恒性:库存是有限且需要账实相符的资源,超卖是资损,少卖是客诉,两者都得在方案里正面回答。第三是失败窗口:扣减、落库、发消息分布在不同组件上,任意一步失败都会留下钱货不一致的中间态。
由此推出设计主线。把请求做成漏斗,客户端、网关、应用、Redis、消息队列、数据库各自只承担自己能处理的数量,越往后量越小;把“判断库存是否充足”和“扣减库存”压成一次原子操作,从根上消除 check-then-act 的竞态;把裁决层放在 Redis、把账本放在数据库,两者之间用异步链路连接,并配幂等、回补与对账。
为什么这样设计而不是全程强一致:一条链路上同时要满足高并发、强一致和低延迟,通常做不到,只能选择在哪一环放宽。库存的强一致放在数据库上的代价是行锁排队与写入热点,于是把并发压力前移到 Redis 的原子操作上,用最终一致加对账来收敛账本。
前提与边界:Redis 的脚本执行语义、集群模式下脚本对 key 的要求、消息队列是否支持事务消息、MySQL 的行锁与唯一键行为,都随版本与部署形态变化,落地前需对照目标版本文档核实。
细节
- 过滤顺序与量级递减。客户端按钮置灰只是体验,不构成防线;真正的过滤从接入层开始,按用户、设备、IP 维度限流,把单用户高频请求挡在应用之外。应用层可以再用本地计数(进程内 AtomicInteger 或本地缓存)按真实库存的若干倍粗筛,例如库存 100 件时放行几百个请求进入 Redis 扣减,让绝大多数请求在几乎没有网络开销的情况下被拒。
- 为什么用 Lua 做扣减。Redis 执行命令是单线程的,一段 Lua 脚本在执行期间不被其他命令插入,于是“读库存、判断、扣减、记录用户”这几步可以合成一个原子单元,中间没有可被插入的窗口。代价是脚本会占住主线程,脚本必须短,脚本里放 KEYS 扫描或大范围 HGETALL 会连带拖慢同实例上的其他业务。
- 库存预热与变更通道。开抢前把数据库库存加载到 Redis,加载动作需要通过单实例任务或分布式锁保证只执行一次;活动期间的补货、调整库存要走独立通道同步到 Redis 和数据库,否则会出现“Redis 显示 100 件、数据库只有 80 件”的错位。
- 一人一单的表达。用 Redis 的 set 结构记录已下单用户,或直接用数据库唯一键(用户与活动的组合)表达。它的价值不只是防重复,而是把“是否重复”变成一次幂等命中,为后面的重试与消息重投留出空间。
- 失败窗口。Redis 扣减成功后,发送消息、写数据库、返回回执都可能失败。此时库存已经少了而订单没有生成,就是少卖。处理方式是把扣减与消息发送绑定成一次可重放的单元:本地消息表加定时补偿,或使用消息队列的事务消息能力,同时让消费端幂等。
- 超时释放必须幂等。常见规则是订单 15 分钟未支付则释放库存。释放动作要基于状态机判断,例如“从未支付流转到已取消”只允许成功一次;如果只用“删除记录再加回库存”这种写法,重复执行会把同一份库存加回两次,超卖就此产生。
- 超卖与少卖的成因不同,监控要分开。超卖通常来自扣减不原子、回补重复、或绕过 Redis 直接写数据库;少卖通常来自扣减成功而后续链路失败且无人回补。前者要审计每一次回补,后者要盯“扣减成功但落库失败”的差值。
- 热 key 与集群约束。单个商品的库存 key 集中在同一个 Redis 节点上,扣减请求也会集中到同一分片。常见缓解手法是把库存按份数拆成多个 key 分桶,代价是桶间不均衡会留下少量卖不掉的余量。若使用集群模式,Lua 脚本涉及的多个 key 需落在同一槽,分桶键设计要一并考虑。
机制图或链路
flowchart TD
A["用户点击下单"] --> B["接入层限流: 用户/设备/活动维度"]
B --> C["应用层本地计数粗筛"]
C --> D["Redis Lua: 原子判断库存并记录用户"]
D -->|库存不足或重复下单| E["快速返回失败"]
D -->|扣减成功| F["发送消息到 MQ"]
F --> G["消费端按唯一键幂等落库"]
G --> H["数据库账本"]
F --> I["发送失败则由本地消息表重投"]
H --> J["对账任务: Redis 与 DB 库存差值比对"]
一句话点题:这张图的价值在于每往下一层,流量都要再少一个量级;真正需要严格正确的只有扣减那一步和它的补偿链路。
正确写法
最小 Lua 脚本(仅演示核心逻辑,未覆盖脚本超时配置、集群槽位约束、指标埋点与异常兜底):
-- KEYS[1] 库存 key, KEYS[2] 已下单用户集合
-- ARGV[1] 用户标识, ARGV[2] 本次扣减数量
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then
return -2 -- 重复下单
end
local stock = tonumber(redis.call('GET', KEYS[1]) or '-1')
if stock < tonumber(ARGV[2]) then
return -1 -- 库存不足
end
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
return stock - tonumber(ARGV[2]) -- 剩余库存
返回码要区分库存不足、重复下单与系统异常,否则前端只能给出一句含糊提示,用户会持续重试。
危险写法一:先 GET 判断,再单独 DECR 扣减。两个命令之间存在可被其他请求插入的窗口,多个请求可以同时读到同一份库存并各自扣减,超卖由此产生;之后加多少重试都补不回被破坏的守恒性。
危险写法二:用一把商品级分布式锁把整个扣减包住。锁的粒度覆盖了所有用户,吞吐被串行化;锁过期与续期又引入新的边界,锁失效的瞬间并发重新涌入,超卖仍然可能出现。
危险写法三:把 Redis 当成账本的全部依据,数据库只写订单不记库存流水。Redis 重启、淘汰、误操作都会让账实不符,事后也缺少可推导的基准。
延伸一层
- 与限流的关系。限流管入口,秒杀的分层过滤可以看作限流在活动场景下的一种组织方式;入口拦不住的量,靠后面的原子扣减与异步链路兜住,两者是分工而不是互替。
- 与幂等的关系。回补、超时释放、消息重投三处都依赖幂等,常见落地是唯一键、去重表与状态机流转。秒杀是把幂等设计用在高并发写路径上的典型场景。
- 与缓存一致性的关系。库存可以看作一种带业务规则的缓存写,但它比普通缓存严格:普通缓存允许短暂陈旧,库存不允许凭空多出来。所以它需要账本、对账与回补,而不只是读写顺序上的技巧。
- 面试官常接着问“队列里堆积了几百万条消息怎么办”。回答要点是先判断是消费能力不足还是消息被反复重投,再决定扩分区、扩消费者、旁路降级或丢弃可丢的消息类型;消费端幂等是扩容的前提,否则扩容只会放大重复副作用。
架构师视角
- 角色划分要写清:Redis 是裁决层,数据库是账本,消息队列是缓冲与重放通道。三层各有明确的失效模式,方案文档里应写清任意一层不可用时活动的降级姿态。
- 对账是必需项而不是优化项。定时比对 Redis 与数据库的库存差值、扣减成功与落库成功的水位,差异超过阈值告警,日终校准。没有对账,任何补偿都只是猜测。
- 风险归属要落到人。回补由谁执行、差异由谁确认、活动期间谁值班,应在方案评审阶段明确,否则库存不一致会成为长期无人认领的工单。
- 变更治理。库存数量、活动规则、限流阈值的修改走配置与审计,不允许直接改 Redis 数据;高风险阈值变更要有压测数据与回滚方案。
- 容量评估按放大后的量做。压测要对齐开抢瞬间的入口 QPS,并演练 Redis 故障、消息堆积、数据库写入变慢三类场景,活动预案才有依据。
面试怎么答
30 秒精华
秒杀的核心是把请求做成逐层递减的漏斗,并把判断库存与扣减库存压成一次原子操作;正确性靠 Redis 原子扣减加数据库账本、幂等回补与对账,而不是把商品整体锁起来。回答时先讲流量形态与守恒性这两个约束,再讲扣减位置的选择,最后补失败窗口与补偿。
答法骨架
- 先给结论:这是流量与一致性的组合问题,不是加锁问题。
- 讲分层:接入层限流、应用层本地粗筛、Redis 原子扣减、消息削峰、数据库结算,每层只放行下一层能处理的数量。
- 讲原子性:Redis 单线程加 Lua 把多步合成一步,说明为什么 GET 加 DECR 会超卖。
- 补失败与兜底:扣减与消息绑定、消费幂等、超时释放幂等、对账与回补,并说明超卖与少卖的成因不同。
可能被追问
- “Redis 扣了、数据库没落成怎么办?”应答要点:这属于失败窗口,用本地消息表或事务消息保证扣减与消息同生共死,消费端按唯一键幂等;短期靠重投放量,长期靠对账发现差异并回补。
- “为什么不直接用分布式锁?”应答要点:锁提供互斥,不提供吞吐,也不解决失败后的补偿;热点商品上锁会把并发串行化,且锁过期与续期本身又引入新的边界。原子扣减天然互斥,代价更低。
高阶追问
- “集群模式下 Lua 脚本怎么写?”应答要点:脚本涉及的 key 需要落在同一槽,通常用 hashtag 把同一商品的库存键与用户集合键绑定到同一分片;具体限制需对照所用 Redis 版本与客户端文档核实。
- “对账以哪边为准,怎么重放?”应答要点:以数据库流水作为账本推导应收敛值,Redis 只作裁决层;重放要基于流水与幂等规则,避免把已经回补过的差异再补一次。
容易翻车
- 常见错误说法:“加一把分布式锁就没有超卖。”正确边界:锁只提供互斥,锁过期、续期失败、以及跨组件操作都在锁的保护范围之外,超卖仍可能发生;真正消除竞态的是把判断与扣减合成原子操作。
- 常见错误说法:“Redis 扣减成功就等于下单成功。”正确边界:扣减只是裁决,消息与落库仍可能失败,需要幂等重投、补偿与对账来收敛,否则表现为有库存扣减却没有订单的少卖。
- 常见错误说法:“库存放 Redis 就够了,数据库只是留档。”正确边界:Redis 会重启、会淘汰、会被误操作,账本必须在数据库;只有裁决层加账本加对账,账实相符才有可验证的依据。
复习卡片
- 一句话:秒杀是把瞬时流量做成逐层递减的漏斗,并把库存判断与扣减压成一次原子操作,再用幂等与对账收敛失败窗口。
- 主链路:接入层限流,应用层本地粗筛,Redis Lua 原子扣减并记录用户,扣减成功后发消息到队列,消费端按唯一键幂等落库,发送失败走本地消息表重投,定时对账比对库存差值。
- 关键边界:GET 与 DECR 分离会超卖;脚本要短;集群下多 key 需同槽;超时释放与消息重投都要幂等;Redis 是裁决层,数据库才是账本。
- 选型与适用场景:库存有限、瞬时流量远大于稳态的活动型业务适合这套结构;对一致性要求更高而流量平稳的场景,直接在数据库用行锁或唯一键结算更省心。
- 易混点:超卖与少卖成因不同;限流与降级分工不同(入口拦截与故障兜底);缓存允许短暂陈旧,库存不允许。
自测
- 为什么“先 GET 判断、再 DECR 扣减”的写法会出现超卖?换成 Lua 之后消除的是什么?
- 库存扣减成功但消息发送失败时,系统处于什么状态?有哪两种处理方向,各自的代价是什么?
- 口述:对照你参与过的一次高并发写入场景(不虚构公司名与业务数据),说明流量在哪一层被拦住、压力落在哪个组件的哪一步,以及当时最担心的是哪种不一致。
参考答案
- 两个命令之间存在可被其他请求插入的时间窗口,多个请求可以同时读到同一份库存并各自扣减,扣减总量超过初始库存。Lua 消除的是这个窗口:脚本执行期间其他命令被排队,判断与扣减作为整体生效,也就是把 check-then-act 变成一次原子动作。
- 处于库存已扣减、订单未生成的中间态,即少卖。方向一是让扣减与消息发送绑定成可重放单元(本地消息表加定时补偿,或事务消息),代价是要维护额外的表与补偿任务;方向二是先落库再以数据库为裁决,代价是并发能力下降、写入成为新的热点。
- 回答骨架:先讲流量形态与约束(峰值与稳态的倍差、库存守恒),再讲拦在哪一层(接入层限流、应用层粗筛),然后讲压力落在哪一步(Redis 原子扣减与消息写入),最后讲兜底(幂等、补偿、对账、降级预案)以及当时最担心的不一致类型。
本周练一次
20 分钟内:写下你手上一个“高并发写加有限资源”的接口,画出它的分层过滤链路,标注每一层放行的量级与拒绝的原因;再写出这条链上三处必须幂等的动作,以及各自失效时会出现什么现象。最后闭眼口述 2 分钟,用“漏斗、原子扣减、失败窗口、对账”四个词讲完整条设计。
#面试