3.2 库存扣减:原子操作防超卖

场景

超卖是电商系统最经典也最要命的事故,离我们最近的一次发生在上新当天。那天上了一只限量匠人杯,库存 50 只,定价 399。十点开售,朋友在群里吆喝,两分钟内涌入 100 多笔订单。下午她高高兴兴去发货,导出单子一数——卖出 63 只,库存还剩 0。多出来的 13 只赔不起,但她自己掏腰包挨个打电话道歉,那天她嗓子都哑了。

事后我一笔笔查日志,问题清楚得像教科书:并发。高并发请求同时读到库存“还有”,各自扣减各自写回,经典的读后写竞态。我们当时用的查询再更新的写法,相当于把库存值从内存读出来、减一、再存回去,两个请求同时读到相同的值,写回时就互相覆盖了。形象点说,就是两个收银员同时看了一眼抽屉,都以为里面够找零。那天赔付加道歉折腾到半夜,朋友最后说了句我记很久的话:技术上的省事,最后都是生意上的破事。从那天起,库存模块在我们这获得了“永不妥协”的优先级。

另一个相关但方向相反的问题同样磨人:误伤。发现超卖风险后,有段时间我们简单粗暴地加了“库存不足就直接弹错”的拦截,结果正价商品在正常流量下也被误判,正常顾客下单失败,转化率当天掉了八个点。库存模块的追求就两条:不超卖、不误伤,两者都做到才算合格。此外还有预占库存的设计——下单后 15 分钟未付款自动释放,这部分我们放在订单状态机那一节细讲。

方案

防超卖的方案对比过四种,各自的适用面不一样:

方案 原理 优点 缺点
查后更新 先读再写 实现最简单 并发必超卖
数据库原子inc inc(-qty) 一步到位 简单可靠 不够时 inc 会变负
条件更新 where stock>=qty + inc 原子且防误判 需要重试逻辑
事务 先查后扣包在事务里 语义完整 写法复杂、有性能开销

我们最终用的是条件更新加原子 inc 的组合拳:一条语句搞定判断和扣减,数据库层面的原子操作天然防并发。核心写法是 where 条件里带 stock >= qty,再 inc(-qty),一条指令同时完成“够不够”和“扣多少”。如果库存不足,stats.updated === 0,前端据此提示库存不足。这套方案的好处是不需要锁、不需要事务,单条文档更新在云数据库层面是原子的。

还有一个设计要点:库存字段分散到 sku 文档里(stock 字段),而不是放在 spu 上。不同规格的杯子各自独立库存,这与 2.1 节的集合设计一脉相承。扣减时只锁一个 sku 文档,不影响其他规格的售卖,并发粒度细。

再往深一步,成熟一点的设计会把库存拆成两个字段:stock(实物库存)与 avail(可售库存)。下单锁定时 avail 减、支付成功后 stock 真减、超时释放 avail 加回来。我们这本书的项目规模还用不上这么细的分层,但限量抢购那 15 分钟里,类似的思路救过场:抢购期间临时给 avail 做了预占隔离,付款超时的库存回流进下一波,而不是让库存一直被无效订单吊着。设计要跟着业务规模走,过度设计和设计不足都是病。判断标准很朴素:先看一笔订单自己亏不亏得起,再看一周出几次库存事故,两头一碰,该分层的自然就分层了。

兜底机制同样重要:即使前端拦截了,最终防线一定在云函数。前端提示只是体验优化,任何绕过前端的请求(比如旧版小程序、抓包重放)都必须被服务端拦下。我们所有的库存校验都在 createOrder 云函数里做,前端的提示是“建议”,云函数的拦截是“法律”。

除了扣减,库存还有一个常被忽略的侧面:盘点对账。每天的库存快照和订单流水对不上是常态,原因是流水分散在多个环节(锁定、释放、退款回补)。我们的做法是每天凌晨跑一次对账脚本,把 stock 当前值与流水累加值比对,差异超过阈值就在企业微信里告警。这套对账机制在 5.2 节会展开,这里先埋个引子:库存不是扣完就完的事,它需要持续被审计。

配置/代码

核心扣减逻辑就一段:

// createOrder 云函数片段:原子扣减库存
const _ = db.command;
const r = await db.collection('skus').doc(skuId).update({
  data: { stock: _.inc(-qty) },
  // 关键:条件更新,库存不足时 updated 为 0
  where: { stock: _.gte(qty) }
});
if (r.stats.updated === 0) {
  return { code: 'STOCK_OUT' }; // 库存不足,前端据提示
}

