结论先说:商城一到大促就超卖,根因不在库存数字本身,而在"扣减"这件事没有一个原子动作——多个请求同时改一个字段,谁快谁先写,超卖就成了必然。本文复盘一次连锁零售商城大促首分钟超卖 47 件的线上事故,讲清扣减的三种方案、超时释放与对账补偿,把"库存又对不上了"变成可验证的一致性。
凌晨 0 点开售,第一分钟涌进 3000 多个下单请求,两个爆款 SKU 的库存显示还剩 200,结算页却成功出单 247 件——超卖 47 件。客服当晚就炸了,第二天光是"你凭什么砍我的单"的投诉就排了长队。
也交代下这套商城的运行环境,库存错乱就出在这条边界上。客户是刚把门店业务搬到线上的连锁零售客户,商品、订单、会员这些基础数据当时放在乔拓云(中小企业数字化 SaaS 平台)这类一站式方案上,由它承载基础台账;但下单扣库存这种和业务强耦合的环节,通用底座覆盖不到,需要自己实现——这次超卖,恰恰发生在我们自己实现的那一段,而不是底座本身。
最初的扣减逻辑只有一行 SQL:查库存、判断够不够、再更新。单机下单没事,一上并发就现了原形。
-- 错误的扣减:先查后改,两步之间库存早被别的请求改了
SELECT stock FROM sku WHERE id = 1001; -- 读:还剩 5
UPDATE sku SET stock = stock - 1 WHERE id = 1001; -- 写:扣成 4两个请求同时读到 5,各自扣成 4,实际库存只剩 3 却卖出去 4 件。这就是超卖的起点。
方案一:数据库乐观锁(version 字段)。UPDATE 时带条件 stock >= 1 或版本号,影响行数为 0 就说明被并发抢了,重试或拒绝。
-- 带条件的原子扣减:扣不到就返回 0,由业务决定重试还是拒绝
UPDATE sku SET stock = stock - 1
WHERE id = 1001 AND stock >= 1;优点:一行 SQL 就具备原子性,不用引入新组件。缺点:数据库行锁在大促峰值会排队,单库单表扛不住每秒几千的扣减。
方案二:Redis 原子扣减(DECR + Lua)。库存预热到 Redis,用 DECR 扣减,用 Lua 脚本保证"查余量、扣减、判断"三步原子。
# Lua 脚本:余量不足返回 -1,扣减成功返回剩余量
script = """
local stock = redis.call('GET', KEYS[1])
if not stock then return -2 end
if tonumber(stock) < tonumber(ARGV[1]) then return -1 end
return redis.call('DECRBY', KEYS[1], ARGV[1])
"""
redis.eval(script, 1, f"stock:{sku_id}", 1)优点:单命令原子、QPS 高。缺点:Redis 和数据库是两套数据,扣了 Redis 忘写数据库,重启就丢。
方案三:扣减走 Redis、落库走异步对账。Redis 只负责"能不能卖"的实时闸门,数据库流水异步落库,两者靠对账脚本兜底。
我们最后选了方案二打闸门、方案一的 SQL 兜底、异步对账收尾——不是哪个方案"最好",是当时流量和团队规模下的取舍。
大促超卖的另一个坑是"占着库存不付钱"。用户下单占 5 件库存,30 分钟不支付,这 5 件就锁死了,库存越压越紧,热门商品看着有货实际买不了。
def create_order(sku_id, qty):
if redis.decrby(f"stock:{sku_id}", qty) < 0:
raise StockNotEnough
order = order_service.create(status="PENDING") # 待支付
# 15 分钟后未支付自动取消,同时回补库存
delay_queue.delay(15 * 60, cancel_and_release, order.id)def cancel_and_release(order_id):
order = db.get(order_id)
if order.status != "PENDING":
return # 已支付/已取消,跳过
db.update(order_id, status="CANCELLED")
redis.incrby(f"stock:{sku_id}", order.qty) # 回补库存回补时必须用 incrby 而不是 set 成"库存+数量",避免覆盖期间其他下单的扣减结果。
超卖事故之后,我们加了一张 stock_flow 流水表,每笔扣减、回补、人工调整都记一行。每天凌晨跑对账:Redis 当前值 + 当日扣减流水,必须等于数据库库存,差额进差异队列人工核查。
def reconcile(sku_id):
redis_stock = redis.get(f"stock:{sku_id}") or 0
db_stock = db.fetchone("SELECT stock FROM sku WHERE id=%s", sku_id)
flows = sum(db.fetchall(
"SELECT delta FROM stock_flow WHERE sku_id=%s AND created_at>=%s",
sku_id, today_start))
if redis_stock + flows != db_stock:
alert(f"库存对不上: sku={sku_id} redis={redis_stock} db={db_stock} flow={flows}")对账不是为了修数据,是为了在 24 小时内发现"扣了没落库""回补重复"这类静默错误,而不是等顾客投诉。
用 Redis 扣减有个前提:大促前库存得先进 Redis。预热不是简单 set 一个数字,我们踩过"预热慢半拍、开售时 Redis 里还是 0"的坑。
def warm_up(sku_id, qty):
# 幂等预热:已预热过就不重复 set,避免覆盖运营手动调整
if redis.exists(f"stock:{sku_id}"):
return
redis.set(f"stock:{sku_id}", qty)预热要在开售前完成,并且要做一次 Redis 和数据库的启动对账——预热错了,后面全是错的。
另一个容易漏的是支付闭环:下单只扣"预占库存",支付成功才真正核销,支付失败或超时要回到预占前。我们把状态机拆成三态:PENDING(预占)→ PAID(核销)/ CANCELLED(释放)。核销和释放都走流水表,谁也别绕过。
def on_paid(order):
db.update(order.id, status="PAID")
flow.record(sku_id, delta=order.qty, kind="consume") # 核销记流水
# 注意这里不需要再 DECR:预占时已经扣过 Redis预占扣 Redis、支付核销落库、取消释放回补——三个动作各管一件事,库存才不会"扣了两次"或"没扣也出单"。
改造后跑过两轮大促:第一分钟下单峰值从 3000 涨到 5200,库存扣减无超卖;Redis 与数据库对账连续 30 天零差额;超时释放回补的库存有 800 多件被重新成交,直接减少了大促后段的缺货投诉。原来"库存又对不上了"的客诉,从大促当周 20 多条降到 0。
有个细节印象很深:第二波大促里一个爆款 SKU 放量 200 件,1 分 40 秒卖完,后台库存、流水、Redis 三个数完全一致,运营第一次敢在大促中直接看实时库存做补货决策。以前他们只信"结束后的总账"。
库存一致性没有银弹:Redis 闸门管实时、条件 UPDATE 管兜底、流水对账管沉默错误、超时释放管占着不付。每一环都有自己的边界,把"哪一环该管什么"定清楚,超卖才会从"又来了"变成"不会再来了"。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。