尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

订单库存分布式事务落地:本地消息表与事务消息的最终一致实战

订单库存分布式事务落地:本地消息表与事务消息的最终一致实战 上篇把分布式事务的基本概念和几条主干路线理了一遍大家最关心的其实不是哪个方案名字更好听而是落到自己系统里到底该怎么选、怎么写、怎么上线。这篇接着往下走不重复讲理论直接聚焦到最经典也最折磨人的场景——订单与库存的分布式事务把一致性这个词掰开揉碎再把一套能落地的方案从表结构讲到期满失败补偿。我会把篇幅重点放在三块一致性到底是什么级别才够用、订单库存场景的完整拆解、以及本地消息表/事务消息这套组合在生产环境里的真实坑和运营细节。适合已经了解分布式事务基本概念、准备动手改造或正在排查数据不一致问题的后端工程师。1. 这一篇要往下走多远从概念到可落地的世界先回顾一下上篇留下的关键结论分布式事务本质上没有银弹你只能在一致性强度、可用性、性能、业务侵入度之间做取舍。XA两阶段提交能拿到强一致但代价是数据库资源锁持有时间变长、吞吐掉得厉害并且参与者越多越容易出prepare后宕机的尴尬局面TCC把补偿逻辑交给业务方灵活但开发成本极高一个正向接口配一个confirm一个cancel字段多一个都得跟着改Seata AT模式用undo_log做反向补偿看起来省事但全局锁和undo_log在数据量上来之后也有不少隐性成本而本地消息表/事务消息这套思路靠状态机和重试把最终一致落地是目前生产环境里性价比最高、也最容易被团队接受的方式。这篇既然叫第二部分就不再花篇幅罗列概念了重点回答三个实际问题。第一个问题是意识层面的订单和库存到底需要多强的一致性是不是必须做到强一致才不会出事。很多人被数据不能错这句话吓住一上来就奔着强一致去结果引入了大量复杂性最后发现业务根本承受不了那个吞吐和延迟这个方向性问题需要先拎清楚。第二个问题是设计层面的一个标准的下单扣库存流程如果用本地消息表事务消息来推完整链路长什么样。订单服务先写库还是先发消息消息里塞什么内容库存服务收到重复消息怎么兜底订单创建成功但库存扣减失败的那部分单子谁来管——这些问题我会用一个接近真实业务的案例逐步拆开讲。第三个问题是运营层面的方案上线之后怎么知道它还在正常运转。分布式事务和单机事务最大的区别在于单机事务错了马上报错回滚分布式事务错了往往是暂时看不出来要等到对账或者用户投诉才发现。所以幂等设计、兜底对账、降级开关和故障演练这些看不见的工作才是线上稳定的基石。我见过不少团队方案讨论时头头是道一上线就翻车原因基本都一样只设计了一条happy path异常路径全凭嘴说。这一篇我会把happy path和异常路径都写清楚尤其是那些常规文档不会写、但线上一定会遇到的情况。2. 一致性到底是什么先分清你要的是强一致还是最终一致这个坑我踩得很深。早些年做电商订单系统领导一句数据必须强一致我们组就上了XA把所有涉及库存扣减的接口全部包进全局事务。结果压测一跑数据库连接池被打满事务成功率反而下降了因为跨服务的事务分支把连接占用时间拉长了好几倍。后来才想明白很多业务场景压根不需要强一致需要的是最终能对上账。先看清楚CAP这件事在实际系统中意味着什么。CAP说了网络分区不可避免分区发生时C和A只能二选一。分布式事务是把多个节点的数据变更绑成一个逻辑整体这本身就是一种对抗分区的操作。你越追求C就越要在A上付出代价——最典型的就是XA的准备阶段所有参与者把资源锁住等待协调者指令协调者一挂全员卡死。而如果选择A就得接受一个事实在某个时间窗口内各节点数据看起来是不一致的但只要最终收敛到一致对业务来说就是可接受的。这里有个很关键的分层认知数据库层面的强一致单机ACID是要么全做要么全不做而分布式事务追求的往往只是最终结果是正确的。拿用户下单来说用户看到下单成功但这笔订单对应的库存扣减可能还躺在消息队列里等消费这在系统内部确实不一致但用户不关心交易链路也不受影响——只要库存最终被扣掉超卖没有发生这笔交易就是正确的。所以第一个要敲定的问题是你的业务能容忍多大的不一致窗口。转账到银行卡这种场景用户转完账号上没第一时间扣款体验就崩了而且金融监管要求实时幂等这种通常是强一致诉求电商下单扣库存窗口期几百毫秒甚至几秒用户无感完全可以走最终一致积分赠送、优惠券发放这类就更无所谓了延迟十分钟也没人发现。明确了这个窗口你就能决定要不要为了一致性付出那么多代价。第二个要敲定的问题是不一致会不会被自动修复。银行转账扣款没成功会直接挂账必须人等处理但订单超时未支付可以自动关闭并回滚库存——这类业务本身自带补偿机制天然适合最终一致。如果你发现业务里所有的不一致都需要人工介入才能修复那说明要么你的补偿设计有缺陷要么你确实选错了技术路线。还有一点容易被忽略方案选择要看写入量。同一条业务数据每天几百笔和每秒几百笔设计思路完全不同。小流量场景哪怕半夜跑个全量对账脚本都行大流量场景必须把对账和补偿做进正常业务流程里。这也是为什么我在后文推荐本地消息表方案——它对小流量团队友好对高并发大厂也扛得住区别只在于消息表怎么分片、任务怎么调度。3. 订单与库存这个最经典的分布式事务场景怎么拆先给业务定个性质用户下单时订单服务要创建订单库存服务要扣减库存这两个动作分属不同数据库甚至不同机房。常见错误做法是把扣库存和建订单放进一个接口里同步调用一个失败了全部回滚。这个做法在单机时代没问题拆了服务之后就成灾难根源了。问题本质在于跨服务调用的原子性是靠网络请求成功返回来判断的但网络请求超时了你分不清对方到底是执行成功还是执行失败。同步调用的回滚也难做订单已创建但库存扣减接口超时这时候你回滚订单可能用户已经看到了订单号也可能支付回调已经打进来了整个状态就乱套了。所以订单与库存事务必须拆成本地事务 异步消息的方式把跨节点的原子性问题转化成单节点的原子性问题再靠消息投递补偿来解决。拆分之后的理想流程是这样下单请求进来订单服务在自己的数据库里开启一个本地事务插入订单主记录同时往本地消息表插入一条待发送状态的扣库存消息一起提交。本地事务提交成功意味着订单创建 扣库存意图已经持久化接下来由发送端组件把消息投递到MQ投递成功后更新消息状态。库存服务消费到消息后在自己的数据库里执行扣减扣减成功回执一个确认消息订单侧看到确认后更新消息状态为已完成。整个过程没有跨库事务每一步都是本地操作唯一的协调者就是消息表的状态机。再看扣库存的顺序问题。是先扣库存再建订单还是先建订单再扣库存我推荐先建订单再通过消息触发扣库存。原因很简单订单才是用户感知的业务实体库存是一种资源。先建订单哪怕扣库存最终失败了我们可以把订单标记为异常并让用户重试反过来先扣库存再建订单库存扣掉了但订单没建出来用户根本不知道发生了什么你还得半夜跑脚本去找那些消失的扣减排查成本高得多。还有一条更贴合电商业务的细节不要把库存扣减设计成直接扣实物库存而是要引入预占/冻结模型。用户下单那一刻库存服务冻结N件改变的是冻结字段用户支付成功后再把冻结转为实际扣减如果订单超时未支付被取消再执行解冻。这个模型的好处在于库存的变更被分成了预占和确认两个阶段中间隔着很长的支付窗口你根本不需要保证订单状态和库存扣减在同一个瞬间强一致只要支付完成时确认扣减、支付失败时解冻释放两边最终一定对得上。那订单创建成功但扣库存消息弄丢了呢这事不能只依赖MQ重试兜底。设计上必须让订单是否存在和库存扣减是否发生这两个事实产生对照关系。最简单的做法是给订单表和库存扣减记录都带上同样的业务单号然后一个定时任务每天扫描订单表找出已支付但库存扣减记录不存在的订单主动触发补偿扣减。这个兜底对账任务比你在MQ上堆一万次重试都管用。再往下就是性能考量。为什么上MQ而不直接同步RPC调用除了解决超时无法判定结果的问题还有一个被低估的原因是削峰。大促场景下瞬时下单量可能是平均值的几十倍同步调用库存服务库存数据库直接被流量打垮但放到MQ里消费端可以按自己的最大处理能力慢慢拉队列积压一点没关系只要最终扣完就行。削峰填谷本身也是最终一致的一部分——你换取了短暂的延迟换回了系统的存活。4. 本地消息表 事务消息的落地细节这套方案到底怎么写得滴水不漏先说说本地消息表这套设计的核心思想它把消息发送这件事也变成了一个本地事务的一部分。你要发消息不是直接调用MQ的send而是先在自己的库里插入一条消息记录然后在同一个事务里完成业务操作和消息记录插入事务提交后再由一个后台任务或发送器去真正投递这条消息。这样消息一定不会丢——因为消息记录和业务数据在同一个库里要么同时成功要么同时失败。所以我特别不推荐那种先发MQ再执行本地操作的写法。你发了消息MQ也拉了消息也消费了结果本地数据库事务提交失败这不就乱套了吗反过来的先本地操作成功再发MQ也有问题本地事务提交了发送MQ时网络抖动消息没发出去库存就没扣。本地消息表就是把这个窗口期消灭掉消息先落库本地事务提交后再投递投递失败就重试重试失败还有定时任务扫描。消息表的结构样例如下字段不多但每个都有用处CREATE TABLE local_message ( id BIGINT AUTO_INCREMENT PRIMARY KEY, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型ORDER_CREATE / STOCK_DEDUCT, biz_id VARCHAR(64) NOT NULL COMMENT 业务单号对应订单号, content TEXT NOT NULL COMMENT 消息体如扣减库存所需的信息, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2已完成 3死亡, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, KEY idx_status_retry (status, next_retry_time), UNIQUE KEY uk_biz (biz_type, biz_id) ) ENGINEInnoDB;投递消息的后台任务逻辑也简单定期扫描status0且next_retry_time小于当前时间的记录把content反序列化后发送到MQ发送成功的标记为已发送发送失败的重试次数加一下次重试时间按指数退避来计算。超过最大重试次数就标成死亡状态触发告警让人工介入。这套东西看起来很朴素但稳定而且消息表就在本地库做事务、做查询都很顺手。RocketMQ的事务消息是本地消息表思路的一个变种把消息表搬到了MQ内部。基本流程是发送端先把消息以half状态投递到BrokerBroker此时不把消息推给消费者发送端收到half成功回执后开始执行本地事务本地事务提交则再次通知Broker把消息标记为可消费本地事务回滚则通知Broker删除消息如果发送端在本地事务执行过程中宕机了Broker会定时回查发送端发送端根据本地事务的结果回应该消息的状态。这个机制天然解决了本地消息表和MQ之间的双写问题也是现在很多生产系统选择RocketMQ事务消息的原因。不管用哪种方式消费端的幂等设计都是生死线。消息队列为了保证不丢消息默认至少送达一次也就是说同一消息可能被重复投递。库存扣减如果没做幂等重复消费一次库存就多扣一次超卖就是这么来的。幂等的常规做法是消费端建一张去重表以业务单号或消息ID为唯一键处理消息前先尝试插入这条处理记录插成功了才真正执行业务逻辑插不进去说明已经处理过直接返回成功。这个去重表的唯一键必须包含业务单号不能只靠MQ的messageId因为不同消息可能携带同一个业务单号。还有消息顺序的问题。在订单库存场景里同一笔订单的消息不是很多但理论上可能有一条扣库存和一条释放库存的消息先后发出。如果这两条消息落到不同队列被并行消费就可能出现释放先执行、扣减后执行的倒序结果库存数据直接错乱。解决思路是按订单号做hash让同一订单的消息都进同一个消费队列消费端单线程或分区顺序消费。大部分业务消息其实没有全局顺序要求只有同一实体的局部顺序要求用分区顺序就够了。个人建议不要一上来就直接用MQ的事务消息。先从本地消息表起步等整个链路跑顺了、重试和幂等机制都验证过了再考虑要不要引入RocketMQ事务消息来简化模型。本地消息表和事务消息在一致性的最终效果上没有本质区别区别只是在消息表归谁管——自己管还是MQ帮你管。自己没有额外中间件、团队对MQ不够熟的时候本地消息表反而是风险更低的方案。5. 线上跑起来的那些事幂等、对账、降级与故障演练方案设计得再漂亮一旦进了生产环境考验就从能不能实现变成了能不能长期稳定运行。这一章我要讲的是那些不进技术方案文档、但真正决定系统口碑的运营细节。先说对账任务。用最终一致方案就意味着你必须接受系统在某个时刻是不完全一致的所以定期检查并纠正不是可选项是必需项。我见过有团队本地消息表上线半年都没做对账靠MQ控制台看到有死信才去处理结果一堆订单和库存对不上。对账任务不要设计得太复杂核心就是对两组数据找出订单已支付但库存扣减记录缺失的异常数据以及订单已取消但冻结库存未释放的异常数据。每天凌晨跑一次发现问题发送到告警群同时向补偿逻辑发送修复指令。SQL和逻辑可以先做出一版最简单再根据报警量慢慢完善。监控指标也要围绕最终一致这条链路来建。队列积压数量、最老消息的堆积时间、消息死亡数量、重试次数分布这四个指标能覆盖大部分问题。最老消息堆积时间尤其重要——一个队列如果出现了特别旧的消息往往不是消费慢而是某一条消息卡住导致后续消息无法继续消费这时候要赶紧排查业务逻辑里的死循环或者外部依赖超时。死亡消息告警更要即时能做到分钟级最好因为死亡消息背后往往是业务异常拖得越久数据偏得越远。降级方案是很多人忽略的。MQ挂了怎么办消息表越积越多线上真正出问题的时候你需要的不是更完善的分布式事务方案而是能让业务先跑起来的临时手段。我见过比较务实的做法是生产环境保留一个开关当MQ不可用时扣库存的调用从异步转同步RPC直接请求库存服务接口即使同步调用超时订单也能标记为失败让用户重试总比用户下单后库存一直没扣要安全。这个开关是要提前写好的不是出事的时候临时改代码。主从延迟也是个容易踩的坑。后台任务在扫描本地消息表或对账时如果扫的是从库而主从复制有延迟可能把刚提交的事务漏掉或者把还没完全同步的数据当成死信处理。建议扫描任务直接连主库或者对延迟敏感度高的查询统一走主库避免因为一层复制延迟导致误判和误补偿。最后一定要做故障演练。分布式事务方案的可靠性不是靠代码review看出来的是靠故障演练练出来的。最简单的演练是杀掉消费者进程观察消息积压情况再启动进程看消息能不能继续消费积压。更狠一点的演练是模拟MQ broker挂掉看本地消息表是不是正常积压、恢复后是不是能自动重发。还有更极端的——主库磁盘写满、订单服务重启导致事务消息状态都没更新这套补偿机制能不能把数据追回来。这些演练做下来你对自己系统的信心比看十篇文档都管用。我用这套本地消息表 定时补偿 兜底对账的组合帮两个团队落地过订单库存场景的事务改造最终效果都差不多正常流量下延迟没有明显增加大促流量下通过MQ削峰保证了库存服务的可用性最重要的是数据从来不需要人工修。倒是早期做TCC的那段时间每天半夜收到补偿失败告警是常态后来才意识到很多业务场景的根本不需要那么强的可控性把状态机和重试设计好最终一致带来的收益远超那一点点延迟窗口。对了如果你刚开始改造给消息消费端加个处理耗时日志线上排查不一致问题的时候你会发现它比任何监控大盘都好使。
返回列表