☰
电商下单系统重构实战:状态机、库存扣减与幂等设计
2026/10/21 21:32:36 网站建设 项目流程

做电商系统的开发者,早晚都要跟下单功能硬碰硬。我接手过一套看起来“能跑”的下单模块,平时日单量几千的时候什么问题都没有,大家觉得稳得很,结果第一次上大促活动就出了大事故。那天开售前二十分钟,监控开始疯狂报警:下单接口的P99延迟从50毫秒一路涨到了3000毫秒,数据库连接池被打满,支付回调大面积超时,订单卡在待支付状态没人处理,客服那边堆了几百条“付了钱却查不到订单”的投诉。那次之后我们重构了整整两个月,踩过的坑基本能写成一本书。这篇文章就把这套电商下单功能从翻车到稳定的完整过程梳理一遍,重点讲订单状态机、库存扣减、防重复提交、事务一致性和大促性能优化这几块,适合正在做订单模块,或者想把“能用但一上线就闹脾气”的下单逻辑重写成稳定版本的开发者。

1. 第一次大促翻车现场:下单链路是怎么被打挂的

1.1 复盘:崩的不是服务器,是下单逻辑本身

老代码的逻辑直接得不拐弯:查商品信息先走数据库,库存扣减是“先SELECT再UPDATE”两步,创建订单和扣库存放在同一个事务里,下单主流程里还同步调用优惠券算价、积分赠送、物流预计算。听起来不算离谱,但在大促流量进来后,问题被放大到了无限大。

首先爆发的是锁竞争。两个爆款SKU撑起了60%以上的下单流量,库存表里对应的两行记录成了全系统最拥堵的两行。同一时刻几百个线程同时去争抢同一行的更新锁,InnoDB的行锁排队时间不断飙升,数据库连接被长时间占住,连接池很快就见底了。连接不够用之后,新请求全部堵在“获取连接”这一步,服务整体RT开始雪崩式上升。

第二个问题是事务过长。下单逻辑把优惠券推荐、积分赠送这些操作全部包在了一个大事务里,一个请求最多要跑好几百毫秒甚至更久。锁持有时间越长,后面排队越久,数据库资源就被这些“既不必要、又慢”的事务吃光了。

第三个问题是缺乏防重和状态约束。用户在弱网环境多点了两下下单按钮,系统就生成了好几张待支付订单;订单状态在代码里到处是硬编码判断,今天把状态改成“已支付”,明天又手动把“已取消”改成别的状态,出了线上问题只能靠人工翻数据。

1.2 翻车现场提炼出的四条改造主线

那次复盘之后,我们给自己定了四条改造主线,也是后面所有工作的骨架:

  • 库存扣减必须原子化,而且要把热点从数据库行锁上挪走;
  • 订单状态必须收敛进一套标准状态机,禁止业务代码随手改状态;
  • 下单请求必须做后端幂等防重,不能只依赖前端按钮;
  • 下单主事务必须瘦身,能异步的全部异步,跨服务的一致性交给最终一致方案。

这四条线每一条都对应了线上一次真实的事故。另外复盘时发现,下单接口的日志只记录了“成功/失败”,没有记录完整的请求参数和关键上下文,导致排查问题时只能靠猜。那之后我们给下单链路加了全链路日志,每个请求从入口到出口都能串起来看,这一步对后续所有问题排查都帮了大忙。

2. 订单状态机重构:从任意改动到按规则流转

2.1 先约束状态,再谈业务扩展

订单领域是典型的状态驱动领域。下单之后,订单会经历待支付、已支付、待发货、已发货、已完成、已取消、售后中这一系列状态变化。新手开发者容易走的弯路是:直接在业务流程里用一堆if语句去改状态,比如超时了就把status改成某个数字。单个业务模块这样做没问题,但订单状态一旦散落各处,就会出两个严重后果。

第一,状态与状态之间的合法性没人把关。系统里可能有人把“已取消”的订单直接改成“已支付”,从代码上看只是几个字段的更新,但从业务语义上这是完全不可信的状态跳跃。第二,新状态或新流程上线时,你根本不知道哪些地方改了订单状态,只能全局搜状态码,改起来提心吊胆。

所以我在重构时坚持引入状态流转规则表,用代码集中定义允许的状态流转,所有改状态的入口都走同一个服务方法。一句话概括就是:状态不是你设置了正确的值,而是你只能沿着合法的路径前进。

2.2 状态机落地:代码结构、合法性校验与审计

落地的代码倒不复杂,用枚举加映射表,在内存里维护一份“当前状态 → 允许变更的目标状态”集合,所有状态变更必须调用同一个方法,方法内部先做合法性校验,不合法就抛异常并记录告警。下面是一个简化版示例,真实项目里可以扩展成配置中心下发:

