只要做过电商后端的,基本都绕不开“订单和库存”这一对冤家。平时流量低的时候,方案怎么简单怎么来,查库存、扣库存、下单三步走,基本不出问题。可一旦碰上秒杀、大促这类高并发场景,流量翻几十倍,同样的代码立马露馅。你以为爆的是性能,其实最先崩的是数据一致性。并发请求一多,库存余量容易被扣成负数,或者同一件商品被多个用户同时锁定,最后订单生成了,库存对不上账,客服被投诉淹没。
我见过很多团队在库存这块踩坑,也见过一些还不错的处理套路。这篇就把高并发场景下订单和库存处理的完整思路捋一遍,从问题本质、常见方案、可落地的Redis预扣方案,到实操细节、压测验证和避坑经验,尽量讲透,供有类似需求的读者直接参考。
1. 先分清问题:我们要防的是“超卖”,还是“崩溃”?
很多同学一听到高并发库存,第一反应是“上Redis”、“上MQ”,其实方案选型之前,最重要的是把问题拆清楚。库存处理在高并发下有两个层次的矛盾:一个叫正确性,一个叫可用性,两者经常被混在一起讨论,但解法完全不同。
1.1 并发不是唯一难点,一致性才是
先说正确性。库存是一个有上限的资源,所有并发请求都在抢同一批数量有限的商品。如果两个用户同时下单,数据库里库存只剩1件,两个请求都读到“还有1件”,然后各自扣减,最终库存变成-1,这就是典型的超卖。
超卖的本质是“检查库存”和“扣减库存”这两个动作不是原子的。任何解决方案,不管用什么中间件,核心目标都是让这两个动作变成一个不可分割的原子操作。
从数据库层面看,最简单的做法是加锁。比如select ... for update把某件商品的行锁住,等当前事务提交后再释放,这样并发请求会被串行化,不会超卖。但坏处也很明显:数据库的行锁意味着吞吐量上不去,秒杀场景的QPS稍微一大,数据库连接池先被打满,后面全是等待。
另一种思路是乐观锁,在商品表里加一列version,更新的时候判断版本号是否匹配。这个方案不需要提前加锁,但在大并发下会导致大量更新失败,用户看到的反馈就是“抢购失败,请重试”,体验并不好。
从业务角度看,库存扣减失败是可以接受的,毕竟商品有限,但数据绝不能错。相比之下,更值得关注的是“一致性”本身。
1.2 订单和库存不是一个维度的矛盾
再把订单和库存分开看。订单是流程性数据,状态会变:待支付、已支付、已取消、已关闭。库存是结果性数据,只有一个总量和剩余量。订单关心的是“这笔交易进行到哪一步”,库存关心的是“总数扣得对不对”。
这就引出一个很重要的点:不要试图在同一个操作里同时把订单和库存做完。
常见的一些系统里,下单逻辑是“创建一个订单记录,同时更新库存”。这个设计在低并发下没问题,但在高并发下会死死抱住数据库事务,把整个链条拖慢。更合理的做法是把“预占库存”和“生成订单”拆成两步,中间靠缓存和消息队列衔接。订单先处于占住库存的状态,之后只有真正支付成功,库存才算彻底扣完;如果付款超时,再通过补偿机制把库存还回去。
这种设计思想叫异步化,严格一点说是最终一致性。它牺牲了“即时强一致”,换来了吞吐能力和系统稳定性。订单和库存之间短暂的不一致是可以接受的,只要通过后续对账和补偿把状态收敛回正确结果即可。
下面用一个表格直观对比几个维度的差异:
| 对比维度 | 订单 | 库存 |
|---|---|---|
| 数据类型 | 过程数据 | 最终状态数据 |
| 核心操作 | 创建、支付、取消 | 扣减、回补 |
| 一致性要求 | 强一致要求高 | 允许最终一致 |
| 高并发压力 | 大,但可异步 | 大,且需原子扣减 |
| 常用手段 | 状态机、幂等键 | 原子操作、补偿 |
2. 常见方案的优劣对比分析
处理高并发库存的方案,网上能搜到很多,但大多是只言片语。我按“数据库方案”和“缓存方案”两个维度把它们归拢起来,逐个分析优势和局限,这样你在选型的时候才有判断依据。
2.1 数据库悲观锁:最简单,但扛不住高温场景
悲观锁就是select ... for update。一个事务里先锁住商品行,再更新库存,最后提交。优点是实现简单,正确性最高,任何并发场景都不会超卖。缺点是锁的粒度太粗,整个商品的更新被串行化,比如一秒钟来一万个请求,也只有一个一个排队处理。
这个方案适合内部系统、后台管理系统、流量很低的业务,不适合用户端直接访问的高并发下单。真要用在秒杀场景,数据库CPU飙升、连接池耗尽几乎是必然结果。
2.2 数据库乐观锁:思路清晰,仍有局限
乐观锁通过version字段实现。执行更新的时候带上版本条件where version = ?,如果影响行数为0,说明已经被其他事务修改,本次操作失败,需要重试。
这个方案的好处是去掉了显式锁,并发能力比悲观锁高不少。但问题也很明显:高并发下大量请求会同时执行更新,大部分都因为版本不匹配而失败,用户体验差。而且如果事务里还要插入订单记录,事务的粒度没有变小,数据库压力依然大。
从实际经验看,乐观锁更适合“并发度可控”的场景,比如普通商品的限购,几万人的抢购还能扛一扛,百万级别的活动就吃力了。
2.3 缓存预扣+异步下单:主流方案是这样来的
既然数据库锁成本太高,大家自然会想:能不能把库存从数据库搬到内存里来扣?Redis就是这个思路下的主流选择。利用Redis单线程原子操作的特性,用Lua脚本把“检查库存”和“扣减库存”合并成一个原子命令,速度极快,又能防止超卖。
需要注意的是,Redis里的扣减只是“预占”,不是最终扣减。用户下单后先占住库存,然后异步创建订单,等支付成功后再把数据库库存真正扣掉。如果用户超时未支付,需要通过延迟消息把预占的库存回补掉。
这个方案能扛住高并发,同时引入了额外的复杂度:缓存和数据库的一致性怎么保证?订单创建失败怎么办?回补会不会重复扣?这些都是后面要重点解决的问题。
下面用一个表格对比上述三类方案:
| 方案 | 正确性 | 高并发能力 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 数据库悲观锁 | 强 | 很低 | 低 | 内部系统、后台 |
| 数据库乐观锁 | 强 | 中 | 中 | 普通业务限流 |
| Redis预扣+异步下单 | 最终一致 | 高 | 高 | 秒杀、大促、抢购 |
3. 一套可落地的缓存预扣+异步下单方案
方案确定之后,最难的就是把细节落地。这里我完整讲一套在实践中验证过的流程,整体分为五个步骤:库存初始化、原子预扣、异步下单、超时回补、日终对账。每一步都有对应的代码或者伪代码,方便读者对照自己的项目调整。
3.1 库存初始化与缓存预热
Redis里的库存不能自动从数据库同步过来,所以活动开始前必须先把商品库存写入缓存。这里有一个细节:不要把总库存直接用一个key存,建议把库存拆成多个小桶,每个桶独立扣减。这样能避免所有请求都集中在同一个key上,形成热点,具体拆法后面会讲。
初始化的时候要同时保证“缓存里没有旧数据”。建议的做法是先用del删除旧key,再执行set写入新库存。操作最好用Lua脚本或者事务包起来,避免并发下出现脏数据。
127.0.0.1:6379> SET stock:sku_001:0 100 OK 127.0.0.1:6379> SET stock:sku_001:1 100 OK这里的库存数量是从数据库里查出来的可用库存,扣减的时候Redis只做预占,真正的扣减在支付完成后再落到数据库。
3.2 用Lua脚本完成原子扣减
预扣库存是整个方案的核心,必须保证原子性。Redis单个命令本身是原子的,但如果先GET判断、再DECR,中间就会被人插队。所以必须用Lua脚本把“判断库存是否充足”和“执行扣减”合并为一个操作。
下面是一个常用脚本:
-- KEYS[1] 库存key,比如 stock:sku_001 -- ARGV[1] 本次预占数量 local stock = tonumber(redis.call('GET', KEYS[1])) if not stock then return -1 -- 库存key不存在 end if stock < tonumber(ARGV[1]) then return -2 -- 库存不足 end redis.call('DECRBY', KEYS[1], ARGV[1]) return 1 -- 预占成功请求方调用这个脚本,返回值是1代表预占成功,整数为负则对应失败原因。这里最好把订单号作为参数传进脚本,在扣减的同时记录到另一个有序集合里,后面超时回补能直接用。
Lua脚本一方面保证了“读库存-扣库存”的原子性,另一方面减少了一次网络RTT,执行一次脚本顶好几次命令,在高并发下能显著降低Redis的访问压力。
3.3 订单异步创建与库存锁定状态
预扣库存成功后,客户端拿到的只是一个“预占成功”的结果,此时订单还没落库。下一步是把待创建的订单信息发给MQ,由消费端异步创建订单记录。
这里有个关键点:异步创建订单必须做幂等。MQ消息可能重复投递,消费端如果收到两次相同的消息,不能生成两条订单。常见做法是使用订单号作为幂等键,在订单表上建唯一索引,创建前先查一遍,存在就直接返回。
订单创建时,状态一般是“待支付”或“已锁定”,同时记录预占锁定的库存数量。这个状态和Redis里预扣的数量遥相呼应,任何一端状态异常,都能通过对账发现。
MQ选型上,项目里有RocketMQ用RocketMQ,有RabbitMQ用RabbitMQ,核心要求是消息不能丢,能重试。如果是自研系统,至少要保证消息持久化和手动ACK,避免消费时进程挂了消息丢失,导致订单和库存永久不一致。
3.4 超时订单自动回补
用户预占库存后,如果一直不支付,库存就被白白锁住。这时需要超时回补机制。常用的做法有两种:一种是利用Redis的过期key监听,一种是延迟消息。
过期key监听实现的原理是:预扣库存时同时设置一个带TTL的临时key,TTL等于支付有效期,比如15分钟。Redis过期事件会被订阅到,收到事件后检查订单是否已支付,如果没支付就回补库存。
这个方案实现起来比较轻,但Redis过期事件存在丢失可能,而且当key数量很多时,过期事件会有延迟。所以更稳的做法是使用延迟队列,或者定时任务扫描待支付订单。
在实际项目中,我建议以定时任务兜底为主。实现逻辑很简单:每隔一分钟扫描超时未支付的订单,更新订单状态为“已关闭”,并回补库存。回补时同样要幂等,不能让同一笔订单反复回补,否则会把库存加多了。做法是在回补前判断订单状态,只有“待支付”状态才能回补,回补后要加个操作流水记录。
3.5 日终对账把“脏数据”揪出来
异步方案再怎么完善,也难免出现极端情况:Redis扣了、订单没建;或者订单关了、库存没回补。线上事故往往就是这些零星的不一致累积出来的。所以必须引入对账任务。
对账的思路比较简单:按商品维度,把“数据库剩余库存+已支付订单占用数量+待支付订单占用数量”和“活动初始化库存总数”做比较,两边应该相等。如果对不上,就要逐笔核对Redis预扣记录、订单状态和支付流水,找出哪一步掉了链子。
这个任务不需要很频繁,活动结束后跑一次就行,也可以用定时任务每天跑。在实际项目中,对账任务经常能发现棘手的问题,是整个方案的最后一道防线。
4. 实战中最容易踩的四个坑
这套Redis预扣+异步下单的方案,说起来不太复杂,但真正落地时会遇到各种细节问题。我挑了四个最典型的坑,结合实际经验逐一说清楚。
4.1 缓存击穿:活动开始瞬间的请求风暴
库存这种key是典型的热点key,瞬时访问量极大。如果活动开始前Redis里没有数据,大量请求会同时回源到数据库,直接把库打挂。这就是缓存击穿。
处理办法有两个:一个是活动开始前预热,确保所有库存key先写入Redis;另一个是设置逻辑过期或者互斥锁,不让所有请求都穿透到数据库。预热听起来简单,但要特别注意“活动配置发布”和“库存写入Redis”的顺序,不能出现活动已经对外,库存还没预热好的时间窗口。
我见过一次事故,运营同学在后台点“上线活动”后,库存任务因为依赖的调度服务还没启动,导致前几分钟的全是“已售罄”。后来又紧急补库存,用户已被劝退,损失不小。后来做法改为:活动配置状态是“待开始”,库存全部预热完成后再自动改为“已开始”。
4.2 幂等性:同一订单别扣两次库存
幂等性在异步下单里非常关键。比如MQ重试、或者用户在支付回调时又点了好几次,都可能对同一笔订单触发多次扣减。如果没有幂等保护,库存会被多扣,数据库后台还会出现重复订单。
订单维度幂等最简单的做法就是订单号唯一索引。插入订单时,如果已经存在相同订单号,直接返回成功。用数据库的唯一约束来做幂等,比应用层判重更可靠。
还有一个细节:库存扣减流水表也要做唯一约束。比如把“订单号+扣减类型”作为唯一键,这样即使服务重复处理,也不会插入两条扣减流水,库存字段不会被错误扣两次。
4.3 热点商品和库存分桶
有些商品的热门程度完全碾压其他商品,比如秒杀里的那几款爆品。所有流量都打到一个Redis key上,单key的访问量再高也只有那一个Redis节点承载,容易达到性能瓶颈。
解决方案是分桶存储:把一件商品的总库存拆成N份,分散到N个不同的key上,请求被随机路由到其中一个桶。初始化时用stock:sku_001:0、stock:sku_001:1等表示多个分桶,每个桶只放一部分库存。
分桶后带来的问题是:如果某个桶库存先扣完,但其他桶还有剩余,就可能出现“还有货却提示售罄”的情况。解决办法有两个方向,一是所有桶扣完之后,再继续去其他桶里尝试扣减;二是让每个桶在扣减时都先做一次均衡查询。实际操作中,均匀分桶和路由写简单点就行,对绝大多数项目来说,库存轻微不均衡是可以接受的,前提是不能超卖。
4.4 超时回补的延迟和重复回补
回补操作本身也容易出问题。第一个问题是时效性:如果定时任务设置的扫描间隔是5分钟,意味着一个超时订单最长可能要等5分钟才会被关单回补。对于秒杀场景,这个时间窗口内的库存被无效占住,会影响真正的购买用户。建议缩短扫描间隔,或者配合延迟消息,把回补的及时性提到秒级。
第二个问题是重复回补。如果回补任务和用户主动取消订单同时发生,两个流程都可能执行“恢复库存”的操作,库存会被多加。解决办法是用“订单状态流转”来控制,只有状态从“待支付”改成“已关闭”的那一方才有资格回补库存,另一方操作前先检查当前状态,状态已变更则放弃操作。
5. 上线前要做的验证和监控
方案写完了,不等于能直接上线。高并发场景最怕的是“以为没问题”,实际一压测全是问题。这里说下我建议做的验证手段和监控项。
5.1 用压测验证真实并发表现
压测的目标不是刷出高TPS让你发朋友圈,而是验证两个核心点:超卖是否发生,系统在预期流量下是否稳定。
超卖验证最直接的方法:准备一个商品库存设为1000,用压测工具同时发起两万个并发请求,压测结束后检查数据库扣减总量是否等于1000,订单数量是否符合预期。如果压测过程中出现超卖,快去查逻辑漏洞。
系统稳定性验证则需要观察链路中每个环节的指标:Redis的CPU、QPS、内存;MQ的积压量、消费耗时;数据库的连接数、慢查询数量。任何一个环节成为瓶颈,都要针对性地做优化。
| 检查项 | 关注指标 | 可接受范围参考 |
|---|---|---|
| Redis | QPS、内存、命中率 | QPS不超过节点上限80%,无剧烈波动 |
| MQ | 积压量、消费延迟 | 积压量不持续增长,消费延迟在秒级 |
| 数据库 | 活跃连接数、慢查询数 | 活跃连接低于池上限,慢查询无 |
| 应用服务 | 响应时间、错误率 | 平均响应符合业务预期,错误率接近于0 |
5.2 监控指标怎么设计
压测只是上线前的一次体检,线上运行时也需要24小时盯住关键指标。监控分为三层:业务指标、中间件指标和应用指标。
业务指标里,最重要的是“库存扣减成功数”“回补数”“订单创建数”三个数的口径是否一致。这三个数如果长时间不吻合,大概率是异步链路丢了消息或重复消费了。
中间件指标就是Redis、MQ这些组件的基础监控,日常重点看慢命令、连接数、积压量。应用指标则要看下单接口的TP99响应时间、预扣接口的错误率。设置好告警策略,响应时间或错误率超过阈值时第一时间告警,比事后排查节省太多时间。
5.3 方案不是一成不变的
最后补充一句:没有一套方案能覆盖所有场景。如果你的商品种类少、总量有限,数据库悲观锁可能也够用。如果目标是百万级别流量的大促,那Redis预扣+MQ异步+频繁对账这套体系几乎是标配。
重要的是先想清楚自己的场景边界,再决定引入多少复杂度。复杂度每多一层,线上排查的难度都会增加,能够简单解决就别为了炫技引入一堆中间件。
6. 我的一些体会
这类库存方案我自己经手过几个版本,从最初直接改数据库,到后来引入Redis和MQ,踩过的坑不少,也有一些比较深的体会。第一个体会是:无论方案多复杂,最后一定要有位兜底的对账程序。很多“看起来没问题”的方案,上线后都会在某个边界条件下漏出一两个不一致的数据,如果没有对账,等用户发现了才去补账,基本就是线上事故。
第二个体会是:不要一开始就把所有并发场景当成一样的。同样是高并发,秒杀场景和普通商品大促的处理策略差别很大,秒杀更看重超卖控制和链路削峰,普通大促则更看重系统整体反应的稳定和订单状态的正确。先分清场景,再谈技术选型,比什么都重要。
第三个体会是:高并发的库存方案,说到底不是“把一个操作用力最快地执行”,而是把“下单-支付-关单-回补”这整条链路的每一步都设计得足够稳,让系统在异常状态下也能自我修复。学会用最终一致性而不是强一致性的眼光去看待系统,很多纠结自然就解开了。