注意 where 与 update 组合的条件更新写法在不同 sdk 版本里有差异,老版本要用先 where 再 update 的链式形式。我们的做法是 sdk 版本对齐加真机压测,别等上线了才发现写法不支持。

同一 sku 多个数量时还有一种边界:并发里两个请求一个买 3 一个买 2,库存剩 4。条件更新会保证只有一个成功(3>2 不成立,2 成立),另一个收到 STOCK_OUT 后可以走重试逻辑。重试要在云函数里做,不能让前端轮询,否则请求放大更难控。

下单流程完整顺序是:校验参数 → 原子扣减库存 → 写订单 → 调起支付。扣减在前、订单在后,这中间任何一步失败,前面已扣的库存要回补。回补同样是原子操作(inc 加回去),并记一条流水日志便于对账:

// 库存变更流水,便于事后追溯
await db.collection('stock_log').add({
  data: { skuId, qty: -qty, orderId, type: 'lock', ts: Date.now() }
});

下单失败时的回补要写成幂等逻辑——同一个 orderId 无论回补多少次,库存只加一次。做法是回补前先查 stock_log 里有没有该 orderId 的回补记录,没有才执行。不幂等的回补遇上网络重试,库存会像变魔术一样凭空多出来,这种偏差对账时才发现,查起来极痛苦。这件事我们在 3.4 节订单状态机里还会以“关单回补”的形式再遇到一次,是同一类问题的不同侧面。

踩坑

第一个坑就是前面那场超卖事故本身。查后写改成条件更新后,我们专门做了一次压测:50 并发同时对库存 10 的 sku 下单,最终成功订单恰好 10 笔,多一笔都没有。这个压测脚本我们保留了下来,每次改动库存逻辑都跑一遍,成了镇店之宝。顺带说一句,压测别在生产环境跑,用第二个云环境(1.3 节搭的 dev 环境) mock 一批测试 sku,安全又放心。

第二个坑是 inc 扣成负数。早期版本只写了 inc 没写条件,库存 0 的时候请求还能扣成 -3,物理意义上卖出了不存在的货。条件 updated 检查就是堵这个口子,缺一不可。

第三个坑是库存字段和显示不同步。详情页显示“余 12 件”是读的 sku 文档,扣减是另一个时间点,顾客看到 12 件但点下去被告知没货,体验割裂。接受现实:显示永远是近似值,下单才见真章。文案上把“余 12 件”改成“库存紧张”这类模糊表达,留出缓冲余地。更好的做法是显示一个保守值(实际库存减去最近一分钟的下单量),让显示与可售之间留出安全距离,这是从一次顾客投诉里学来的:她刚看到还有 1 件,点下去就没了,说我们“逗她玩”。

第四个坑是退款库存回补顺序颠倒。有次退款流程先退款后回补库存,中间网络抖动,库存没回补但钱退了,平白少了货。规定动作:先回补库存成功、再执行退款,次序不能颠倒,并且两步都记流水。这事还提醒我们:涉及到钱和货的两步操作,永远是“先货后钱”或设计成可回滚,中间态要可观测、可补救。

第五个坑是活跃 sku 的高并发把云函数连接打满。限量款秒杀场景下,我们在 sku 层面加了短时间的请求整形:同一开户频次限制,超出直接排队提示。用最简单的方式保护后端,比硬扛优雅。

第六个坑是库存为 0 的商品仍可在购物车里下单。购物车里的快照 sku 可能已售罄,结算时没二次校验,订单照样生成。结算前必须再跑一遍条件更新,与库存详情里的提示保持一致的拦截口径,两头都得硬。

第七个坑是压测脚本本身埋的雷。早年压测用同一个 openid 循环下单,被频次拦截挡住,压测结果好得离谱,差点让一个有缺陷的版本上线。压测脚本要用多账号、贴近真实分布,否则等于自己骗自己。

小结

库存扣减这节的灵魂就一句话:把并发问题交给数据库的条件更新,别在业务代码里自己加锁。上线之后我们又跑过几十次大大小小的促销,最多一次 300 并发抢 30 只杯子,订单数严丝合缝,再也没有超卖过。朋友说现在搞活动她终于敢在群里喊“就 30 只,手慢无”——喊得越狠,背后这套机制就得越硬。另外提醒一点:云函数的并发和超时都有上限(可参考 1.4 节的限制表),抢购规模真上来了,前端整形和频次拦截要同步跟上,不能让库存逻辑单打独斗。在做秒杀类活动时,可以参考秒杀系统实战里对流量整形和层级拦截的完整设计。下一节进入整条链路最关键的一环:微信支付怎么接、回调怎么验签。

延伸阅读