ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

分布式事务实战:微服务数据一致性方案与Seata落地避坑指南

分布式事务实战:微服务数据一致性方案与Seata落地避坑指南 下单成功却扣不了库存、支付回调重复处理导致订单状态错乱、积分加了却发货失败……这些线上问题我估计干过微服务的同学都遇到过。分布式事务这个概念被聊了很多年网上资料也一大堆但真要落地的时候坑比想象中多得多。这篇博客我打算以订单 库存这条最经典的链路为主线把分布式事务到底在解决什么问题、有哪些主流方案、实际工程里怎么选型、框架怎么落地、以及那些文档里不会写的坑一次讲透。适合谁来读呢工作一两年、正在从单体转向微服务的后端开发或者已经在用 Spring Cloud 但被数据一致性问题困扰的团队。读完不敢说让你变成分布式事务专家但至少再遇到这类问题你能有个清晰的判断框架知道从哪下手。1. 分布式事务到底在解决什么问题1.1 从一个下单场景说起你想想看最开始做单体应用的时候下单就是在一个数据库里执行几行 SQL开个事务要么全成功要么全失败数据怎么都对得上。但微服务一拆订单服务管订单库库存服务管库存库一次下单操作要跨两个服务、两个数据库单靠本地事务就管不住了——你没法在自己的数据库事务里去控制另一个服务的提交或回滚就像你在自己支行的柜台上没法直接把别人账户里的钱改掉。于是出现了这种情况订单表里多了一条未支付订单库存却已经扣了或者库存扣了订单压根没生成成功。用户层面看到的就是付了钱没货或者莫名其妙被退款这在生产环境里就是事故级别的问题。这里我想先纠正一个误区很多人一说到分布式事务第一反应是要用什么框架、什么中间件。但实际上先要搞清楚的是你到底需不需要引入分布式事务。很多场景根本不需要比如通过接口设计的调整把跨服务操作合并到单服务内或者把强一致降级成最终一致问题自然就消失了。1.2 判断标准两个以上独立数据源同时变更什么情况下才需要认真考虑分布式事务我总结了一个判断标准一次业务操作中有两个以上独立的数据源数据库、缓存、搜索引擎等需要同时变更业务上又不允许中间状态被外部观察到这时候你就躲不掉了。典型场景列几个订单 库存下单要写订单表、扣库存表两个表几乎肯定不在一个库。账户 积分支付成功要给用户加积分积分加失败用户就来找你。订单 优惠券下单要核销优惠券订单取消了还得把优惠券还回去。余额 流水资金扣减和流水记录哪怕在一个库里对一致性要求也极高。这些场景的共同点是数据之间有业务强关联但物理上又没法放在同一个数据库事务里。这就是分布式事务存在的全部理由。1.3 强一致与最终一致先统一认知接下来必须先把一个概念讲清楚分布式事务不可能做到和本地事务一模一样的强一致这是 CAP 理论决定的你只能在可用性和一致性之间做取舍。CP 方案牺牲部分可用性去换取强一致典型代表是 2PC/XA。AP 方案优先保证可用性数据通过重试、补偿、对账等手段最终达到一致典型代表是 TCC、SAGA、本地消息表、MQ 事务消息。互联网业务绝大多数都选 AP 加最终一致因为用户等不起。你想想下单时如果因为库存服务抖动让用户一直转圈等一个分布式事务提交几分钟过去用户早跑了。但如果是资金清算、账务核心这种场景一致性优先级极高那就可能要用更接近强一致的方案。我的建议是选型不是为了追求技术上的高级而是匹配业务对一致性、性能、开发成本的容忍度。这个原则会贯穿后面的所有方案对比。2. 主流的分布式事务方案逐个拆解2.1 2PC/XA教科书方案的真实处境两阶段提交2PC是所有教材都会讲的基础方案它是这样工作的有一个协调者先给所有参与者发 prepare每个参与者执行完本地事务但不提交把资源锁住然后向协调者返回准备好了。协调者收到全部成功响应后再发 commit 让所有参与者真正提交任何一个人失败就发 rollback 让所有人回滚。从模型上看它非常严谨MySQL、Oracle 原生支持 XA 协议。但到了微服务架构里它的落地体验相当糟糕我列举几个问题事务期间参与者资源一直被锁定。数据库行锁、表锁长时间不释放在高并发下连接池很快被打满吞吐量急剧下降。协调者是单点。协调者一旦挂了所有分支事务全部卡在中间态恢复起来非常痛苦日志和状态处理是噩梦。通信次数多、链路长。二阶段提交本身就要多轮交互再叠加跨服务的网络调用延迟很难看。说句实在话XA 更适合还在用单体但存在多库的系统或者内部系统里对一致性要求极高、并发要求不高的场景。把它用在面向 C 端的订单链路上大概率要出大事。2.2 TCC用业务补偿换不锁资源TCCTry-Confirm-Cancel是目前业界用得比较多的一种方案核心思想是把一个业务操作拆成三个动作Try资源预留 业务检查。比如下单时先检查库存够不够够了就把数量冻结起来但不真正扣减余额够不够够了就先冻结金额。Confirm确认执行真正扣减。Try 全部成功之后调用要求必须成功。Cancel取消把 Try 阶段预留的资源释放掉。比如释放冻结库存、解冻余额。TCC 最大的优点是不锁数据库资源把锁的粒度从数据库锁转移到了业务状态所以并发性能和吞吐量比 2PC 好得多。但它也有明显的代价业务侵入太重每个接口都要额外实现 Try、Confirm、Cancel 三套逻辑还要处理空回滚、悬挂、幂等等一堆细节。实际项目里 TCC 最经典的用法是预占库存模式下单时把库存扣到冻结库存支付成功再把冻结库存变成已售支付失败或超时就释放冻结库存。这个模式看起来简单但里面坑很多我后面专门有一节讲。2.3 本地消息表最朴素的最终一致本地消息表是很多老系统在中间件还不成熟时的选择思路很简单但很实用业务操作所在的那个库里建一张消息表。业务数据和消息记录在同一个本地事务里一起写入。一个定时任务扫描消息表里状态为待发送的消息投递到 MQ。消费方处理成功后通过回调或者任务反查把消息状态改成已发送。这个方案的好处是理解成本低不依赖任何额外的分布式事务中间件。最关键的是消息不会丢因为消息和业务数据是同库同事务写入的要么一起成功要么一起失败天然一致。缺点是消息表会随业务量膨胀定时扫描有延迟而且每个业务都要维护一套消息表的读写、重试、清理逻辑工程上确实比较土。但现在很多系统虽然不叫本地消息表本质思路是一样的业务表 状态机 定时任务做对账补偿。包括后面要讲的 MQ 事务消息本质上也是把本地消息表内嵌到了消息中间件里。2.4 MQ 事务消息把本地消息表搬进 BrokerRocketMQ 的事务消息是现在很多人首选的一种方案它的机制看着复杂拆开其实就几步生产者发一条 half message半消息到 Broker这个半消息对消费者不可见。生产者执行本地事务。本地事务成功提交半消息消息对消费者可见本地事务失败回滚半消息消息被删除。如果生产者执行本地事务的过程中挂了或者一直没返回结果Broker 会隔一段时间主动反查生产者的本地事务状态根据结果决定提交还是回滚这就是所谓的事务状态回查。这里要特别澄清一个容易混淆的点Kafka 原生不支持这种事务消息模式。Kafka 经常被提到的 EOS精确一次语义主要解决的是流处理场景下生产-消费之间的一致性和业务层面的分布式事务不是一回事。所以如果团队技术栈是 Kafka又想用事务消息一般得靠前面说的本地消息表或自己封装。另外必须提醒一句事务消息只解决生产者本地事务和发消息这两件事的一致性消费端能不能正确处理还要靠消费端的幂等和重试兜底。很多人以为用了事务消息就一劳永逸了其实消息发出去之后的故事才刚刚开始。2.5 SAGA长事务的编排与补偿SAGA 是一个提出很早的长事务解决方案核心思想是把一个大型业务事务拆成若干本地事务每个本地事务都配一个补偿事务。执行过程中任何一步失败就按逆序执行前面步骤的补偿操作把整个链路拉回初始状态。SAGA 有两种实现模式编排Choreography没有中央协调者每个服务完成后自己发布事件触发下一个服务形成一条事件链。协调Orchestration有一个专门的协调者告诉每个服务该做什么成功后触发下一步失败后触发补偿链路状态集中管理。在订单 库存 积分 优惠券这种长链路场景里SAGA 特别合适。比如下单主流程是创建订单 → 扣库存 → 加积分 → 发券如果加积分失败就逆序执行释放库存 取消订单的补偿。SAGA 比较考验业务方对补偿动作的设计能力补偿逻辑必须可重入而且要干净不能补偿的时候又搞出新的脏数据。另外SAGA 的最终一致窗口期比较长中间状态对用户是可见的所以对用户展示上要有处理中这类状态的引导设计。2.6 最大努力通知所有方案的兜底策略最大努力通知经常被忽略但它其实是分布式事务里非常重要的一张安全网。它的核心是发起方通过定时任务、重试队列不断重试将结果通知给接收方直到成功或者达到业务允许的最大重试次数后放弃。它不保证消息一定送达也不保证时间所以叫最大努力。接收方必须自己做好幂等和状态判断来接住。举两个典型例子支付平台回调商户系统回调失败会重试多次商户系统必须保证回调处理幂等订单超时未支付自动关单本质也是一种最大努力配合定时任务和对账去修正最终状态。你发现没有最大努力通知并不独立构成一个分布式事务方案但任何方案落到生产环境都需要它来兜底。这也是为什么有的团队会专门做一个对账系统每天定时扫描核心表的数据偏差就是因为分布式事务在极端情况下总会有漏网之鱼而对账就是最后一道防线。3. 实战中的选型思路与框架落地3.1 先做一张方案对比表聊完方案很多人会问那到底该选哪个我给一张对比表方便按业务情况快速过滤方案一致性性能影响业务侵入适用场景2PC/XA强大锁资源小多库单体、低并发强一致场景TCC最终一致小大三接口高并发、资金/库存预占场景本地消息表最终一致小中无 MQ 事务能力的老系统MQ 事务消息最终一致小中生产者本地事务 异步消费链路SAGA最终一致小中需补偿接口长链路、业务流程化场景做选型时有几个点要提醒强一致不等于更好关键看业务能不能等、能不能接受补偿。用户支付明明成功了结果因为协调者抖动告诉用户失败这种体验损失比短暂的最终不一致更严重。业务侵入度和一致性粒度是变相关的。TCC 侵入大但换来的是更可控的状态和更高的并发消息方案侵入小但最终一致的窗口期更长。选型不是单选很多时候是混合使用。同一笔订单链路里扣库存用 TCC加积分用事务消息超时关单用 SAGA最后对账用消息表和定时任务兜底这个组合很常见。3.2 下单扣库存最经典的场景怎么组合订单和库存是我被问到最多的场景这里给一个比较稳的通用设计思路如果链路短只有订单和库存两个服务优先考虑 TCC。库存服务提供预占、确认、释放三个接口订单服务在本地事务里创建订单然后远程调库存预占。如果链路长有订单、库存、积分、优惠券、赠品等多个服务参与用 SAGA 编排或者 MQ 事务消息把下游操作作为异步消费处理主流程只保证订单和库存的强关联。如果库存本身是 Redis 预扣加异步回写数据库那事情就简单了分布式事务真正要管的是订单状态和数据库库存的最终对账用消息加定时任务就能解决。说句掏心窝的话我曾经有个老同事总结过一句话我一直很认同先把业务状态机画清楚再谈事务方案。很多分布式事务的问题本质是状态机设计得不清楚——订单没有明确的待支付、已支付、已取消、已退款状态库存没有明确的可用、冻结、已售状态什么框架都救不了你。3.3 Spring Cloud 环境下的落地姿势国内做微服务基本是 Spring Cloud Spring Boot 的天下落地分布式事务一般有两条路。第一条路引入 Seata。Seata 目前支持 AT、TCC、SAGA 和 XA 四种模式。其中 AT 模式对业务代码侵入最小它通过代理数据源自动记录 SQL 执行前后的数据镜像生成 undo_log回滚时反向补偿业务方只需要在方法上加一个 GlobalTransactional 注解。但要注意AT 模式和 XA 类似也是对资源加锁的只是粒度控制得更好性能比 XA 强不少但在高并发写热点的情况下仍然需要重点关注。第二条路自己实现消息补偿。在 Spring 里用 Transactional 保证本地事务再通过 TransactionSynchronizationManager 注册事务同步器在事务提交之后发送 MQ 消息避免出现事务还没提交消息先发出去了的问题。这是一个很实用的技巧Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto.toEntity()); // 注册事务同步回调事务提交后再发消息 TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { Override public void afterCommit() { mqTemplate.convertAndSend(order.created, dto.getOrderId()); } }); }这段代码的本质其实就是手动实现了一个简化版的事务消息。代码不复杂但要注意如果事务提交之后 MQ 发送失败消息就丢了。所以严格来说还得配合定时任务扫表补发。这就又回到了本地消息表的思路。你会发现这些方案之间真的没有绝对的优劣只有适用场景的不同。3.4 Seata 快速上手核心概念Seata 是目前 Java 生态里最主流的分布式事务框架上手前先把几个核心概念搞清楚否则看文档容易懵。TCTransaction Coordinator事务协调者独立部署的服务负责全局事务状态的管理。TMTransaction Manager事务管理器事务的发起方在业务代码里用 GlobalTransactional 标注方法由它开启全局事务。RMResource Manager资源管理器管理分支事务的资源比如它代理了一个数据源负责把分支事务的提交、回滚上报给 TC。AT 模式的代码写起来非常简洁GlobalTransactional public void createOrderWithStock(OrderDTO dto, StockDTO stockDTO) { orderService.create(dto); // 本地事务插入订单表 stockService.deduct(stockDTO); // 远程调用库存服务扣减 // 任意一步失败Seata 自动回滚已提交的分支 }AT 模式会自动解析 SQL分析出影响的行记录前后镜像然后生成 undo_log。事务回滚时根据 undo_log 反向生成补偿 SQL把数据还原回去。业务代码几乎无侵入这是它受欢迎的根本原因。但也正因为是自动的遇到复杂 SQL 时镜像解析就可能出问题。比如大量使用存储过程、不确定 SQL、批量 update、函数运算等Seata 解析不了或者解析不准回滚就可能出错。所以引入 Seata 的团队一定要对分支事务里的 SQL 做人工 review避免写出无法逆向的 SQL。4. 实操中常见的坑与排查实录4.1 幂等是所有方案的共同地基无论你用哪种方案最后都绕不开幂等。分布式环境下网络超时重试、消息重复投递、消费重试都会导致同一个请求被重复执行。最典型的是支付回调第三方支付平台回调你的接口可能因为网络原因把同一个支付结果推送好几次如果你不做幂等处理用户付一次款订单可能被处理两次结果就是重复发货、重复加积分。幂等设计我常用的几个手段唯一业务键加唯一索引。入库前先查询插入冲突就说明已经处理过直接返回成功。状态机约束。订单状态只有待支付 → 已支付是合法的重复回调时发现状态已经不是待支付直接忽略。用 Redis setnx 做短时间内的请求去重适合并发极高的入口。有一个原则大家一定要记住分布式事务框架帮你保证的是数据一致性而幂等是业务应用层必须自己做的事。框架不可能替你做业务幂等因为幂等规则只有业务才清楚。4.2 TCC 的三个隐藏坑空回滚、悬挂与幂等控制TCC 只要上了生产几乎一定会踩这几个坑尤其是空回滚和悬挂。先说空回滚。Try 阶段因为网络超时根本没执行到但协调者认为 Try 失败直接调用了 Cancel。这时候 Cancel 面对的是一个从未 Try的事务如果不做判断就去释放资源可能把别人预留的资源给释放了。解决办法是在事务控制表里记录事务状态Cancel 执行前先查 Try 是否执行过没执行过就不做任何资源操作只记录一个空回滚标记。再说悬挂。Try 方法执行了但响应超时协调者等不到结果就调用了 Cancel而 Cancel 先执行完了。等 Try 的请求慢悠悠到达时就出现了一个既被 Try 又被 Cancel 的状态这就是悬挂。解决办法是在 Try 执行之前先查 Cancel 是否已经执行过如果已经 CancelTry 直接返回不再做资源预占。最后是幂等控制。Try、Confirm、Cancel 都可能因为网络重试被调用多次必须用一张事务控制表以全局事务 ID 为唯一键三个方法执行前都做防重判断。这三个坑没有哪个开源组件能完全替你做因为它们都和具体业务状态相关。所以 TCC 要落地团队必须有意识地设计好控制表和判断逻辑不能光靠框架注解就以为万事大吉。4.3 一个真实问题的排查过程之前我遇到一个线上问题用户下单成功但偶尔出现订单已支付、库存没扣的情况客诉源源不断。排查顺序是这样的先看订单服务和库存服务的日志确认库存扣减服务到底有没有收到请求。结果发现部分请求库存服务确实收到了但处理超时订单服务等不到结果直接回滚了。再看数据库。订单表里确实没有这条订单但库存冻结记录被扣了。原因是 TCC 的 Try 阶段库存已经预占成功订单服务在本地事务提交后远程调用库存确认时网络恰好超时。最后按照空回滚 悬挂的思路去查发现 Cancel 在 Try 还没到达时就被执行了形成了悬挂状态。当时的修复方案是在库存服务里引入一张事务控制表字段是 tx_id 加状态Try、Confirm、Cancel 全部通过这张表做幂等和状态流转同时把订单服务调用库存确认的超时时间调大并且对超时结果加一次主动查询兜底不再直接判定失败。这个案例很典型说明分布式事务的问题往往是链路超时 状态错乱叠加出来的。光靠把超时时间调大不够必须有状态机和控制表兜底才能保证极端情况下数据最终是干净的。4.4 我踩过的几条经验教训最后分享几条经验都是真金白银换来的。第一能不分库就不要分库。很多项目数据量才几百万就开始按服务拆库拆表结果把一堆分布式事务问题引了进来。先评估一下真的需要拆吗如果只是为了逻辑边界更清晰单库里用多 schema、或者通过领域边界设计避免跨库事务可能是更省事的选择。第二事务范围越小越好。一个事务内要尽量降低远程调用的数量能用队列异步就异步能合并的调用就合并。很多人写代码习惯在事务里做 HTTP 调用这是本地事务变慢、超时的头号原因。记住事务里只放数据库操作消息发送放到事务提交之后。第三监控和告警要围绕最终一致来设计。订单和库存不一致的数量、积压未对账的消息数、补偿任务的重试次数这些指标比接口耗时更能反映分布式事务系统是否健康。建议团队至少有一个一致性对账任务每天定时扫描几个核心表的数据偏差哪怕只是输出一份报告也能让你在出问题的时候早点发现。第四不要盲目追求框架。Seata 很好用但引入它意味着多一个 TC 中间件要维护意味着要理解 AT 模式的镜像机制和它带来的 SQL 限制。如果业务链路简单、并发不高一张本地消息表配一个定时任务可能是最稳的方案。技术选型不是用最火的而是用最合适的。做分布式事务这几年我最深的体会是事务方案只是最后的保险真正重要的还是把业务拆得足够清晰、状态机设计得足够严谨、接口做得足够幂等。如果能通过合理的业务设计让一次操作只落在一个库里那比任何分布式事务方案都值钱。另外还想补充一个小技巧很多团队会建一张补偿任务执行表来登记每一条补偿动作的触发原因、执行结果和重试次数这个表也是事后排查问题的利器强烈建议从一开始就建好。
返回列表