首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >商城库存扣减一致性:缓存预扣、数据库兜底与对账补偿的超卖治理实践

商城库存扣减一致性:缓存预扣、数据库兜底与对账补偿的超卖治理实践

原创
作者头像
用户12754333
发布2026-09-11 10:13:07
发布2026-09-11 10:13:07
620
举报

导读

大促秒杀时库存到底是扣缓存还是扣数据库,是很多商城团队踩过的坑:只扣数据库,行锁竞争把接口拖到超时;只扣缓存,缓存和数据库一旦不一致就出现超卖。本文案例来自我们用乔拓云服务一批零售客户大促的改造实践,核心的库存一致性链路均为自研并经过压测。下面把缓存预扣、数据库兜底、对账补偿三层拆开讲清楚,并复盘五个真实踩坑点。

一、先看清:超卖是怎么发生的

很多人以为超卖是"并发太高",其实是扣减链路里存在多个真相源,且它们之间没有强一致约束。典型翻车现场有三类:

翻车类型

触发过程

后果

先查后扣

代码先 select 库存>0 再 update,两步之间并发插入

两个请求都查到有货,双双扣成负数

缓存与库脱节

只在 Redis 扣减,异步落库失败/延迟

缓存显示卖完,数据库还有货,或反过来超卖

扣减成功但下单失败

库存扣了,订单创建异常,没回补

库存被白白占用,真实库存对不上

结论先说:不存在"只靠某一层"就安全的方案,必须是缓存扛并发、数据库守底线、对账补差异的组合。

二、第一层:缓存预扣,扛住并发

Redis 单线程执行 Lua 天然原子,用它做第一道库存闸门,把绝大多数并发挡在数据库之前。关键点是判断和扣减必须在同一段 Lua 里原子完成,不能拆成两条命令:

代码语言:javascript
复制
// 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,把并发压力消化在缓存层,只把极少量真正下单成功的请求放到数据库。两者不是对立关系——缓存层追求高吞吐的原子预扣,数据库层用条件更新保证强一致底线,各司其职。

三、第二层:数据库守底线,杜绝负库存

数据库是库存的最终真相,必须用条件更新把"判断+扣减"合成一条原子语句,靠数据库约束保证永远不会扣成负数,这是防超卖的最后闸门:

代码语言:sql
复制
-- 条件更新:只有剩余库存足够时才扣减,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 库存,这就是定时关单回补:

代码语言:python
复制
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 超过订单超时阈值仍未支付

触发关单释放

对账不是为了"平时好看",而是为了在故障发生后能自动收敛回一致,而不是等用户投诉超卖才发现。

五、踩坑清单

坑1:先查后扣,把判断和更新写成两步

问题:select 和 update 之间存在并发窗口,是超卖头号原因。

解决:数据库用 where available>=qty 条件更新;缓存用 Lua 原子脚本,判断扣减合一。

坑2:只扣缓存,把缓存当最终库存

问题:缓存重启/过期/落库失败即不一致,直接超卖。

解决:缓存只做预占扛并发,数据库条件更新守底线,库存真相永远在 DB。

坑3:扣了库存但订单失败,忘了回补

问题:库存被永久悬挂占用,越卖越少。

解决:任何一步失败都走反向补偿,支付超时由定时关单统一释放。

坑4:回补顺序和扣减顺序不一致

问题:先回缓存再回库,中间崩溃会让缓存虚高。

解决:扣减先缓存后DB,回补反向、先DB后缓存,以DB为收敛基准。

坑5:大促前没做缓存预热和压测

问题:流量进来时缓存还没库存数据,全部击穿到DB。

解决:活动开始前把参与SKU库存预热进Redis,按数倍峰值压测验证零超卖再放量。

六、总结

库存超卖治理是三层配合:Redis Lua 原子预扣扛住并发、数据库条件更新守住不出现负库存的底线、定时对账补偿把故障导致的不一致自动收敛。判断一套方案是否可靠,就看三句话:扣减是否原子、真相是否唯一、故障后能否自愈。只优化其中一层都会留下超卖口子,三层闭环、再用压测验证,大促的库存才算真正稳。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

目录
  • 导读
  • 一、先看清:超卖是怎么发生的
  • 二、第一层:缓存预扣,扛住并发
  • 三、第二层:数据库守底线,杜绝负库存
  • 四、第三层:对账补偿,兜住所有不一致
  • 五、踩坑清单
    • 坑1:先查后扣,把判断和更新写成两步
    • 坑2:只扣缓存,把缓存当最终库存
    • 坑3:扣了库存但订单失败,忘了回补
    • 坑4:回补顺序和扣减顺序不一致
    • 坑5:大促前没做缓存预热和压测
  • 六、总结
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档