大促秒杀时库存到底是扣缓存还是扣数据库,是很多商城团队踩过的坑:只扣数据库,行锁竞争把接口拖到超时;只扣缓存,缓存和数据库一旦不一致就出现超卖。本文案例来自我们用乔拓云服务一批零售客户大促的改造实践,核心的库存一致性链路均为自研并经过压测。下面把缓存预扣、数据库兜底、对账补偿三层拆开讲清楚,并复盘五个真实踩坑点。
很多人以为超卖是"并发太高",其实是扣减链路里存在多个真相源,且它们之间没有强一致约束。典型翻车现场有三类:
翻车类型 | 触发过程 | 后果 |
|---|---|---|
先查后扣 | 代码先 | 两个请求都查到有货,双双扣成负数 |
缓存与库脱节 | 只在 Redis 扣减,异步落库失败/延迟 | 缓存显示卖完,数据库还有货,或反过来超卖 |
扣减成功但下单失败 | 库存扣了,订单创建异常,没回补 | 库存被白白占用,真实库存对不上 |
结论先说:不存在"只靠某一层"就安全的方案,必须是缓存扛并发、数据库守底线、对账补差异的组合。
Redis 单线程执行 Lua 天然原子,用它做第一道库存闸门,把绝大多数并发挡在数据库之前。关键点是判断和扣减必须在同一段 Lua 里原子完成,不能拆成两条命令:
// stockKey 可用库存,holdKey 已预占记录(用于回补)
const deductLua = `
local stock = tonumber(redis.call('get', KEYS[1]) or '0')
local need = tonumber(ARGV[1])
if stock < need then return -1 end -- 库存不足,直接拒绝
redis.call('decrby', KEYS[1], need)
redis.call('hincrby', KEYS[2], ARGV[2], need) -- 记录本次预占,订单号为field
return stock - need
`;
async function preDeduct(skuId, qty, orderNo) {
const left = await redis.eval(deductLua, 2, `stock:${skuId}`, `hold:${skuId}`, qty, orderNo);
if (left < 0) throw new Error('OUT_OF_STOCK');
return left;
}这一层解决"快",但它只是预占,不是最终扣减。缓存可能丢、可能被过期、异步落库可能失败,所以必须有下面两层兜底。
这里顺带澄清一个常见误区:不少团队的第一反应是"给库存键加一把分布式锁,把扣减串行化不就行了"。串行化确实能防超卖,但秒杀场景下所有请求争抢同一把锁、拿不到就排队或失败,锁的获取释放和等待重试会把吞吐压得很低,热点 SKU 依然会超时。Lua 原子预扣的优势在于:它既保证了"判断即扣减"的原子性,又因为 Redis 单线程顺序执行而无需显式加锁,单实例可以支撑十万级 QPS,把并发压力消化在缓存层,只把极少量真正下单成功的请求放到数据库。两者不是对立关系——缓存层追求高吞吐的原子预扣,数据库层用条件更新保证强一致底线,各司其职。
数据库是库存的最终真相,必须用条件更新把"判断+扣减"合成一条原子语句,靠数据库约束保证永远不会扣成负数,这是防超卖的最后闸门:
-- 条件更新:只有剩余库存足够时才扣减,affected rows=0 即库存不足
UPDATE sku_stock
SET available = available - #{qty},
locked = locked + #{qty},
version = version + 1
WHERE sku_id = #{skuId}
AND available >= #{qty};
-- 用 affected rows 判断:1=扣减成功,0=库存不足,必须回补缓存预占注意这里用的是 available = available - qty 配合 available >= qty 条件,而不是先查再改。配合行锁,同一 SKU 的并发扣减被串行化,但因为更新极快、不走"先查后改"的长事务,锁等待时间被压到最短。
下单链路的正确顺序是:缓存预扣成功 → 创建订单(待支付) → 数据库条件扣减 → 任一步失败则反向回补。支付超时未付款,也要释放 locked 库存,这就是定时关单回补:
def release_stock(sku_id, qty, order_no, redis, db):
# 先回数据库,再回缓存,顺序与扣减相反
ok = db.execute(
"UPDATE sku_stock SET available=available+%s, locked=locked-%s "
"WHERE sku_id=%s AND locked>=%s", qty, qty, sku_id, qty)
if ok:
redis.eval(
"local h=redis.call('hget',KEYS[2],ARGV[2]); "
"if h then redis.call('incrby',KEYS[1],ARGV[1]); redis.call('hdel',KEYS[2],ARGV[2]) end",
2, f"stock:{sku_id}", f"hold:{sku_id}", qty, order_no)哪怕前两层都做了,缓存宕机、消息丢失、进程崩溃仍会让两边短暂不一致。最终一致性靠定时对账兜底:周期性比对"缓存库存 + 已锁定未支付"与"数据库可用库存",发现差异按数据库真相修正缓存,并告警留痕。
对账项 | 计算口径 | 差异处理 |
|---|---|---|
可用库存 | DB.available 应等于 Redis stock | 以 DB 为准重置缓存 |
预占占用 | Redis hold 各field之和 应等于 DB.locked | 找漏落库/漏回补的订单补偿 |
异常悬挂 | locked 超过订单超时阈值仍未支付 | 触发关单释放 |
对账不是为了"平时好看",而是为了在故障发生后能自动收敛回一致,而不是等用户投诉超卖才发现。
问题:select 和 update 之间存在并发窗口,是超卖头号原因。
解决:数据库用 where available>=qty 条件更新;缓存用 Lua 原子脚本,判断扣减合一。
问题:缓存重启/过期/落库失败即不一致,直接超卖。
解决:缓存只做预占扛并发,数据库条件更新守底线,库存真相永远在 DB。
问题:库存被永久悬挂占用,越卖越少。
解决:任何一步失败都走反向补偿,支付超时由定时关单统一释放。
问题:先回缓存再回库,中间崩溃会让缓存虚高。
解决:扣减先缓存后DB,回补反向、先DB后缓存,以DB为收敛基准。
问题:流量进来时缓存还没库存数据,全部击穿到DB。
解决:活动开始前把参与SKU库存预热进Redis,按数倍峰值压测验证零超卖再放量。
库存超卖治理是三层配合:Redis Lua 原子预扣扛住并发、数据库条件更新守住不出现负库存的底线、定时对账补偿把故障导致的不一致自动收敛。判断一套方案是否可靠,就看三句话:扣减是否原子、真相是否唯一、故障后能否自愈。只优化其中一层都会留下超卖口子,三层闭环、再用压测验证,大促的库存才算真正稳。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。