1. 库存扣减为什么总在大促时掉链子
做电商系统的人,迟早都会在库存这块栽跟头。我见过太多团队,平时测试环境跑得好好的,一到活动开卖,订单量稍微冲一冲,超卖、少卖、订单卡死、库存对不上账,各种问题全冒出来。库存扣减看着简单,无非是把一个数字减一,但真正放到高并发、分布式、多服务协作的环境里,它就是一个典型的并发控制难题。
这篇文章我想把电商库存扣减这件事从头到尾捋一遍。从业务场景出发,讲清楚为什么库存扣减会出问题,有哪些主流的解决方案,每种方案的适用边界在哪里,以及实际落地时需要注意哪些细节。不管你是刚接触电商业务的后端开发,还是正在重构库存模块的架构师,应该都能从中找到可以直接参考的东西。
库存扣减的核心矛盾其实就一句话:多个请求同时操作同一份库存数据,如何保证最终扣减结果准确且系统稳定。这个问题在不同业务场景下,答案是不一样的。比如秒杀场景和普通下单场景,对库存扣减的要求就完全不同。秒杀追求极致的并发吞吐,可以接受一定的异步延迟;普通下单则更看重实时性和准确性。所以不存在银弹方案,只有适合当前业务阶段的方案。
2. 库存扣减的核心场景与业务拆解
2.1 下单扣减与支付扣减的时机选择
库存扣减的时机,是设计库存系统时第一个要回答的问题。常见的有两种:下单即扣减和支付后扣减。
下单即扣减,就是用户提交订单的那一刻,库存就锁定了。这种方式的优点是逻辑简单,库存实时准确,不会出现用户付了钱却没货的情况。缺点也很明显,如果用户下单后不付款,库存就被占住了,需要额外的超时释放机制。适合库存紧张、商品价值高的场景,比如限量款、抢购活动。
支付后扣减,是用户完成支付后才真正扣库存。好处是不会被未支付订单占库存,库存利用率高。但风险在于,用户支付时可能库存已经没了,需要做退款处理,体验很差。适合库存充足、转化率优先的场景。
实际项目中,大多数团队采用的是下单锁库存、支付确认扣减、超时自动释放的组合方案。这样既保证了库存不被无效占用,又避免了支付后无货的尴尬。
2.2 不同业务场景对库存扣减的要求差异
不是所有商品都需要用同一套库存扣减逻辑。我一般会把商品分成几类来区别对待:
| 业务类型 | 库存特征 | 并发量级 | 推荐方案 |
|---|---|---|---|
| 普通商品 | 库存充足 | 中等 | 数据库乐观锁 |
| 秒杀商品 | 库存极少 | 极高 | Redis预扣减+异步落库 |
| 预售商品 | 库存确定 | 中等 | 下单锁库存+定时释放 |
| 虚拟商品 | 无物理库存 | 高 | 独立库存池+分布式锁 |
这个分类不是绝对的,但思路是:根据库存的稀缺程度和并发压力,选择不同重量级的方案。用大炮打蚊子是浪费,用弹弓打老虎是找死。
2.3 库存扣减涉及的核心数据模型
库存数据模型的设计直接影响后续所有操作。最基础的三张表:库存表、库存流水表、库存锁定表。
库存表记录当前可售库存和已锁定库存,库存流水表记录每一次库存变动的明细,库存锁定表记录每一笔锁定的库存及其状态。这三张表配合使用,才能做到库存可追溯、可对账、可恢复。
很多团队只建一张库存表,扣减直接update,出了问题连查都查不了。库存流水表不是可选项,是必选项。
3. 主流库存扣减方案深度对比
3.1 数据库乐观锁方案
乐观锁是最容易想到的方案。核心思路是在库存表加一个版本号字段,每次更新时带上版本号条件:
UPDATE stock SET quantity = quantity - 1, version = version + 1 WHERE sku_id = ? AND quantity >= 1 AND version = ?;如果更新影响行数为0,说明库存被其他请求改过了,重试即可。
这个方案的优点是实现简单,不依赖额外组件,数据一致性强。缺点是并发高时大量请求会失败重试,数据库压力大。我实测过,单行库存的并发更新,MySQL在几百QPS时就开始明显吃力了。
优化方向有几个:一是把重试次数控制在合理范围,比如3次,超过就返回失败;二是配合库存分段,把一个SKU的库存拆成多行,降低单行竞争;三是用数据库的行锁代替版本号,但要注意死锁问题。
3.2 Redis原子操作方案
Redis的原子递减是应对高并发的利器。用DECR或者Lua脚本,可以在内存层面完成库存扣减,性能轻松上万QPS。
local stock = redis.call('GET', KEYS[1]) if tonumber(stock) >= tonumber(ARGV[1]) then redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 else return 0 end但Redis方案的问题在于数据可靠性。Redis挂了怎么办?扣减成功但落库失败怎么办?所以Redis通常只作为预扣减层,真正的库存数据还是要落到数据库。这就引出了异步落库和最终一致性的问题。
3.3 消息队列异步扣减方案
把库存扣减请求丢到消息队列,由消费者串行处理,这是另一种思路。好处是削峰填谷,数据库压力可控。坏处是用户下单后不能立刻知道库存是否扣减成功,需要异步通知。
这个方案适合秒杀场景,用户提交后先返回排队中,后续通过轮询或推送告知结果。但要注意消息重复消费的问题,消费者必须做幂等处理。
3.4 分布式锁方案
用Redis或ZooKeeper实现分布式锁,保证同一时刻只有一个请求能操作某个SKU的库存。这个方案的正确性没问题,但性能是硬伤。锁的粒度如果控制不好,很容易成为瓶颈。
我的经验是,分布式锁更适合库存回滚、库存盘点这类低频但要求强一致的操作,不适合高频的下单扣减。
3.5 各方案适用场景速查
| 方案 | 并发能力 | 一致性 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库乐观锁 | 低 | 强 | 低 | 普通商品下单 |
| Redis原子操作 | 高 | 最终一致 | 中 | 秒杀预扣减 |
| 消息队列异步 | 高 | 最终一致 | 中高 | 秒杀异步处理 |
| 分布式锁 | 低 | 强 | 中 | 库存回滚、盘点 |
4. 高并发场景下的库存扣减实战
4.1 秒杀场景的库存预扣减设计
秒杀是库存扣减的极限场景。我的做法是分三层:Redis预扣减、消息队列缓冲、数据库最终落库。
用户请求进来,先在Redis用Lua脚本做原子扣减。扣减成功的请求,生成一条消息丢到队列,立即返回用户“排队中”。消费者从队列取出消息,在数据库执行真正的库存扣减和订单创建。如果数据库扣减失败,再把Redis的库存加回去。
这个流程的关键点在于:Redis扣减和消息发送必须保证原子性。我一般用Redis的事务或者Lua脚本把这两个操作包在一起,避免扣了库存但消息没发出去。
4.2 库存分段与热点分散
单个SKU的库存如果只有一行记录,所有请求都竞争这一行,数据库肯定扛不住。解决办法是库存分段:把一个SKU的1000件库存拆成10行,每行100件。请求进来先随机选一段,再在该段内扣减。
这样可以把单行竞争分散到多行,数据库压力直接降一个数量级。但要注意,分段后总库存的查询需要SUM,而且某一段扣完后需要动态调整,逻辑会复杂一些。
分段的数量不是越多越好。我一般根据预估峰值QPS来定,每段承担的QPS控制在数据库单行能承受的范围内。比如单行能扛200 QPS,预估峰值2000 QPS,那就分10段以上。
4.3 扣减失败的补偿与回滚机制
任何分布式方案都必须考虑失败补偿。库存扣减失败的情况有很多:数据库超时、消息丢失、消费者异常、网络抖动。
我的做法是建立一套库存流水对账机制。每一次库存变动都记录流水,包括预扣减、确认扣减、回滚。定时任务扫描流水表,找出状态不一致的记录进行修复。比如Redis扣了但数据库没扣,就补扣;数据库扣了但Redis没扣,就补Redis。
补偿机制的核心是幂等。每条流水有唯一ID,补偿操作根据ID判断是否已经执行过,避免重复扣减。
4.4 库存回滚的正确姿势
订单取消、超时未支付、退款,都需要回滚库存。回滚不是简单地把数字加回去,要考虑几个问题:
第一,回滚的库存要回到原来的分段吗?如果原来那段已经满了,怎么办?我的做法是回滚到总库存池,由定时任务重新分配。
第二,回滚和扣减并发时怎么保证不超卖?回滚是增加库存,理论上不会导致超卖,但可能和扣减操作产生竞争。用数据库行锁或者Redis原子操作都能解决。
第三,回滚要有记录,不能凭空加库存。每一笔回滚都要关联到具体的订单和扣减流水,方便对账。
5. 库存一致性保障与对账体系
5.1 最终一致性的实现路径
分布式环境下,强一致性代价太高,大多数电商系统选择最终一致性。实现路径是:本地事务+消息队列+定时补偿。
扣减库存和创建订单在同一个本地事务里完成,然后发消息通知其他系统。如果消息发送失败,用本地消息表兜底,定时任务扫描重发。下游系统消费消息时做幂等,保证重复消费不出问题。
这套机制的关键在于本地消息表的设计。消息表和业务表在同一个数据库,同一个事务写入,保证消息不丢。定时任务扫描未发送的消息,补发到队列。
5.2 库存对账的定时任务设计
对账是发现库存问题的最后一道防线。我一般会设计三个对账任务:
- 分钟级对账:对比Redis和数据库的库存差异,超过阈值告警
- 小时级对账:对比库存流水和库存余额,找出不一致的记录
- 天级对账:全量核对库存、订单、流水,生成对账报告
对账任务本身也要考虑性能,不能全表扫描。我一般按时间范围分批处理,每次处理最近一段时间的数据,历史数据归档后离线对账。
5.3 超卖与少卖的排查思路
超卖和少卖是库存问题的两个极端。超卖是扣减时没控制住并发,少卖是扣减失败后没有正确回滚。
排查超卖,先看扣减逻辑有没有加锁或原子操作,再看数据库隔离级别是否合适,最后检查是否有绕过库存校验的入口。排查少卖,重点看回滚逻辑是否完整,补偿任务是否正常执行,消息是否有丢失。
我遇到过一次少卖,查了半天发现是回滚消息的消费者挂了,库存一直没加回去。后来加了消费者健康检查和消息堆积告警,这类问题就能第一时间发现。
6. 库存扣减的避坑经验与实操建议
6.1 这些坑我替你踩过了
第一个坑:用SELECT再UPDATE的方式扣库存。这是最经典的错误,查询和更新之间有间隙,并发时必然超卖。必须用UPDATE ... WHERE quantity >= 1这种原子操作。
第二个坑:库存扣减和订单创建不在同一个事务。扣了库存但订单创建失败,库存就白白丢了。要么放同一个事务,要么用补偿机制。
第三个坑:Redis和数据库双写不一致。先写Redis再写数据库,或者反过来,都有不一致的风险。正确做法是以一个为准,另一个异步同步,配合对账修复。
第四个坑:忽略库存回滚的幂等。回滚操作重复执行,库存越加越多。每笔回滚都要有唯一标识,执行前先判断。
第五个坑:大促前没有做库存预热。Redis里的库存数据是空的,大促开始瞬间大量请求穿透到数据库。提前把库存加载到Redis,能避免这个问题。
6.2 压测与容量评估怎么做
库存模块上线前必须压测。我一般用JMeter或wrk模拟并发扣减,观察几个指标:TPS、响应时间、数据库连接数、Redis命中率、错误率。
压测时要特别注意热点SKU的场景。所有请求打同一个SKU,这才是最坏情况。如果压测时每个请求打不同SKU,结果会好看很多,但没有参考价值。
容量评估的公式很简单:所需实例数 = 峰值QPS / 单实例承载QPS。单实例承载QPS通过压测得出,再留50%的余量。比如压测单实例能扛1000 QPS,预估峰值3000 QPS,那就至少6个实例。
6.3 监控告警的关键指标
库存模块的监控,我重点关注这几个指标:
- 库存扣减成功率:低于99.9%就要查原因
- 库存扣减耗时P99:超过500ms说明有性能问题
- Redis和数据库库存差异:超过阈值立即告警
- 消息队列堆积量:持续增长说明消费者处理不过来
- 回滚任务执行情况:失败次数超过0就要关注
这些指标配上告警,基本能在问题扩大前发现苗头。
6.4 从单体到分布式的演进建议
如果系统刚开始,库存扣减用数据库乐观锁就够了,别一上来就上Redis和消息队列,复杂度太高,维护成本大。
当单表库存出现性能瓶颈时,先考虑库存分段和读写分离。再往上,引入Redis做预扣减,数据库做最终落库。最后才是消息队列异步化和分布式事务。
每一步演进都要有明确的触发条件,比如QPS超过多少、响应时间超过多少。不要为了技术而技术,适合当前阶段的才是最好的。
库存扣减这件事,说到底是在准确性、性能和复杂度之间找平衡。没有完美的方案,只有不断根据业务变化调整的方案。我在实际项目中的体会是,先把最简单的方案做对做扎实,再根据压测数据逐步优化,比一开始就设计一个复杂架构要靠谱得多。