资讯动态

电商下单系统重构实战:状态机、库存扣减与幂等设计

发布时间:2026/10/21 21:32:39 来源:尧图企业网站定制
做电商系统的开发者早晚都要跟下单功能硬碰硬。我接手过一套看起来“能跑”的下单模块平时日单量几千的时候什么问题都没有大家觉得稳得很结果第一次上大促活动就出了大事故。那天开售前二十分钟监控开始疯狂报警下单接口的P99延迟从50毫秒一路涨到了3000毫秒数据库连接池被打满支付回调大面积超时订单卡在待支付状态没人处理客服那边堆了几百条“付了钱却查不到订单”的投诉。那次之后我们重构了整整两个月踩过的坑基本能写成一本书。这篇文章就把这套电商下单功能从翻车到稳定的完整过程梳理一遍重点讲订单状态机、库存扣减、防重复提交、事务一致性和大促性能优化这几块适合正在做订单模块或者想把“能用但一上线就闹脾气”的下单逻辑重写成稳定版本的开发者。1. 第一次大促翻车现场下单链路是怎么被打挂的1.1 复盘崩的不是服务器是下单逻辑本身老代码的逻辑直接得不拐弯查商品信息先走数据库库存扣减是“先SELECT再UPDATE”两步创建订单和扣库存放在同一个事务里下单主流程里还同步调用优惠券算价、积分赠送、物流预计算。听起来不算离谱但在大促流量进来后问题被放大到了无限大。首先爆发的是锁竞争。两个爆款SKU撑起了60%以上的下单流量库存表里对应的两行记录成了全系统最拥堵的两行。同一时刻几百个线程同时去争抢同一行的更新锁InnoDB的行锁排队时间不断飙升数据库连接被长时间占住连接池很快就见底了。连接不够用之后新请求全部堵在“获取连接”这一步服务整体RT开始雪崩式上升。第二个问题是事务过长。下单逻辑把优惠券推荐、积分赠送这些操作全部包在了一个大事务里一个请求最多要跑好几百毫秒甚至更久。锁持有时间越长后面排队越久数据库资源就被这些“既不必要、又慢”的事务吃光了。第三个问题是缺乏防重和状态约束。用户在弱网环境多点了两下下单按钮系统就生成了好几张待支付订单订单状态在代码里到处是硬编码判断今天把状态改成“已支付”明天又手动把“已取消”改成别的状态出了线上问题只能靠人工翻数据。1.2 翻车现场提炼出的四条改造主线那次复盘之后我们给自己定了四条改造主线也是后面所有工作的骨架库存扣减必须原子化而且要把热点从数据库行锁上挪走订单状态必须收敛进一套标准状态机禁止业务代码随手改状态下单请求必须做后端幂等防重不能只依赖前端按钮下单主事务必须瘦身能异步的全部异步跨服务的一致性交给最终一致方案。这四条线每一条都对应了线上一次真实的事故。另外复盘时发现下单接口的日志只记录了“成功/失败”没有记录完整的请求参数和关键上下文导致排查问题时只能靠猜。那之后我们给下单链路加了全链路日志每个请求从入口到出口都能串起来看这一步对后续所有问题排查都帮了大忙。2. 订单状态机重构从任意改动到按规则流转2.1 先约束状态再谈业务扩展订单领域是典型的状态驱动领域。下单之后订单会经历待支付、已支付、待发货、已发货、已完成、已取消、售后中这一系列状态变化。新手开发者容易走的弯路是直接在业务流程里用一堆if语句去改状态比如超时了就把status改成某个数字。单个业务模块这样做没问题但订单状态一旦散落各处就会出两个严重后果。第一状态与状态之间的合法性没人把关。系统里可能有人把“已取消”的订单直接改成“已支付”从代码上看只是几个字段的更新但从业务语义上这是完全不可信的状态跳跃。第二新状态或新流程上线时你根本不知道哪些地方改了订单状态只能全局搜状态码改起来提心吊胆。所以我在重构时坚持引入状态流转规则表用代码集中定义允许的状态流转所有改状态的入口都走同一个服务方法。一句话概括就是状态不是你设置了正确的值而是你只能沿着合法的路径前进。2.2 状态机落地代码结构、合法性校验与审计落地的代码倒不复杂用枚举加映射表在内存里维护一份“当前状态 → 允许变更的目标状态”集合所有状态变更必须调用同一个方法方法内部先做合法性校验不合法就抛异常并记录告警。下面是一个简化版示例真实项目里可以扩展成配置中心下发private static final MapOrderStatus, SetOrderStatus 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毫秒。这里要强调压测数据受机器配置、网络环境、数据量影响非常大不能直接当基准用到自己的项目里但它确实反映了“把核心动作做强做短、把非核心动作移出链路”这个方向的正确性。这个项目做完之后我个人的核心体会是下单功能拼的从来不是某个单点技术多炫酷而是整个链路上每一个设计决策是否稳当。状态机收敛让订单不乱幂等防重让重复请求不产生资损库存扣减挪出数据库热点让性能扛得住大促瘦身后的下单事务加最终一致兜底让故障面可控。这些都是很朴素的手段组合起来却异常可靠。如果再做一次我还是会按照同样的顺序推进先理清状态和幂等再谈性能优化。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