
只要做过电商后端的基本都绕不开“订单和库存”这一对冤家。平时流量低的时候方案怎么简单怎么来查库存、扣库存、下单三步走基本不出问题。可一旦碰上秒杀、大促这类高并发场景流量翻几十倍同样的代码立马露馅。你以为爆的是性能其实最先崩的是数据一致性。并发请求一多库存余量容易被扣成负数或者同一件商品被多个用户同时锁定最后订单生成了库存对不上账客服被投诉淹没。我见过很多团队在库存这块踩坑也见过一些还不错的处理套路。这篇就把高并发场景下订单和库存处理的完整思路捋一遍从问题本质、常见方案、可落地的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的临时keyTTL等于支付有效期比如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的积压量、消费耗时数据库的连接数、慢查询数量。任何一个环节成为瓶颈都要针对性地做优化。检查项关注指标可接受范围参考RedisQPS、内存、命中率QPS不超过节点上限80%无剧烈波动MQ积压量、消费延迟积压量不持续增长消费延迟在秒级数据库活跃连接数、慢查询数活跃连接低于池上限慢查询无应用服务响应时间、错误率平均响应符合业务预期错误率接近于05.2 监控指标怎么设计压测只是上线前的一次体检线上运行时也需要24小时盯住关键指标。监控分为三层业务指标、中间件指标和应用指标。业务指标里最重要的是“库存扣减成功数”“回补数”“订单创建数”三个数的口径是否一致。这三个数如果长时间不吻合大概率是异步链路丢了消息或重复消费了。中间件指标就是Redis、MQ这些组件的基础监控日常重点看慢命令、连接数、积压量。应用指标则要看下单接口的TP99响应时间、预扣接口的错误率。设置好告警策略响应时间或错误率超过阈值时第一时间告警比事后排查节省太多时间。5.3 方案不是一成不变的最后补充一句没有一套方案能覆盖所有场景。如果你的商品种类少、总量有限数据库悲观锁可能也够用。如果目标是百万级别流量的大促那Redis预扣MQ异步频繁对账这套体系几乎是标配。重要的是先想清楚自己的场景边界再决定引入多少复杂度。复杂度每多一层线上排查的难度都会增加能够简单解决就别为了炫技引入一堆中间件。6. 我的一些体会这类库存方案我自己经手过几个版本从最初直接改数据库到后来引入Redis和MQ踩过的坑不少也有一些比较深的体会。第一个体会是无论方案多复杂最后一定要有位兜底的对账程序。很多“看起来没问题”的方案上线后都会在某个边界条件下漏出一两个不一致的数据如果没有对账等用户发现了才去补账基本就是线上事故。第二个体会是不要一开始就把所有并发场景当成一样的。同样是高并发秒杀场景和普通商品大促的处理策略差别很大秒杀更看重超卖控制和链路削峰普通大促则更看重系统整体反应的稳定和订单状态的正确。先分清场景再谈技术选型比什么都重要。第三个体会是高并发的库存方案说到底不是“把一个操作用力最快地执行”而是把“下单-支付-关单-回补”这整条链路的每一步都设计得足够稳让系统在异常状态下也能自我修复。学会用最终一致性而不是强一致性的眼光去看待系统很多纠结自然就解开了。