private static final Map<OrderStatus, Set<OrderStatus>> ALLOWED_TRANSITIONS = new EnumMap<>(OrderStatus.class); static { ALLOWED_TRANSITIONS.put(OrderStatus.WAIT_PAY, EnumSet.of(OrderStatus.PAID, OrderStatus.CLOSED)); ALLOWED_TRANSITIONS.put(OrderStatus.PAID, EnumSet.of(OrderStatus.SHIPPED, OrderStatus.REFUNDING)); ALLOWED_TRANSITIONS.put(OrderStatus.SHIPPED, EnumSet.of(OrderStatus.FINISHED, OrderStatus.REFUNDING)); ALLOWED_TRANSITIONS.put(OrderStatus.REFUNDING, EnumSet.of(OrderStatus.REFUNDED, OrderStatus.SHIPPED)); ALLOWED_TRANSITIONS.put(OrderStatus.FINISHED, EnumSet.noneOf(OrderStatus.class)); ALLOWED_TRANSITIONS.put(OrderStatus.CLOSED, EnumSet.noneOf(OrderStatus.class)); ALLOWED_TRANSITIONS.put(OrderStatus.REFUNDED, EnumSet.noneOf(OrderStatus.class)); } public static OrderStatus changeStatus(Order order, OrderStatus target) { OrderStatus current = order.getStatus(); if (!ALLOWED_TRANSITIONS.get(current).contains(target)) { throw new IllegalStateException("非法状态流转: " + current + " -> " + target + ", orderId=" + order.getOrderId()); } order.setStatus(target); saveStatusFlowLog(order, current, target, ""); return target; }

很多人觉得这样写有点“官僚”,但实际线上遇到“订单状态莫名其妙串了”的问题时,你就会明白这层校验的价值。状态变更流水表是强烈建议加的,哪怕只记录“订单号、旧状态、新状态、操作来源、时间”这五个字段,事后排查和人工翻数据库完全不是一个体验。尤其是退款、补单这类高风险操作,审计流水几乎是唯一可靠的还原手段。

另外一个细节:状态码不要裸奔。数据库里可以存数字,但应用层必须用枚举对应,避免在业务代码里写“status == 2”这种魔法数字。不然等代码量上来,光靠人脑记哪个数字代表哪个状态,迟早出事。

2.3 超时关单的可靠性方案:延迟消息与支付对账

订单状态机里,“待支付超过时间限制自动关闭”这个动作最容易出事故。网上常见的做法是定时任务每分钟扫一次表,把超时订单批量关单。订单量小的时候没问题,量一大,每次扫库压力相当可观。更关键的是扫描粒度很难控制:扫太快担心系统压力,扫太慢订单超时后又多活了一阵子,用户可能还在付款界面,结果系统已经把订单关了。

我们的做法是订单创建成功后,立刻发一条延迟消息,延迟时间等于支付超时时间。延迟消息到期后,消费端收到消息,再判断这个订单当前是否还是待支付状态,是就执行关单。这样每个超时订单只被精准处理一次,不需要反复扫库。

但这里有一个必须补上的环节:关单前要跟支付平台做一次实时查询对账。为什么?因为支付回调是有延迟的,可能存在一个场景:用户已经付款了,支付回调还没到我们系统,这时候订单在数据库里还是待支付状态,如果我们直接关单,就会出现“用户钱付了、订单却被关了”的严重资损事故。所以关单消费逻辑的第一步是查支付状态,确认未支付才允许关;如果查询发现已支付,就先把订单状态改成已支付,再走后续发货流程。

3. 库存扣减方案之争:乐观锁、悲观锁与Redis预扣怎么选

3.1 超卖是怎么发生的:一个竞态问题的还原

库存超卖的本质,是一个再典型不过的并发竞态。老代码里扣库存的逻辑是:先查一遍剩余库存,程序里判断数量够不够,够的话再执行UPDATE减库存。这个流程在并发场景下有个致命窗口:两个请求同时查到库存还剩1件,两个都判断可以卖,然后都去执行UPDATE,库存就变成-1了,超卖就这样发生了。

有人说把UPDATE改成UPDATE stock SET count = count - 1 WHERE sku_id = ? AND count > 0就能解决。确实,这条语句本身是原子操作,不会出现两个线程同时把1改成-1的情况。但问题在于,如果扣减成功了、订单却没创建成功,库存就凭空少了;如果只是判断数量而先不扣减,后面肯定还得面临“再扣一次”的逻辑。所以库存扣减方案不是写一条SQL那么简单,它要跟订单创建流程、失败补偿流程整体串联起来。

