面试官问“秒杀库存扣减策略”的时候,到底想听什么?很多人第一反应是背出“乐观锁、悲观锁、Redis原子扣减”这几个词,然后开始讲原理。但我面了这么多年,也被人面了这么多次,可以很负责任地说:面试官想听的从来不是名词,而是你在真实业务场景里做过权衡之后,得出的那套自洽方案。这道题没有唯一正确答案,但有高下之分。你踩过的坑、你做过的取舍、你在极端情况下的兜底方案,才是这场对话里真正值钱的东西。
这篇内容我会把库存扣减从数据库到缓存、从下单到支付、从正常流程到异常兜底的完整链路拆开讲一遍,给出可以直接拿去面试的表述框架,也把我在实战中真正踩过的坑一并放进来。内容会持续更新,毕竟这道题能挖的深度,确实比大多数人想象中要深得多。
1. 库存扣减的底层逻辑:为什么它比想象中难
1.1 扣减不只是“减一”,而是三个环节的联动
很多没真正做过高并发的人,会把库存扣减理解成一个非常朴素的操作:用户点击购买,数据库里那个库存字段减一,完事。这种理解在日活几千的小系统里确实够用,但一放到秒杀场景下,问题立刻暴露。
库存扣减真正的复杂度在于,它不是单点操作,而是“库存预占 + 订单创建 + 异常回补”这三个环节的联动。用户提交订单的那一刻,你得先保证库存还在;库存扣掉之后,订单数据要落库;如果用户后续不支付或者超时未支付,这笔被占住的库存还得能释放出来。任何一个环节断了,都会出现要么超卖、要么库存被永久占用变成死库存的情况。
所以面试官问“库存扣减策略”,本质上是在问:你怎么保证这三个环节在超高并发下依然能够保持一致性和可用性。如果你只回答“用乐观锁”,那只是回答了第一个环节的一半,后面两个环节你还没接住。
1.2 超卖的根源:竞态条件与检查时序
超卖的本质是什么?是多个请求同时读到同一个库存值,然后基于这个旧值去做扣减判断。经典的“检查-扣减”两步操作之间存在时间窗口,这个窗口里如果并发请求扎堆进来,就会出现所有人都看到库存还剩1,结果所有人都扣成功的局面。
数据库层面的行锁能解决这个问题,但代价是并发能力被锁串行化。缓存层面的原子操作能解决这个问题,但代价是缓存与数据库之间的数据一致性维护变得复杂。本质上你做的所有技术选型,都是在“一致性强度”和“并发吞吐量”之间寻找一个可接受的平衡点。
我在面试里遇到过一个很典型的候选人,他跟我说用“Redis的DECR命令”来扣减,我问他那扣成负数怎么办,他说加一个判断,值大于0才扣。我再问这个判断是不是原子的,他愣住了。这就是典型地只知其然不知其所以然。Redis的DECR本身是原子的,但“先GET判断再DECR”这个组合不是原子的,要保证原子性,得用Lua脚本把判断和扣减塞进同一个脚本里执行。
1.3 一致性选型:AP还是CP,业务说了算
做库存扣减之前,你首先得想清楚一个哲学问题:当极端情况发生时,你到底是宁可少卖,还是宁可超卖?这两个选择对应的是完全不同的两套技术方案。
宁可少卖的系统,会把一致性放在第一位。库存扣减必须绝对准确,哪怕损失一部分并发性能也无所谓。这种系统通常会用数据库行锁或者严格的乐观锁来控制并发。典型的业务场景是限量版球鞋发售、艺人的限量周边,超卖一件的损失可能远超技术成本。
宁可不错过用户、允许事后校正的系统,则会把吞吐量放在第一位。先让所有请求都进来,库存扣减用缓存顶住,事后通过异步对账把数据校正回来。典型场景是电商平台的普通秒杀活动,超卖个几千件,事后用优惠券或者退款安抚用户,综合算下来还是划算的。
这个选择没有对错,但你必须说得清楚。你说你用了Redis做扣减,面试官问“那万一Redis扣减和数据库对不上怎么办”,你要是答不上来兜底方案,这题基本就挂了。技术选型不值钱,选型背后的权衡逻辑才值钱。
2. 三种主流扣减方案的对比与选型逻辑
2.1 数据库悲观锁:最稳但最慢的方案
悲观锁的核心思路是:我怀疑一定会有人跟我抢,所以我先把这条数据的锁拿到手,别人只能等我操作完再动它。落到实现层面,就是SELECT * FROM product WHERE id = ? FOR UPDATE,然后执行库存扣减,最后提交事务释放锁。
这个方案的优点非常突出:绝对不会有超卖,因为同一时间只有一个人能拿到锁;实现也最简单,不需要引入任何额外组件,一个数据库就搞定。但缺点同样明显:以单条库存记录为粒度,QPS基本上被锁串行化锁死在上千这个量级。如果秒杀商品同时参与的SKU很少,比如就一个单品,那数据库的写入压力会瞬间打满。
我之前在一个小团队里真的见过有人用这套方案扛秒杀,当时的用户量大概是几万,数据库用的是4核8G的MySQL实例。活动开始的那一刻,数据库的活跃连接数直接飙到上限,CPU跑满,最后不得不临时扩容。事后复盘的时候发现,执行FOR UPDATE期间事务持锁时间虽然短,但大量请求在锁等待队列里堆积的时候,数据库的连接池最先扛不住。
所以悲观锁这个方案,我只建议在并发量可控、商品SKU数量较多的场景下使用。比如后台管理系统的库存调整、预售活动的中后段,这时候并发没那么夸张,用悲观锁最省心最安全。
2.2 数据库乐观锁:吞吐量提升,但重试是个坑
乐观锁的思路是:我默认大家不会打架,但在更新的时候检查一下版本号或者库存条件,如果不匹配就说明有人改过了,需要重试。经典SQL长这样:
UPDATE product SET stock = stock - 1, version = version + 1 WHERE id = ? AND stock > 0 AND version = ?通过WHERE条件里的stock > 0来保证不会扣成负数,通过version = ?来保证不是基于过期数据做更新。如果影响行数为0,说明要么库存不足,要么版本冲突,上层拿到这个结果后进行重试或者直接返回失败。
这个方案比悲观锁聪明的地方在于,它把锁的粒度从“锁住整条记录”变成了“只在更新瞬间做条件校验”,数据库的并发能力因此提升了不少。但它的坑在重试。在高并发秒杀场景下,版本冲突的概率非常高,如果客户端一冲突就重试,重试几次之后数据库的请求量会成倍放大。我见过最夸张的情况是,本来只预期10万的QPS,乐观锁重试直接把数据库打到每秒30万次的更新请求,库直接给拖垮了。
所以使用乐观锁的关键不是冲突检测,而是冲突之后的处理策略。你要么限制重试次数上限(比如最多重试3次),要么直接放弃本次操作把用户引导到“再次尝试”页面,让用户重新发起。更重要的一点是,不要把版本号当成唯一的乐观锁条件,直接在WHERE里加上stock > 0的库存条件,会让整个方案健壮得多。
2.3 Redis + Lua:高并发场景下的主流解法
第三个方案,也是目前大厂秒杀场景下最主流的方案,就是用Redis做库存预扣,用Lua脚本保证扣减的原子性。核心代码长这样:
local stock = tonumber(redis.call('get', KEYS[1])) if not stock or stock < tonumber(ARGV[1]) then return 0 end redis.call('decrby', KEYS[1], ARGV[1]) return 1这段脚本解决的核心问题是:判断库存是否足够、执行扣减这两个步骤,从前端的“两步操作”变成了Redis服务端的“一个原子脚本”。而Redis本身是单线程执行命令的,Lua脚本在执行期间不会被其他命令打断,所以并发请求进来之后,本质上是被Redis串行化处理的,不会有任何两个请求同时读到同一个库存值。
这个方案的性能天花板非常高。单机Redis跑这个脚本,每秒可以稳定处理几万次的扣减请求;即使是亿级秒杀场景,只要做好库存分片和Redis集群扩容,吞吐量基本上不是瓶颈。
但使用这个方案,面试官必然会追问一个核心问题:Redis里的库存和数据库的库存,一致性怎么保证?这个问题我放在第3章详细展开。你先记住一个结论:Redis扣减只能作为“预占”,真正要落库的库存扣减和订单数据,必须在异步流程里去完成,而且要有一整套补偿机制确保最终一致。
2.4 三种方案选型对比速查表
我把这三个方案的核心差异整理成了一个表格,面试的时候你可以直接引用:
| 维度 | 数据库悲观锁 | 数据库乐观锁 | Redis + Lua |
|---|---|---|---|
| 一致性强度 | 绝对一致 | 较强(依赖重试) | 最终一致(需异步补偿) |
| 并发能力 | 低(被锁串行化) | 中(存在重试放大效应) | 极高(单机数万QPS) |
| 实现复杂度 | 最简单 | 中等(需处理冲突) | 中等偏高(需维护缓存一致性) |
| 适用场景 | 后台系统、SKU众多、低并发 | 中小规模秒杀、库存字段单一 | 大规模秒杀、热点SKU、高并发 |
| 超卖风险 | 无 | 极低 | 需通过预扣量控制 |
3. Redis扣减的细节实操:从预热到释放
3.1 库存预热:为什么必须在活动前把Redis准备好
秒杀开始前,数据库里的库存是完整的,比如SKU有1000件。但Redis里还没有对应的键。如果活动一开始用户请求打进来,你的代码发现Redis里没有库存键,回头去数据库里面查,再把库存初始化到Redis,这个初始化操作在流量高峰阶段会发生什么?多个请求同时发现无键、同时去数据库查、同时往Redis写,轻则数据库瞬时压力倍增,重则初始化逻辑在高并发下出现竞态条件,导致初始化的库存值都不对。
所以标准的做法是在活动开始前,通过一个预热任务把商品的库存提前写入Redis。常用命令是SET sku:stock:10001 1000,如果有多个SKU,可以用Pipeline批量写入,避免频繁的网络往返。预热完成之后,还要额外做一件事:验证预热结果。
我之前就栽过一次。当时预热脚本里有个字段映射写错了,把SKU的ID写串了,导致A商品的库存被初始化到了B商品的键下面。活动上线前没有做二次核对,等到秒杀开始之后运营发现A商品怎么抢都提示库存不足,B商品的库存竟然越卖越多,后台数据直接乱掉了。所以预热之后一定要写一个核对任务,把Redis里的库存总和和数据库里的可售库存对一遍,不一致就告警阻断上线。
预热的动作必须在活动开始前完成,而不是放在用户的请求链路里去做“懒加载”。懒加载的思路在缓存穿透防护里常见,但用在秒杀预热上就是灾难。
3.2 扣减与回补:Lua脚本的正确写法与容错
前面给了一个最基础的Lua脚本,但在真实生产环境里,一段合格的扣减脚本至少要包含三个能力:按需扣减、库存不足时返回明确错误码、支持多SKU的批量扣减。
先看按需扣减。秒杀场景下用户可能同时下单多件商品,比如一个用户抢了3件T恤,你的脚本需要支持传入扣减数量,而不是固定减一。DECRBY本身就是支持指定步长的,关键是判断逻辑要改成stock < ARGV[1]而不是stock < 1。
再看库存不足的错误处理。脚本返回0表示库存不足,这时候上层的策略是直接给用户返回“已抢光”的提示。这里有一个容易被忽略的细节:脚本里如果判断库存不足直接返回0,会留下一个不再被使用的Redis键吗?不会,因为GET和DECRBY都不会删除键。但如果我们在扣减成功后做了“库存清零删除键”的操作,比如DEL,那就必须考虑后续回补的问题了。
批量扣减的场景通常出现在购物车和组合下单里。一个订单包含多个SKU,每个SKU的库存要一起扣,且必须保证所有SKU要么全部扣成功要么全部扣失败。这时候Lua脚本里要用redis.call逐一处理多个键,并且要提前处理某个键不存在的情况。脚本执行过程中如果出错,Redis会回滚脚本内执行的所有写操作,这个特性本身就保证了批量扣减的原子性。
回补的逻辑也很关键。用户支付超时、订单取消、风控拦截等场景,都需要把扣减掉的库存加回来。最简单粗暴的做法是INCRBY把数量加回去,但这里会有一个隐患:如果一个订单在扣减成功之后、回补之前,用户又发了一个新订单,新订单扣减的是回补之前的库存,那账目上就乱了。所以回补必须是对原始扣减动作的反向操作,并且要通过唯一的幂等键(比如订单号)来保证同一条订单的库存不会重复回补。具体方案在第4章的异常处理里细说。
3.3 库存键的有效期设置:这里藏着一个很深的坑
Redis规范里有个好习惯:所有键都要设置过期时间,防止内存泄漏。但库存键如果设置过期时间,会引发一个非常隐蔽的问题:活动进行到一半,用户的抢购还在继续,库存键的TTL到了,Redis自动把键删除了。
此时你的扣减脚本会得到什么结果?GET返回空,脚本里如果对空值做了“视为库存充足”的处理,那超卖就发生了;如果做了“视为库存不足”的处理,那明明还有库存的商品忽然就显示抢光了。
正确的做法有两种。第一种是预热时不设置过期时间,活动结束后通过定时任务清理;第二种是设置足够长的过期时间以保证覆盖整个活动周期,并且在每次成功扣减后重置TTL。这两种方案我实际都用过,更推荐第二种,因为如果活动时间临时延长,定时任务没跑,至少键还在,不会出现活动没结束但库存键消失的情况。
还有一个变体玩法:库存键可以分成多个子键来扛并发。比如1000件库存分成10个桶,每个桶100件,用户的扣减请求先做一次哈希或者随机分配落到某个桶上,从而把热点从单个键分散到10个键上。这个方案能显著提升Redis的吞吐上限,但代价是库存利用率可能不均衡,某个桶先扣完了,用户就会被提示库存不足,实际上其他桶还有库存。所以分桶策略要先想清楚:是接受这种不均衡,还是扣减失败后在桶之间做二次路由。我建议先做一轮桶内扣减,失败后按顺序尝试其他桶,保证用户体验。
4. 下单即扣还是支付扣减:被大多数人忽略的业务决策
4.1 三种扣减时机的业务代价对比
库存扣减的时机,直接决定了用户体验和系统复杂度。市面上主流的做法有三种:下单即扣、支付前预占、支付成功后再扣。
下单即扣最直观。用户提交订单的瞬间,库存立刻被扣掉。这样做的好处是库存数据非常准确,不会出现用户付了钱但没货的情况;坏处是恶意占单成本极低,用户可以把热门商品反复下单不支付,把库存全占住,导致真正的买家买不到。遇到过最极端的情况,某次活动有用户批量注册了上百个账号,每个账号下单后不支付,库存被全部锁死,正常用户一进来就是缺货,活动效果直接归零。
支付前预占是折中方案。用户下单时,系统先锁定一部分库存,但不真正扣减,给用户一个支付倒计时(比如15分钟)。用户在倒计时内完成支付,库存正式扣减;超时未支付,锁定的库存自动释放。这套方案能有效拦截恶意占单,但还需要在“锁定库存”和“真实库存”之间做区分,复杂度比下单即扣要高一个台阶。Redis扣减方案里通常会把一个SKU的库存拆成两个键:一个总库存键,一个锁定库存键。下单时检查总库存能否覆盖锁定数量,支付时把库存从锁定状态转为已售状态。
支付成功后再扣是三种方案里最保守的。用户下单完全不占用库存,只有收到支付回调之后才去扣减。这个方案的用户体验最好,但库存超卖风险最高。一旦下单量超过库存量,后面的订单就要面临无货可发的危机,需要给用户退款或者赔偿,激起的客诉量会非常恐怖。
4.2 怎么选:库存量级和商品单价决定方案
没有哪个方案是全局最优的,选型的逻辑要回到业务本身去判断。我的经验是看两个维度:库存量级和商品单价。
高库存、低单价的商品,比如日用百货、零食饮料,用户对等待和缺货的容忍度都比较高,完全可以在支付成功后再扣减,即使超卖了赔付成本也可控。
低库存、高单价的商品,比如限量球鞋、电子设备,每一件都是真金白银,超卖一件的成本可能覆盖整个活动的利润。这种场景必须用下单即扣或者支付前预占,宁可损失一部分转化率,也要保证库存数据的刚性准确。
中等库存、中等单价的东西,比如服装箱包,适合用支付前预占,在恶意占单和用户体验之间找一个平衡点。
我在第1章提到的“一致性选型是业务决策”,到这里就完全串起来了。技术方案永远是为业务目标服务的,你面对面试官时能说出这套决策逻辑,比单纯背方案要加分得多。
4.3 不同扣减时机下的Redis键设计实例
为了让你对“支付前预占”有一个更直观的理解,这里给出一个具体的设计方案。
一个SKU在Redis里维护三个键:
sku:stock:10001 总库存余量(初始等于预热库存) sku:locked:10001 已锁定库存量(初始为0) sku:sold:10001 已售库存量(初始为0)用户下单时,Lua脚本执行逻辑:检查总库存余量 - 已锁定库存量 - 已售库存量是否大于等于下单数量。如果满足,则总库存余量减去下单数量,同时已锁定库存量加上下单数量。这个检查到锁定的过程同样在一个Lua脚本里完成,保证原子性。
用户支付成功时,收到支付网关回调后,执行另一个Lua脚本:已锁定库存量减去下单数量,已售库存量加上下单数量。
用户超时未支付或主动取消时,执行回补脚本:总库存余量加上下单数量,已锁定库存量减去下单数量。
这套设计的好处是,任何时候你都能通过INFO命令或者定时任务,在管理后台看到当前活动的“实时库存构成”。哪部分是可抢购的,哪部分是被锁定但还没付款的,哪部分是已经卖出去的,一目了然。关键指标都能监控起来,出了活动事故也可以很快定位是哪一环出了问题。
5. 面试官深挖追问清单:这六问你接得住几问
5.1 “Redis里的库存和数据库里的库存,对不上了怎么办?”
这是整道题里被追问概率最高的问题,没有之一。即便你已经说了用Redis做最终一致性补偿,面试官依然想知道数据不一致时你有没有具体的兜底方案。
我的答案是异步对账机制。每一笔Redis扣减都会产生一条包含订单号、SKU、扣减数量、时间戳的流水记录,通过MQ异步发给库存服务。库存服务消费消息后,在数据库事务里扣减真实库存,并落一条扣减流水。定时任务每隔一段时间扫描数据库流水和Redis流水,两边进行核对,差额超过阈值就触发告警。
如果数据库扣减失败,比如数据库里没有这条商品记录了,库存服务会发送一条补偿消息去把Redis回补。整个链路的终点就是数据库,Redis只是一个高性能的“预占层”,最终的库存数据以数据库流水为准。
5.2 “库存扣成负数了怎么办?”
负库存是系统有严重缺陷的信号,而不是一个“库存扣多了”的小问题。出现负数意味着你的扣减脚本缺乏库存下限的校验,而一次负库存背后可能有几十个超卖订单在等着你处理。
处理方案要看严重程度。如果是Redis扣减阶段就已经出现负数,说明你对Redis键的扣减没有做下限判断,把Lua脚本加一句stock < 0的判断全部返回失败即可。如果是数据库阶段出现负数,说明事务里的扣减SQL没有带WHERE stock > 0条件,需要立即修复SQL,同时准备一个数据订正脚本去人工处理已经出现负数的商品库存。
5.3 “同一用户重复下单,怎么处理?”
一个用户把1000件库存全部下单占住,对系统来说是合法的,对业务来说是灾难。所以扣减系统必须和风控系统联合工作。
最简单的策略是在Redis里给每个用户一个秒杀标记键,比如user:seckill:10001:uid123456,下单成功就写入一个短时TTL的标记,TTL和支付倒计时一致,过期后用户才能重新下单。更严格的策略是把标记的时间窗口设为整个活动周期,一个用户一个账号在一个SKU下最多只能买一件。
如果是下单即扣的逻辑,这个标记还能顺带解决另一件事:防止用户用多个账号批量下单后合并支付。通过用户维度的限购,从源头控制恶意占单带来的库存损耗。
5.4 “扣减的库存不够,用户重新下单又抢不到,怎么办?”
很多系统在这里犯的第一个错误是:订单创建失败后,把用户直接踢出流程,也不管他那份已经被扣掉的库存。
正确做法是先把订单标记为“异常状态”,然后走回补流程把库存释放掉,再尝试在库存充足的情况下重新创建订单。如果重试3次仍然失败,可以把用户加入候补队列,一旦有其他人超时未支付释放库存,就按候补顺序补单。这个设计成本不高,但对用户体感的提升非常明显。抢到东西之后又被白白取消,是所有用户最讨厌的体验。
5.5 “Redis都扛不住了,怎么办?”
Redis扛不住通常分两种情况:单节点QPS打满,或者网络带宽打满。前者靠分桶分片解决,把一个SKU的库存拆到多个Redis键上分担压力;后者靠客户端本地缓存预热来减少请求次数,让用户刷新的按钮不再每次都打到Redis上。
本地缓存预热指的是在秒杀开始前,把商品的“可抢状态”和“库存水位”推送到各个应用节点的本地缓存里,前端展示的按钮状态先从本地缓存读,不直接打Redis,只有用户真正点击“立即抢购”时才穿透到Redis。这套组合下来,Redis的请求量能降下来一大截。
5.6 “秒杀结束了,Redis里的数据怎么收尾?”
活动结束后,Redis里的库存数据不再需要支撑高并发扣减,就需要把Redis的数据和数据库的数据做最终对账。对账通过之后,清理掉Redis里对应的键,释放内存。
这里注意一点:清理不能直接把键删掉,而是要先确认所有已售订单的数据库流水已经完全落库,并且不存在未补偿的扣减记录。否则你清理了Redis库存键,万一后面有用户退款,回补操作没地方执行,就会现“钱退了但库存还是少”的账实不符。
6. 库存扣减面试中的表达框架与常见失分点
6.1 一个可以套用的“三段式”回答框架
面试官问“库存扣减怎么做”的时候,我建议的回答结构是三段式,层层递进:
第一段讲业务理解。先说明你清楚秒杀库存扣减的核心矛盾在于高并发与强一致性之间的取舍,然后说明在你负责的业务里,核心商品是什么量级、用户的支付意愿如何、超卖的代价有多大。这一段的目的,是让面试官知道你不是在背答案,而是在做业务设计。
第二段讲技术方案。从你业务里最核心的量级出发,说明你的技术选型是Redis + Lua还是数据库乐观锁,为什么选这个,同事说明方案的吞吐上限和一致性保障级别。选型理由要具体到数字,比如“这个SKU的库存是1万件,预估瞬间流量是20万QPS,数据库单表撑不住,所以要用Redis顶在第一层”。
第三段讲异常处理。主动说出库存不足、超时未支付、Redis和数据库对不上账这三个场景的处理方案,让面试官看到你有完整的兜底意识。
6.2 我在面试中最常看到的三个失分点
第一个失分点是缓存与数据库一致性说成“强一致”。如果你方案里用了Redis做预扣,就永远不可能和数据库做到强一致,你只能说“最终一致”。强一致是数据库主从复制都做不到的极致目标,把这词说错了会显得不专业。
第二个失分点是只讲扣减不讲回补。库存扣减是一套“进与出”的系统,只谈扣不谈释放,说明你对订单的生命周期管理理解不完整。面试官要的是你把“用户下单-库存预占-支付成功/超时失败-库存释放/核销”这条链路闭环说清楚。
第三个失分点是把“防止超卖”和“提升并发”混为一谈。防止超卖是一致性控制,提升并发是吞吐优化。你把它们并列之后,没有继续说明两个目标之间的关系是怎样的,面试官就很难判断你是否真正理解这个系统的核心矛盾。这两个目标本质上是矛盾的,方案里必须明确指向“最大化吞吐的同时守住一致性底线”这个主线。
7. 持续更新:这套策略在真实大促中的完整落地复盘
每次双11、618这种级别的活动结束之后,我都会做一次完整的复盘。库存扣减策略在纸面上写得再完美,上线后的实际表现总有意外。这里分享一个我近期参与的案例,一个头部美妆品牌的大促活动,SKU数量200个左右,单品最高库存2万件,预热期预估峰值流量约30万QPS。
我们的最终方案用的是Redis分桶 + 数据库最终校验的组合。Redis侧,每个SKU拆成10个桶,每个桶里是Lua脚本做原子扣减;数据库侧,每个订单的库存扣减走一条MQ队列串行处理,确保数据库的写入压力可控。活动峰值时,Redis集群最高QPS达到了15万左右,数据库的扣减队列积压最多的时候有3万条,但最终都能在秒杀结束后的2分钟内全部消费完毕。
上线的第一天晚上就遇到了一个有意思的问题:有用户反馈秒杀页面显示库存充足,但是点“立即抢购”之后就提示“系统繁忙,请稍后重试”。排查发现是个库存桶已经扣完,但我们前端的“库存水位”展示是从Redis的某个汇总键读的,汇总键没及时同步单个桶已扣完的状态。这套分桶方案为了保证性能,用户请求落桶的哈希逻辑是可以访问到所有桶的状态的,所以扣减本身没问题,是状态展示链路漏了信息同步。后来我们调整了数据上报逻辑,让桶的扣减事件实时汇总到展示键上,才彻底解决。这个问题如果不复盘,下次大促一定还会再踩一遍。
这个案例里另一个值得说的细节是库存数据的可视化。我们把Redis的三个核心键(总库存余量、已锁定、已售)加上数据库的实时库存水位,全部接进了监控大屏。活动进行中,运营和大促负责人可以实时看到库存水位的变化曲线,哪款商品卖得比预期快、哪款商品有异常锁定堆高,都能在第一时间发现。库存扣减不只是一套技术工具,它也是大促决策的眼睛。
8. 写在最后:我个人的几个实操习惯
我觉得库存扣减这个方向做久了,真正沉淀下来的是几个习惯。
第一,做任何容量预估,都要给自己留三倍余量。线上真实流量永远比预估高,尤其是秒杀这种容易上热搜的活动。库存扣减链路里的Redis性能、MQ吞吐、数据库连接池,每一个环节都按预估的三倍去设计。
第二,扣减逻辑上线前,至少要演练一次“扣到负数”和“Redis宕机”这两个场景。很多人觉得Redis宕机是极小概率事件,但真发生了,如果没预案,整个秒杀就废了。我们的预案是Redis不可用时,降级到数据库乐观锁,虽然并发能力断崖式下降,但至少不会超卖。如果数据库也扛不住了,那就直接熔断,页面统一显示“活动太火爆,请稍后再试”。
第三,每一条库存扣减流水都要有幂等键。订单号是所有补偿操作的自然幂等键,扣减、回补、对账,全部以订单号为维度。没有幂等键的库存系统就是定时炸弹,一旦消息重复消费,库存就被白白扣没。
关于秒杀库存扣减这场面试题,能聊的东西确实比想象中多得多。技术方案本身不复杂,真正的深度在于你对业务、对极端情况的思考。我会持续把这篇文章在每次实战之后再回来更新,把真正有价值的复盘沉淀在这里面,让后来的人少踩几个坑。