库存扣减为什么会出现超卖?
因为扣减本质是「先读后写」:先查询库存判断是否足够,再执行减一。两个请求并发时可能都读到剩余 1 件、都判断为够、都执行减一,最终产生超卖。这不是某段代码写错,而是共享资源被并发读写时的必然结果,需要在并发模型层面显式处理。
超卖往往不是某段代码写错,而是共享资源被并发读写时的必然结果。这篇文章从并发模型的三个层次出发,讲清库存扣减的策略取舍、分布式锁的误用、幂等键设计与对账补偿,给出一条从「防超卖」到「可幂等」的完整落地路径。
很多团队第一次遇到超卖时,第一反应是「哪里写错了」。但如果把超卖当成一次偶发的代码错误去修,往往修不掉——过一段时间促销再起,同样的现象又会出现。原因在于:超卖不是某个函数写错,而是库存这个共享资源被并发读写时的必然结果,只有从并发模型层面处理,才能真正解决。
库存扣减的本质是「先读后写」:先查询当前库存,判断够不够,再把库存减去。这个操作在单请求下单时完全正确,一旦两个请求同时进来,经典的交错就会发生:两个请求都读到剩余 1 件,都判断「够」,都执行减一,最终库存变成 -1 或两个订单同时成立。
这不是靠加班排查能解决的,也不是换个数据库就能自动消失的。它属于并发编程的基础问题,需要显式地去设计。这篇文章把库存扣减从「为什么会超卖」到「怎么做到既可卖又幂等」的完整链路拆开,供正在做电商、零售、票务、预约类系统的团队参考。
如果你的团队正处在交易链路的改造期,欢迎先看看我们在 软件研发服务 与 工程与质量保障 上描述的做法,再对照本文检查自己的实现。
在处理超卖之前,需要先分清并发到底发生在哪一层。很多方案失效,是因为解决的层次和问题的层次不一致。
同一台应用服务器上,多个线程同时处理请求。这一层的并发可以用本地锁(如 Java 的 synchronized、ReentrantLock)解决,成本低、性能好。但它的前提是「只有一个进程在扣同一份库存」,而这个前提在分布式部署下不成立。
应用部署了多个实例,负载均衡把请求分发到不同机器。这时本地锁完全失效——两台机器各自持有自己的锁,谁也拦不住谁。必须引入跨进程的互斥机制,例如基于数据库行锁、基于 Redis 的分布式锁,或者干脆用原子的数据库更新来避免锁。
为了提高读性能,库存常被放在 Redis 里。这就出现了新的不一致:缓存里显示有货,数据库里其实已经被别的链路扣完。这一层的并发不是「多线程同时减」,而是「缓存与数据库两个真相源之间的同步」。
三层里最容易出事故的是第二层和第三层,因为它们在开发机上的单实例环境里根本复现不出来。这也是为什么压测必须在接近生产的集群拓扑下做。
库存扣减没有「一种银弹」,只有「在不同的并发量、一致性要求和实现成本之间做取舍」。下面是四种常见做法。
不要把「查询 + 判断 + 更新」拆成三步,而是压缩成一条带条件的原子语句:
`` UPDATE stock SET available = available - 1 WHERE sku_id = ? AND available >= 1; ``
然后判断这条语句的影响行数:如果返回 1,说明扣减成功;返回 0,说明库存不足。关键是把「判断够不够」这件事交给数据库在同一条语句里完成,借助行锁保证原子性,中间没有给其他事务插入的窗口。
这种做法实现简单、语义清晰、天然正确,绝大多数中小规模系统用它就够了。代价是并发量高时会对同一行产生锁竞争,吞吐受限。
把一条 SKU 的库存拆成 N 份,例如 1000 件拆成 10 段各 100 件,每个请求按某种规则(比如哈希用户 ID)落到某一段上扣减。这样锁竞争从 1 行扩散到 10 行,吞吐提升。
代价是复杂度上升:某一段扣完但其他段还有货时,需要处理「跨段兜底」;库存分布不均时会出现「有的段早早售罄、有的段还剩很多」。分段是典型的「用复杂度换吞吐」,只有当单行确实成为瓶颈时才值得引入。
在 Redis 里做原子扣减(DECR、Lua 脚本或 INCRBY 判断结果),扣成功后再异步同步到数据库。读性能好、吞吐高。
代价是可靠性风险:Redis 可能宕机、异步落库可能失败,一旦对不上就会出现「Redis 说卖完了但数据库还有」或反之。因此这种做法必须配合:库存预热与定期对账、Redis 持久化策略、以及一条可重放的补偿链路。它适合秒杀这类「短时极端峰值」的场景,不适合作为交易主链路的主要依据。
把下单请求先投入队列,由消费者串行处理库存扣减。用串行化消灭并发,逻辑最简单。
代价是响应延迟上升、用户体验从「即时」变成「排队」,并且必须有清晰的「排队中/失败」状态反馈。它适合秒杀,但作为日常下单链路会牺牲体验。
| 做法 | 一致性 | 吞吐 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库原子更新 | 强 | 中 | 低 | 常规电商、预约、票务 |
| 分段库存 | 强 | 高 | 高 | 单热门 SKU 高并发 |
| Redis 预扣减 | 最终一致 | 很高 | 高 | 秒杀、抢购 |
| 队列串行 | 强 | 中 | 中 | 峰值削峰、限定名额 |
多数系统的现实选择是:主链路用数据库原子更新保证正确,峰值用队列或缓存做削峰与加速,两者边界清晰。不要用缓存扣减去替换数据库扣减,而是让它作为前置的减压层。
提到并发,很多人的第一反应是「加锁」。但如果锁用得不对,反而会引入更隐蔽的问题。
用一把全局锁去扣所有商品,等于把所有并发串行化,性能崩掉、又容易超时。锁的粒度应当与资源粒度对齐——锁「SKU 维度」,而不是锁「库存表」。
锁没有超时时间,一旦持锁进程崩溃,锁会永远不释放,整个商品再也卖不出去。锁没有持有者标识,就可能释放掉别人持有的锁。正确做法是:给锁设置合理超时,并让「释放锁」的动作校验持有者,保证只释放自己加的那把。
既然数据库的一条带条件更新就能保证原子性,很多时候根本不需要外部分布式锁。能用单条原子语句解决的,不要用分布式锁——锁引入网络往返、超时、误删等一整套新问题。锁应当留给「必须跨多条记录或多个系统保持互斥」的少数场景。
超卖是「减多了」,幂等要解决的是「同一笔意图执行了多次」。这两件事经常一起出现:一个下单请求超时后客户端重试,服务端其实已经扣过一次库存、建过一次订单——这时库存没错,订单却多了一笔。
幂等的核心是给每个「业务意图」分配一个全局标识(幂等键),并在执行前判断这个意图是否已经处理过。要点如下:
一个务实的顺序是:先做幂等,再谈并发优化。幂等没做之前加多少锁都不安全,因为重试引入的重复本身就是超卖的一种来源。
很多超卖争议的根源,是「库存」这个词本身含义不清。工程上通常需要把它拆成至少三个量:
额外的还有「已锁定待支付库存」「在途库存」「安全库存」等。为什么要拆这么细?因为不同业务节点动的是不同的量:下单时增加占用、支付时确认锁定、取消时释放占用、发货时扣减实物。只有把每个状态变更对应的量定义清楚,才能在出现对账差异时说得清「差在哪一步」。
一个常见设计是维护一张「库存变动流水」:每一次扣减、释放、回补都写一条带类型、来源单据号、时间戳的记录。当前库存是流水的累积结果,任何时刻都能从流水重算出库存,也就能在怀疑不一致时做对账。
再好的设计也无法消灭网络超时。因此系统必须默认「操作可能失败在半路」,并准备好恢复路径。
关于压测的指标定义与场景建模这类话题,我们在 行业动态 栏目和 工程与质量保障 页面都整理了更完整的检查项。
把上面这些串起来,落地时可以按以下顺序推进:
关于整个交付过程的分阶段安排与各阶段产出物,可以参考 交付流程;如果你有更具体的技术问题,也可以先看 常见问题 中关于系统设计与性能的说明。
高并发库存扣减看起来是一个「加锁就完事」的问题,实际包含三层并发、四种策略、幂等设计和补偿对账四条线索。真正决定系统是否可靠的,不是用了多先进的组件,而是有没有把「判断与扣减必须原子」「重复请求必须幂等」「超时操作必须可恢复」这三件事做扎实。
从我们的工程实践看,绝大多数库存类事故都能归到这三条中的某一条没做到位。先把模型定义清楚、把主链路的原子性和幂等性做对,再谈缓存与分段的性能优化,顺序不要反——在正确性没保证之前做性能优化,只会把问题放大。
如果你的系统正被超卖、重复下单或者库存对不上困扰,建议先把这三条逐条自查一遍,大部分问题会在这个过程中暴露出来。
因为扣减本质是「先读后写」:先查询库存判断是否足够,再执行减一。两个请求并发时可能都读到剩余 1 件、都判断为够、都执行减一,最终产生超卖。这不是某段代码写错,而是共享资源被并发读写时的必然结果,需要在并发模型层面显式处理。
把「判断够不够」和「扣减」压缩成一条带条件的数据库原子语句,例如 UPDATE stock SET available = available - 1 WHERE sku_id = ? AND available >= 1,然后以影响行数判断成功与否。这样借数据库行锁保证原子性,中间没有插入窗口,对多数中小规模系统已经足够。
不一定。分布式锁本身也可能被误用:锁粒度过大导致性能崩溃、未设超时导致死锁、未校验持有者导致误删他人的锁。而且能用单条原子语句解决的问题不该引入分布式锁。锁更适合「必须跨多条记录或多个系统保持互斥」的场景,不应成为默认方案。
防超卖解决的是「减多了」,幂等解决的是「同一笔意图执行了多次」。两者常一起出现:下单请求超时后客户端重试,服务端可能已扣过一次库存、建过一次订单,导致账单重复。做法是为每个业务意图分配幂等键、落库加去重约束,并区分「处理中」与「已完成」。建议先做幂等再做并发优化。
因为不同业务节点动的是不同的量:下单时增加占用、支付时确认锁定、取消时释放占用、发货时扣减实物。只有把每个状态变更对应的量定义清楚,出现对账差异时才能说清差在哪一步。可售库存通常是实物库存减去占用库存与安全库存。
只有在确认数据库行锁竞争已成为吞吐瓶颈时才值得引入。缓存预扣减能提升吞吐,但带来缓存与数据库两个真相源之间的一致性问题,必须配合持久化、对账与可重放补偿;队列串行能削峰,但会增加响应延迟。它们应当作为前置减压层,而不是替换主链路的正确性保障。