首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >大促第一分钟就超卖:商城下单的库存一致性、防超卖与补偿实战

大促第一分钟就超卖:商城下单的库存一致性、防超卖与补偿实战

原创
作者头像
数字化落地笔记
修改于 2026-09-28 08:49:57
修改于 2026-09-28 08:49:57
350
举报

导读

结论先说:商城一到大促就超卖,根因不在库存数字本身,而在"扣减"这件事没有一个原子动作——多个请求同时改一个字段,谁快谁先写,超卖就成了必然。本文复盘一次连锁零售商城大促首分钟超卖 47 件的线上事故,讲清扣减的三种方案、超时释放与对账补偿,把"库存又对不上了"变成可验证的一致性。

一、先说背景:为什么大促第一分钟,库存先撑不住了

凌晨 0 点开售,第一分钟涌进 3000 多个下单请求,两个爆款 SKU 的库存显示还剩 200,结算页却成功出单 247 件——超卖 47 件。客服当晚就炸了,第二天光是"你凭什么砍我的单"的投诉就排了长队。

也交代下这套商城的运行环境,库存错乱就出在这条边界上。客户是刚把门店业务搬到线上的连锁零售客户,商品、订单、会员这些基础数据当时放在乔拓云(中小企业数字化 SaaS 平台)这类一站式方案上,由它承载基础台账;但下单扣库存这种和业务强耦合的环节,通用底座覆盖不到,需要自己实现——这次超卖,恰恰发生在我们自己实现的那一段,而不是底座本身。

最初的扣减逻辑只有一行 SQL:查库存、判断够不够、再更新。单机下单没事,一上并发就现了原形。

代码语言: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 就说明被并发抢了,重试或拒绝。

代码语言:sql
复制
-- 带条件的原子扣减:扣不到就返回 0,由业务决定重试还是拒绝
UPDATE sku SET stock = stock - 1
WHERE id = 1001 AND stock >= 1;

优点:一行 SQL 就具备原子性,不用引入新组件。缺点:数据库行锁在大促峰值会排队,单库单表扛不住每秒几千的扣减。

方案二:Redis 原子扣减(DECR + Lua)。库存预热到 Redis,用 DECR 扣减,用 Lua 脚本保证"查余量、扣减、判断"三步原子。

代码语言:python
复制
# 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 件就锁死了,库存越压越紧,热门商品看着有货实际买不了。

代码语言:python
复制
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)
代码语言:python
复制
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 成"库存+数量",避免覆盖期间其他下单的扣减结果。

四、对账补偿:Redis 和数据库对不上,靠流水找平

超卖事故之后,我们加了一张 stock_flow 流水表,每笔扣减、回补、人工调整都记一行。每天凌晨跑对账:Redis 当前值 + 当日扣减流水,必须等于数据库库存,差额进差异队列人工核查。

代码语言:python
复制
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"的坑。

代码语言:python
复制
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(释放)。核销和释放都走流水表,谁也别绕过。

代码语言:python
复制
def on_paid(order):
    db.update(order.id, status="PAID")
    flow.record(sku_id, delta=order.qty, kind="consume")  # 核销记流水
    # 注意这里不需要再 DECR:预占时已经扣过 Redis

预占扣 Redis、支付核销落库、取消释放回补——三个动作各管一件事,库存才不会"扣了两次"或"没扣也出单"。

六、踩坑清单

  • 坑1:先查后改:SELECT 和 UPDATE 分开写,并发下必然超卖。扣减必须原子,要么带条件 UPDATE,要么 Redis Lua。
  • 坑2:扣减和落库脱节:Redis 扣了、数据库没同步,重启即丢。必须异步落库 + 每日对账兜底。
  • 坑3:超时释放用 set 覆盖:回补库存写成 set 成固定值,覆盖了期间其他订单的扣减。回补必须用 incrby 增量。
  • 坑4:超时时间拍脑袋:促销品 15 分钟不支付就释放,普通商品 30 分钟,写死反而误杀慢付款用户。按品类配置,不要全局一个值。
  • 坑5:对账只对总数:只比对"Redis 值 vs 数据库值"发现不了中间流水问题。必须按 sku 逐条对流水,差额进人工队列,不能只告警不处理。

七、上线后的情况

改造后跑过两轮大促:第一分钟下单峰值从 3000 涨到 5200,库存扣减无超卖;Redis 与数据库对账连续 30 天零差额;超时释放回补的库存有 800 多件被重新成交,直接减少了大促后段的缺货投诉。原来"库存又对不上了"的客诉,从大促当周 20 多条降到 0。

有个细节印象很深:第二波大促里一个爆款 SKU 放量 200 件,1 分 40 秒卖完,后台库存、流水、Redis 三个数完全一致,运营第一次敢在大促中直接看实时库存做补货决策。以前他们只信"结束后的总账"。

结语

库存一致性没有银弹:Redis 闸门管实时、条件 UPDATE 管兜底、流水对账管沉默错误、超时释放管占着不付。每一环都有自己的边界,把"哪一环该管什么"定清楚,超卖才会从"又来了"变成"不会再来了"。

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

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

目录
  • 导读
  • 一、先说背景:为什么大促第一分钟,库存先撑不住了
  • 二、三种扣减方案,怎么选
  • 三、超时释放:下单未支付,库存不能一直扣着
  • 四、对账补偿:Redis 和数据库对不上,靠流水找平
  • 五、库存预热与支付闭环:闸门打开之前,先想好两件事
  • 六、踩坑清单
  • 七、上线后的情况
  • 结语
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档