1. 先把库存这本账算清楚三个字段背后是四层口径库存管理这个话题我在不同团队里见过太多次同样的开局产品经理说商品表里加个 stock 字段不就行了开发顺手加了一个 int上线三个月后开始出现明明显示有货下单却提示售罄和库存扣成负数两类工单同时飞来。问题从来不是代码写错了而是从一开始就没把库存到底是什么说清楚。电商交易系统里的库存管理与库存预警本质上不是一个字段的加减法而是一套账本体系加一套决策机制账本负责每一件货现在在哪、被谁占着决策机制负责什么时候该补货、补多少。这篇内容我按实际落地顺序拆开讲包括库存口径怎么划分、超卖的并发缝隙在哪、缓存与数据库怎么配合、预警阈值怎么算才不是拍脑袋以及大促前后容易翻车的细节适合正在做交易链路的后端、做供应链系统的研发也适合想搞明白为什么我们库存总对不上的产品同学。1.1 一个 sku_id 对应的库存从来不是一个数字新手最容易犯的错是把库存当成仓库里还有多少件这一个数。真实业务里至少同时存在四个口径而且它们必须能互相对上账口径含义谁在维护典型用途实物库存仓库物理上存在的数量WMS / 仓管盘点盘点、报损、对账基准可用库存可以被新订单占用的数量交易系统商品详情页、下单校验占用库存锁定已被下单但未履约出库的数量交易系统取消订单时回滚的依据在途库存已采购未入库的数量采购系统补货决策、预售可售量关键恒等式是可用库存 实物库存 − 占用库存 − 已冻结如质检、锁仓数量。很多人会再引入一个在途算进可售那是预售场景的玩法普通现货不建议混进来一旦混了客服会天天接到你们页面说有货结果发不出的投诉。我建议的做法是物理上拆两张表一张stock存实物与占用一张stock_in_transit存在途业务上通过视图或服务层组合而不是把五个数字塞进同一行记录里——塞进去之后任何一次并发更新都会牵扯出一堆字段互相覆盖的问题。1.2 下单减、支付减、发货减三种时机的真实代价这是库存管理里第一个必须做决策的地方而且没有最优解只有你能承受哪种代价。下单减库存用户提交订单瞬间就占用库存。好处是绝对不会超卖坏处是恶意占库、拍下不付款会把库存锁死需要配套的订单超时自动取消 库存回滚。支付减库存付款成功才扣。好处是转化率高、不会锁死坏处是大促时会出现付了钱没货发属于业务事故级别必须配超卖兜底方案。发货减库存实物出库才扣。适合自营仓、预售、定制类目因为发货前一切皆可变但页面可售量就得靠预估很容易失真。我参与过的几个交易系统最终都收敛到下单减库存 超时未支付自动取消回滚这条路上原因是它在用户体验和运营风险之间最平衡。但要注意一个细节回滚必须是幂等的订单取消、支付超时、风控拦截、退款这四个入口都可能触发回滚如果没做幂等库存会被加回去好几次于是出现库存越卖越多的灵异现象——这个坑后面第 3 节会专门讲。1.3 库存流水表所有对不上的账最后都靠它查我在任何系统里都会强制要求一张stock_flow流水表字段大致是id, sku_id, warehouse_id, biz_type, biz_no, change_qty, before_qty, after_qty, operator, create_time。每一次库存变动无论是扣减、回滚、盘点调整、报损都必须写一条流水且(biz_type, biz_no, sku_id)建唯一索引。这张表的价值不在业务功能而在出问题的时候。当运营说昨天这个 SKU 卖了 300 件库存怎么只掉了 280你唯一能靠的就是流水把当天的流水按 biz_type 分组求和立刻能看出是回滚多写了、还是盘点覆盖了。没有流水表的系统排查这类问题只能靠翻日志翻到天亮也未必能定位。我个人的经验是流水表的数据量会很大按天或按月分区保留 6 到 12 个月足够历史数据归档到冷存储即可。2. 超卖的根因不在代码在这三处并发缝隙几乎所有人都知道库存要防超卖但实际排查下来超卖往往不是发生在你以为的那一处。我见过最典型的三种成因一是先查后改的两步操作中间被人插了队二是缓存和数据库的扣减结果不一致三是同一笔订单被重复消费扣了两次或者回滚了两次。这三个问题的解法完全不同混在一起讨论只会越聊越乱。这一节先把并发扣减这条最核心的链路讲透。2.1 从查询后扣减到条件更新一行 SQL 的改造新手写法长这样// 错误示范查询与更新之间存在并发窗口 Stock stock stockMapper.selectBySku(skuId); if (stock.getAvailableQty() num) { stock.setAvailableQty(stock.getAvailableQty() - num); stockMapper.updateById(stock); }两个线程同时读到 1 件都判断通过都执行更新最终卖出 2 件。这就是最经典的超卖。改成条件更新把判断和减法压进一条 SQL让数据库的行锁来保证原子性UPDATE stock SET available_qty available_qty - #{num}, locked_qty locked_qty #{num}, version version 1, update_time NOW() WHERE sku_id #{skuId} AND warehouse_id #{warehouseId} AND available_qty #{num};然后判断affectedRows等于 0 就说明库存不足直接抛业务异常。这一行 SQL 里有两个点必须注意available_qty #{num}这个条件不能少它是防负库存的最后一道闸warehouse_id必须进 WHERE 条件否则多仓场景下会扣错仓。我见过因为漏了仓条件导致 A 仓卖空、B 仓明明有货却显示售罄的案例排查了半天以为是缓存问题。2.2 三种并发控制方案的适用边界条件更新不是唯一解实际选型要看并发量级和一致性要求方案实现方式优点代价适用场景数据库条件更新UPDATE ... WHERE qty n简单、强一致、无额外组件热点行锁竞争QPS 上限有限日常流量、单 SKU 并发 500乐观锁版本号WHERE version #{v}读多写少时冲突少高冲突下重试次数爆炸库存变动不频繁的标品Redis 原子扣减Lua 脚本 后续异步落库极高并发、响应快最终一致需对账兜底秒杀、大促、限量抢购必须说清楚一件事乐观锁在秒杀场景下是负优化。因为同一 SKU 的所有请求都读同一个版本号只有一个能成功其余全部重试重试再失败CPU 全耗在循环里。这种场景要么上 Redis 预扣要么在应用层做请求排队指望数据库扛是不现实的。2.3 一段可落地的 Redis 预扣减实现大促链路我一般用这样的组合Redis 存可售余量Lua 脚本保证原子性扣减成功后发消息异步落库同时写流水。-- deduct_stock.lua -- KEYS[1] stock:available:{skuId} -- ARGV[1] 扣减数量 local available tonumber(redis.call(GET, KEYS[1])) if available nil then return -1 -- 未预热 end local num tonumber(ARGV[1]) if available num then return 0 -- 库存不足 end redis.call(DECRBY, KEYS[1], num) return 1 -- 成功返回值约定成三态很重要-1表示 Key 不存在未预热或已过期这时候必须回源查数据库并回填绝不能当成库存无限放过去0表示不足1表示成功。我踩过的坑就是早期只判断了返回值是否大于 0结果 Redis 重启后所有 Key 丢失所有请求都返回 -1被当成了扣减成功瞬间超卖上万单。落库环节要注意异步落库意味着数据库的available_qty有延迟任何以数据库为准的查询都可能读到旧值。所以展示层的可售量在活动期间应该只读 Redis数据库只作为最终账本和对账基准两套口径不能混着用。3. 缓存与数据库的双写库存一致性的取舍表库存这个数据和普通商品详情完全不是一类东西。商品标题可以缓存十分钟用户看到旧标题不会出事库存缓存十分钟用户下单时才发现没货投诉电话就打进来了。所以库存的缓存策略必须单独设计不能复用通用的读多写少 过期兜底那套。这一节讲清楚缓存怎么参与扣减、异步落库之后怎么保证最终一致、以及回滚为什么比扣减更容易出事故。3.1 为什么库存不能用更新数据库再删缓存的老套路通用缓存方案是写库 → 删缓存 → 下次读回源。放到库存上会出问题删缓存之后大量读请求同时回源查库而数据库里那个值可能还没被异步扣减更新于是雪崩式地把旧值重新写回缓存造成库存回弹。更麻烦的是库存的读和写都极其频繁删除缓存的窗口里必然有并发。我在实际项目里的处理原则是三条扣减路径不与缓存双写混用。要么全程走数据库条件更新要么全程走 Redis 预扣 异步落库选一条别混。展示路径允许短暂偏差但要有兜底校验。详情页和购物车显示的可售量可以来自缓存但下单校验必须走权威扣减接口不能信任页面传上来的还有货。缓存过期时间要短且有主动刷新。我一般设 30 到 60 秒同时在下单成功后主动刷新对应 Key减少用户看到旧值的概率。3.2 异步落库的补偿链路怎么设计Redis 预扣成功之后用消息队列把扣减事件投出去消费者负责更新数据库并写流水。这条链路必须解决两个问题消息丢了怎么办、消息重复了怎么办。丢消息的兜底靠定时对账。每 5 分钟跑一次比对任务把 Redis 中的余量和数据库中实物 − 占用算出来的理论值对齐差异超过阈值就告警并落事件表人工介入。对账任务要按 SKU 分批处理避免一次扫全表把数据库拖垮。重复消费的兜底靠幂等键。每个扣减事件携带biz_no订单号流水表对(biz_type, biz_no, sku_id)建唯一索引重复插入直接捕获唯一键冲突并跳过。这比先查有没有处理过再插入可靠得多因为后者本身也有并发窗口。注意对账发现的差异不要自动修复。自动修复会掩盖真实的 bug我见过的案例是自动补正把一次严重的重复回滚给抹平了等到大促当天问题集中爆发才发现。差异应该告警 记录人工确认后走专用的调整接口调整本身也要写流水。3.3 回滚比扣减更容易出事四个入口一个都不能漏扣减只有一个入口下单回滚可能有四个用户主动取消、支付超时自动关单、风控拦截、售后退款。这四个入口如果没有统一的幂等控制就一定会出现重复加库存。我的做法是把回滚收敛到一个内部服务方法签名带上bizType bizNo内部先查流水表判断这笔业务是否已经回滚过没回滚过才执行UPDATE stock SET available_qty available_qty n, locked_qty locked_qty - n WHERE sku_id ? AND locked_qty n。这里同样有条件locked_qty n防止占用库存被扣成负数。同时要注意执行顺序先更新数据库再删 Redis 对应 Key或者把 Redis 余量加回去两个动作的失败要分别有重试和告警。还有一个隐蔽的坑部分退款该不该回滚库存。如果用户买 3 件退 1 件库存加 1 是对的但如果商品已经发出退回的货需要质检才能重新入库这时候应该加待检库存而不是直接加可售。这个区分必须在业务建模阶段就想好事后补极其痛苦。4. 库存预警不是设个阈值就完事从静态水位到动态补货点库存预警这块我见过最多的实现是库存低于 10 就发钉钉通知。上线第一周很新鲜第二周开始被无视第三周就被屏蔽了。原因很简单固定阈值无法适配不同 SKU 的销售速度。日销 500 件的爆款库存剩 10 件才开始预警等货到早就断货半个月了日销 1 件的长尾品库存剩 10 件能卖十天天天被预警只会造成噪音。真正有用的库存预警核心不是低于多少报警而是什么时候必须下单采购。4.1 三种预警算法各自的适用面算法计算方式适合的品类主要缺陷固定水位库存 N 时告警起订量固定、销量稳定的标品无法适配销售波动N 拍脑袋安全库存法安全库存 Z × σ × √L有历史销量数据、波动可量化需要维护补货周期 L新品无数据日均消耗法预警线 日均销量 × (采购周期 缓冲天数)大部分现货品类促销期会低估需要活动修正系数实践中我建议把三者组合使用以日均消耗法作为主逻辑用安全库存做下限兜底用固定水位做绝对红线。也就是说最终的预警线取三者中的最大值这样既不会因为日均销量掉到 0 而完全失去预警也不会因为固定阈值太低而在爆款上失效。4.2 安全库存与补货点的完整算法与算例先把公式列清楚这两个数不能混安全库存 SS Z × σ_d × √L其中 Z 是服务水平对应的系数95% 服务水平取 1.6599% 取 2.33σ_d 是日销量的标准差L 是采购提前期天。补货点 ROP 日均销量 d̄ × L SS。建议补货量 Q 目标覆盖天数 × d̄ − 当前可用库存 − 在途库存。举个具体算例某 SKU 过去 30 天销量为[12, 15, 9, 20, 18, 11, 14, 16, 13, 17, ...]算出来日均 d̄ 14.5 件标准差 σ_d ≈ 3.6 件采购提前期 L 7 天取 95% 服务水平 Z 1.65。SS 1.65 × 3.6 × √7 ≈ 1.65 × 3.6 × 2.646 ≈ 15.7取整 16 件。ROP 14.5 × 7 16 ≈ 117.5取整 118 件。也就是说当可用库存 在途库存降到 118 件以下时就该启动采购了。如果目标覆盖 30 天当前可用 60 件、在途 0则建议补货量 30 × 14.5 − 60 − 0 375 件再按供应商起订量比如 50 的倍数取整到 400 件。用 Python 跑这套计算很直接import math import statistics def calc_reorder_point(daily_sales, lead_time_days, service_level_z1.65, cover_days30, available0, in_transit0, moq50): d_bar statistics.mean(daily_sales) sigma statistics.pstdev(daily_sales) if len(daily_sales) 1 else 0.0 safety_stock service_level_z * sigma * math.sqrt(lead_time_days) rop d_bar * lead_time_days safety_stock suggest_qty cover_days * d_bar - available - in_transit if suggest_qty 0: return {rop: round(rop), safety_stock: round(safety_stock), suggest_qty: 0} # 按最小起订量向上取整 suggest_qty math.ceil(suggest_qty / moq) * moq return {rop: round(rop), safety_stock: round(safety_stock), suggest_qty: int(suggest_qty)}提示新品没有历史数据时 σ 算不出来不要用 0 顶替那会让安全库存归零等于没有缓冲。常见做法是取同类目同价格带的平均 σ 作为冷启动值跑满 14 天再切换到自身数据。4.3 预警触达去重、抑制、分级、升级算法算出来的预警线只是该报警了能不能让人看到、看到之后会不会行动靠的是触达设计。我把这块分成四层去重同一 SKU 同一预警等级24 小时内只推送一次用一个(sku_id, level, date)的 Redis Key 做标记。抑制如果 SKU 已经在采购流程中存在未完结的采购单同等级的预警静默处理只进日报不实时推送。这一条极其重要否则采购下完单之后每天被同样的预警轰炸人就会麻木。分级按剩余可售天数 可用库存 / 日均销量分级比按绝对数量分级合理得多。等级判断条件触达方式响应要求蓝色剩余可售天数 ≤ 覆盖目标天数进日报本周内确认黄色剩余可售天数 ≤ 采购周期 7群消息24 小时内下单红色剩余可售天数 ≤ 采购周期群消息 电话立即处理黑色可用库存 ≤ 0 且有在售订单升级到负责人应急调拨或下架升级黄色超过 24 小时未处理自动升红红色超过 12 小时未确认升级到上一级。升级逻辑要配确认人字段谁点了确认谁负责不然预警永远悬着没人认领。我还建议加一个反向预警库存超过 90 天都卖不完的 SKU 也要提示这是资金占用和滞销风险。很多团队只做缺货预警不做滞销预警结果仓库堆满卖不动的货采购还在按公式补货。5. 大促前后的库存隔离与削峰实践日常流量下跑得通的方案放到大促当天大概率会崩。原因不在于代码有没有 bug而在于流量形态完全变了平时一个 SKU 每秒几个请求活动开始瞬间可能几万个请求压向同一个库存行。这一节讲三件在大促中真正救命的事库存分池、预热与限流、以及超卖之后的兜底处理。5.1 活动库存与共享库存必须物理隔离只要做过一次大促就会明白活动库存和生活库存共用一个池子是灾难。活动卖超了直接影响日常销售日常订单占用了库存活动承诺的量就兑不出来。我的做法是在stock表里按warehouse_id stock_type做维度扩展stock_type区分NORMAL日常和ACTIVITY活动活动开始前从日常池划拨一批过去形成独立的可售量。划拨要记录流水活动结束后剩余量要回拨回拨同样要幂等。另外必须配置限购规则单用户限购 N 件在 Redis 里用INCR计数实现超过就拒绝。没有限购的限量活动货基本都会被少数账号扫走这个不做前端的各种防刷都是白费。5.2 预热、限流、排队三件套的组合顺序大促的标准链路我一般这样组织活动开始前 30 分钟预热把活动 SKU 的可用量写入 RedisKey 带上活动 ID同时记录预热时间和预期量方便事后核对。入口限流在网关层按用户维度和全局维度双限流全局 QPS 上限设为预估峰值的 1.2 倍超出直接快速失败返回活动太火爆。这里宁可快速失败也不要让请求堆积在队列里超时超时重试会放大流量。服务端排队对超热 SKU可以用本地队列或者分布式队列做一次请求串行化消费速率控制在数据库能承受的范围内消费时再执行 Redis 预扣。异步落库与对账前面讲过不再重复。一个容易被忽略的点是限流阈值要按 SKU 维度而不是只按接口维度。热门 SKU 的请求量可能是冷门 SKU 的上百倍如果只做接口级限流一个爆款就能把整个下单接口的额度吃光其他商品全被误伤。5.3 超卖发生之后的处理预案必须承认一个现实任何方案都有极小概率超卖。真正专业的团队不是号称我们绝不超卖而是提前写好超卖之后的处理预案。我建议预案分三层第一层自动识别。订单创建但库存扣减失败的记录进一张异常单表立刻告警。不要等到发货环节才发现没货。第二层优先级排序。超卖的订单按支付时间排序先付先得后面的走协商。协商话术、补偿方案换货、优惠券、全额退要在活动前就和客服对齐不能临时讨论。第三层责任归因。每次超卖都要复盘到具体原因是预热数据算错了、还是对账没跑、还是回滚重复执行。不复盘的团队会年年犯同样的错。6. 上线之后真正会咬人的几个细节方案设计得再漂亮落到生产环境还是会被细节咬。这一节把我自己踩过、也见过别人踩的几个点列出来都属于文档里不会写、但一定会遇到的类型。第一个时区与定时任务的时间漂移。预警任务一般按天跑如果服务器时区和业务时区不一致会出现昨天算的预警线今天才生效的问题促销前一天特别致命。我的习惯是所有业务时间统一存 UTC 时间戳展示层再转本地时区定时任务显式指定时区别依赖运行环境默认值。第二个库存调整接口没有权限分级。盘点调整、报损、强制置零这些接口如果和生产环境的订单接口共用一个权限模型出事只是时间问题。我给这类接口单独做了审批流调增量超过 100 件的必须二级审批所有调整必须填原因码原因码进流水。这看起来很重但它是防止内部误操作的最后一道墙。第三个Redis 内存淘汰策略把库存 Key 淘汰了。这是我最惨痛的一次。库存 Key 没有设过期时间但 Redis 配的是allkeys-lru内存压力上来之后把库存 Key 淘汰了于是扣减脚本返回-1Key 不存在如果代码没处理这个分支就等于放行。修法有两步库存 Key 单独分配实例或者用独立 DB淘汰策略改成noeviction同时代码里-1必须走回源逻辑绝不能当成功。第四个浮点数算库存。有些品类按重量卖库存存成decimal没问题但如果代码里用double参与计算0.1 0.2 ! 0.3会导致对账永远差一点点天天报差异。金额和重量一律用整数最小单位或者BigDecimal这条没有例外。第五个预警消息没有区分环境。测试环境的预警任务连的是生产 IM 群机器人一跑压测就往群里发几百条库存告急运维直接把机器人禁言顺带把生产预警也屏蔽了。配置里必须强制带环境标识测试环境的消息发到测试群这个开工第一天就该做。第六个历史数据迁移时的库存快照。系统重构迁移库存表时光迁当前值是不够的必须同时迁占用库存和对应的未完结订单。我见过只迁了可用量、没迁占用量的迁移上线后所有在途订单取消时都回加了一次库存凭空多出一批货。第七个对账任务本身把数据库打挂。全表扫描加实时比对在数据量上百万之后会在业务高峰期拖慢主库。解决办法是按更新时间增量对账 分片并行并且只对当天有变动的 SKU做比对没变动的跳过。这个优化做完对账任务从 20 分钟压到 90 秒。我个人的体会是库存管理与库存预警这套东西难的地方从来不是某个算法或者某段代码而是口径统一和责任闭环。口径不统一四个部门会拿着四个数字开会吵一整天责任不闭环预警发了没人认领采购下了单没人跟踪到货。把账本建清楚、把流水记全、把预警落到具体的人头上剩下的技术问题都是可以解决的。