3.2 三种扣减方案的原理与差异

我们在重构过程中认真对比过三种主流方案:数据库乐观锁、数据库悲观锁、Redis预扣加异步同步。

乐观锁的做法是在库存表加一个version字段,更新时带上version条件,更新成功才表示这次扣减成功。优点是实现简单、没有额外中间件依赖;缺点也很明显,并发一高,大量请求更新失败,用户看到的体验就是“明明有货却抢不到”,而且行锁竞争依然在数据库层,热点SKU还是会把单行打得很难受。

悲观锁是SELECT ... FOR UPDATE先把库存行锁住,再判断、再更新,最后释放锁。逻辑上最安全,但持锁时间长,并发能力非常低。在秒杀这种场景下,悲观锁基本是给数据库上刑,连接池很快就会被锁等待占满。

Redis预扣方案则是把库存放到Redis里,扣减动作通过Lua脚本完成,脚本内部判断并原子更新。因为Redis本身是单线程执行命令,Lua脚本执行期间不会被其他命令打断,所以“检查库存是否充足”和“扣减库存”这两步天然没有竞态。扣减成功后,再通过消息队列异步把结果同步回MySQL,数据库只做最终记录和校验,不再承担高并发热点压力。

三种方案放在表里看会更清楚:

方案并发能力一致性保障实现成本主要瓶颈
数据库乐观锁中等,冲突高时大量失败强,更新自带原子条件低数据库行锁、大量重试
数据库悲观锁低,锁等待严重强,全程持锁低锁时间太长、容易拖垮连接池
Redis预扣 + 异步同步高,热点key可拆散最终一致,需补偿机制中偏高缓存与数据库的一致性维护

3.3 Redis预扣+Lua脚本:我的最终选择与踩坑记录

我们最终选了Redis预扣方案,原因是它对热点SKU的支持最好。直接用Redis一个key存某个SKU的库存,扣减脚本大概长这样:

local stockKey = KEYS[1] local qty = tonumber(ARGV[1]) local stock = tonumber(redis.call('GET', stockKey)) if not stock then return -2 -- 库存不存在,说明商品数据还没初始化 end if stock < qty then return -1 -- 库存不足 end redis.call('DECRBY', stockKey, qty) return stock - qty

调用方拿到返回值后,小于0就说明扣减失败,直接返回“库存不足”;大于等于0说明扣减成功,再去创建订单。因为Lua脚本在Redis里是原子执行的,不存在“查询时够、更新时不够”的竞态。

实际落地时踩过的几个坑值得说。第一个坑是库存key的初始化与同步。库存数据不能拍脑袋写死在Redis里,必须有一个初始化任务从数据库加载,加载之前还要确认没有并发扣减正在执行,否则可能出现“数据库500件、Redis里只有400件”的错位。第二个坑是Redis崩溃或主从切换时,库存数据可能丢失。我们的兜底方案是给库存扣减操作全程记录流水,Redis不可用时直接降级到MySQL乐观锁方案,虽然性能变差但保证不超卖。第三个坑是扣减成功之后,下单主流程可能失败,比如优惠券计算抛异常、订单插入失败,这时候Redis里的库存已经扣掉了,必须补偿回去。

3.4 扣减成功但下单失败:库存回补的幂等设计

库存回补看着简单,做起来坑不少。最简单的做法是在catch块里直接执行一次INCRBY stockKey qty,但要注意两个问题。

第一个问题是回补消息可能被重复消费。如果回补逻辑是通过MQ异步执行的,消息重试就可能导致回补两次,库存反而多了。解决办法是给回补消息带一个唯一业务ID,消费端记录处理流水,重复消息直接跳过。

第二个问题是回补的时机。如果订单还在创建事务中,只是响应超时,其实订单可能已经创建成功了,这时贸然回补库存,就会出现“订单存在但库存又加回去”,后续其他用户也能买这件商品,库存数据和订单数据就对不上了。所以正确的做法是先查订单到底存不存在、状态是什么,再决定是回补库存还是继续走订单创建成功后的流程。

4. 防重复下单:前端按钮不可信,后端幂等才是兜底

4.1 置灰按钮为什么挡不住重复请求

很多系统的第一道防重复下单手段是前端按钮置灰、加载中遮罩。这招对普通用户很有效,但拦不住三类场景:一是弱网环境下用户点击后前端没及时更新状态,请求已经发出去了,用户又点了一次;二是用户通过浏览器的后退、刷新,重新提交了上一次的表单;三是脚本或拨测工具模拟重复请求。所以我的结论一直是:前端防重是体验优化,不是可靠性保障,真正的兜底必须在后端。

4.2 幂等键与防重表:后端兜底方案

后端防重最常用的方案是幂等键加防重表。流程是这样的:客户端在下单前生成一个唯一的幂等键,通常是一个UUID,把它跟着下单请求一起提交到后端。后端在处理请求时,先尝试往防重表里插入一条记录,记录包含用户ID和幂等键,这张表在数据库里对“用户ID+幂等键”建了唯一索引。

如果插入成功,说明这是一个新的请求,继续执行下单逻辑;如果插入时抛了唯一键冲突异常,说明这个幂等键已经在处理中,要么是重复提交,要么上一次请求还没完成,这时候直接把已有订单或处理中的状态返回给用户,而不是再创建一遍订单。

这个方案的关键在于“插入防重记录”和“创建订单”不能是两个独立宽松的步骤,否则还是会出现两个请求同时通过检查。我的做法是:防重表插入和订单创建放在同一个本地事务里,防重表先行插入,如果后面的下单步骤失败,事务回滚时防重记录也跟着回滚,这样用户重试时重新插入防重记录,就能再次尝试下单,不会被一次失败锁死。

4.3 用户主动重复下单的规则限制

还有一个和“防重”容易混淆的问题:同一个用户过了几秒又手动下一次单,这算不算重复下单?从系统设计角度看,这不是请求幂等问题,而是业务规则问题。防重表只能拦住完全相同的请求,拦不住用户“想再买一次”的意图。

针对这类场景,很多电商系统会在业务层面做限制。比如用户对同一SKU在短时间内只能有一张待支付订单,下单前先查最近是否有未支付订单,有的话直接提示“您还有待支付的订单”,并返回已有的订单信息。这个限制不是绝对的,有些业务允许用户故意多下一单,要按产品定义来决定。但从防止资损和用户体验角度说,我建议至少在爆款商品上做这个限制,能大幅减少“下了单忘了付、又下一单”的混乱。

5. 下单长事务拆分:本地消息表与最终一致性的落地实践

5.1 服务拆分后,下单为什么不能再靠单库事务

早期单体阶段,订单表、库存表、优惠券表都在同一个数据库里,一个下单请求开启一个本地事务,插入订单、扣库存、减优惠券,全部提交,逻辑上没有任何问题。但到了订单中心、库存中心、支付中心各自独立的阶段,跨服务的本地事务就不存在了。你不可能用一个数据库事务去控制另一个服务里数据的提交或回滚。

那分布式事务怎么解决?最理想的是强一致方案,但强一致往往意味着参与者全部锁定资源、两阶段提交或三阶段提交,性能开销在大促场景下很难接受。电商下单这类业务其实并不需要每时每刻强一致,绝大多数情况下追求的是最终一致:比如库存扣减晚几秒同步回数据库没关系,只要对账系统能在延迟窗口内发现问题并修正。

5.2 本地消息表方案:一次下单的完整数据流

我们用的是经典的本地消息表方案。下单主流程开启一个本地事务,在这个事务里做两件事:一是插入订单记录,二是往消息表插入一条目标为库存服务、状态为待投递的消息。这两个表在同一个库里,所以这个事务可以保证“订单创建了,消息一定也记录了;订单失败了,消息也不会发出去”。

事务提交之后,后台有一个消息投递任务,不停地把消息表里待投递的记录捞出来,发送到MQ。库存服务消费到这条消息后,执行真正的库存扣减,扣减成功后再通知订单中心确认扣减结果。如果库存服务扣减失败,它会发一条回滚消息给订单中心,订单中心把订单置为已取消,并触发Redis库存回补。

这个方案的精髓就一句话:把“本地数据库操作”和“发送消息”绑定在同一个事务里,避免了先发消息后数据变更失败导致的消息与数据不一致。实现成本不高,却解决了“消息要发出去”和“数据要成功”之间的一致性矛盾。

5.3 可靠性兜底:消息重投与消费幂等

本地消息表方案有两个必须处理的可靠性细节。第一,消息投递任务不能只投一次就放弃。MQ在投递过程中可能出现网络抖动,投递了但消费端没收到,所以消息表里必须有一个逾期未确认的状态,定时任务把超过一定时间还没被标记为已投递的消息捞出来重新投递。第二,消费端必须对消息做幂等处理。MQ消费本来就有“至少一次”的语义,同一消息可能被重复投递,库存服务的扣减动作又必须只能生效一次,所以需要在消费端维护一个消息消费流水表,记录每个消息ID是否已经处理过,重复消息直接跳过。

这两个细节少一个,线上早晚会出数据混乱。我在最初版本忽略了消费幂等,结果有一次MQ重置偏移量导致一批重复消费,库存被多扣了,最后靠对账脚本和人工处理才修正过来。从那以后,“消费幂等”就写进了代码规范。

5.4 为什么不直接用分布式事务框架

可能有人会问:现在不是有很多分布式事务框架吗,干嘛自己搞一套本地消息表?我的观点是,框架当然可以用,但下单链路这样的核心链路,对稳定性、性能和故障排查的要求都非常高。分布式事务框架通常需要引入额外的协调者,参与者越多,整体耗时越长,而且一旦协调者出问题,排查和恢复的复杂度会显著上升。对于库存扣减这种天然异步化、又允许短时间不一致的场景,最终一致方案已经足够,没必要为了“看起来更高级”把核心链路的复杂度抬上去。

6. 流量高峰下的下单链路优化:限流、削峰与热点治理

6.1 热点SKU带来的锁竞争与缓存雪崩隐患

大促活动里,爆款SKU往往是整个系统压力的集中点。一个爆款商品可能承担几十万次的下单请求,Redis里它的库存key就是一个热点key,所有请求都打到这一个key上。虽然Redis单key的读写性能很高,但请求量太大时,网络带宽和单次执行时间都会被放大,主从节点之间的同步也更容易出现毛刺。

处理热点key的思路有两个方向。一是把读压力分散:商品详情这类读多写少的场景,可以在本地进程里加一层缓存,或者对同一个key做多副本,分片路由到不同节点,减少单点压力。二是把写压力排队:库存扣减是写操作,没法像读一样随意分片,这时候可以引入队列,把针对同一个SKU的扣减请求排队,一个一个扣。虽然单个请求的响应时间稍有增加,但整体不会因为并发争抢而雪崩。

6.2 MQ削峰填谷:把非核心动作移出下单主链路

下单主链路需要做的事情其实很少:校验商品状态、计算价格、校验并预扣库存、创建订单、响应前端。优惠券核销、积分赠送、短信通知、物流预测、数据统计这些动作,完全可以在订单创建成功后通过MQ异步完成。削峰效果非常明显,数据库的压力从“一个请求要跑十个操作”降到了“一个请求只跑两个核心操作”。

在设计异步任务时要注意队列的消费能力和积压情况。如果大促瞬时峰值是每秒一万单,而优惠券系统每秒只能消费一千条,队列就会快速积压。这时候不要盲目加消费线程,先确认下游系统的处理能力,必要时做批量消费,比如一次拉取一百个订单ID统一处理,吞吐会大幅提升。

6.3 下单接口限流与分库分表后的幂等策略

接口限流是最后一道保护阀。下单接口必须做限流,不然流量超过系统承载上限时,整个链路都会进入不可控的雪崩状态。我常用的限流方案是令牌桶配合用户维度限流:同一个用户每分钟最多允许下单N次,超过就直接拒绝,并提示“操作过于频繁”。再加上全局维度限流,超过总容量的请求直接进排队页,或者用轮询代替同步结果返回。

到了订单量很大的阶段,订单表迟早要分库分表。分库分表之后,按用户ID分片的下单幂等策略依然有效,因为防重表可以和订单表使用相同的分片键,同一个用户的请求只会进入同一个分片。但如果是全局维度的唯一约束,比如全局幂等键,那就需要单独建一张全局防重表,或者用Redis做全局唯一判断。这个取舍要在设计期就定好,否则上线后想改代价非常大。

6.4 压测数据与收尾体会

改造完成之后,我们对模拟项目X做了完整的压测。在同样的单机配置、同样的模拟参数下,下单接口单机QPS从改造前的400左右提升到了2800上下,P99延迟从3000毫秒降到120毫秒以内,库存扣减平均耗时从12毫秒降到接近2毫秒。这里要强调,压测数据受机器配置、网络环境、数据量影响非常大,不能直接当基准用到自己的项目里,但它确实反映了“把核心动作做强做短、把非核心动作移出链路”这个方向的正确性。

这个项目做完之后,我个人的核心体会是:下单功能拼的从来不是某个单点技术多炫酷,而是整个链路上每一个设计决策是否稳当。状态机收敛让订单不乱;幂等防重让重复请求不产生资损;库存扣减挪出数据库热点让性能扛得住大促;瘦身后的下单事务加最终一致兜底让故障面可控。这些都是很朴素的手段,组合起来却异常可靠。如果再做一次,我还是会按照同样的顺序推进:先理清状态和幂等,再谈性能优化。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询