ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

分布式事务从2PC到TCC:六种方案对比与选型指南

分布式事务从2PC到TCC:六种方案对比与选型指南 先聊个真实场景。你在电商系统里下一笔订单订单服务要写订单表库存服务要扣库存积分服务还要加积分。如果这三个服务共用同一个数据库那好办一个本地事务、几条SQL全部搞定。但一旦拆成微服务、拆成独立库问题就来了订单写成功了库存扣减却失败了用户手里拿着一张订单仓库里却没有货。这就是分布式事务要解决的经典问题——当一次业务操作跨越多个数据源或多个服务时如何保证数据最终一致甚至强一致。这篇文章我想把分布式事务的几条主流路线一次性讲透从强一致的2PC到业务侵入明显的TCC再到靠消息和定时对账兜底的最终一致方案。内容会包含完整流程拆解、每个方案的优缺点、我踩过的坑以及选型建议。适合正在做微服务拆分、电商中台、支付交易系统的开发者也适合面试前想系统梳理这块知识的同学。1. 分布式事务的本质订单和库存为什么不能“同时成功”先别急着看方案得先搞清楚分布式事务到底难在哪。本地事务之所以简单是因为ACID里的原子性、隔离性都靠数据库的锁和日志来保证。但跨库之后没有一把全局的锁也没有一个统一的日志每个库各管各的事务要协调的事情就变复杂了。1.1 跨库跨服务的事务边界从哪来微服务架构下每个服务通常拥有自己的数据库服务之间通过RPC或MQ通信。一个业务操作比如下单从前端角度看是一件事但在后端却拆成了订单库、库存库、积分库里的多次写操作。这些写操作之间没有共同的数据库连接也就无法用传统事务把它们绑在同一个“要么全成功、要么全失败”的边界里。于是出现了“分布式事务”这个概念。所谓分布式事务本质上是多个参与者数据库、服务之间达成一致性的协议。参与方越多、网络越不可靠达成一致的难度就越大。这里有个很基础但容易忽略的点网络不是稳定的消息可能丢失、延迟、重复服务可能宕机、超时。也就是说你不仅要处理“成功/失败”两个状态还得处理“无法确定对方是否成功”这个中间状态。1.2 强一致与最终一致CAP约束下的现实选择说到一致性就必须提CAP。在网络分区P不可避免的前提下你必须在可用性A和一致性C之间做选择。分布式事务的所有方案本质上都是在C和A之间找平衡。强一致方案如2PC追求任何时刻所有节点数据一致但牺牲部分可用性和性能。适合对一致性要求极高、并发量可控的核心场景比如跨行转账。最终一致方案如本地消息表、MQ事务消息、对账允许系统在某个时间窗口内数据不一致但在一段时间后通过补偿、重试、对账等手段达到一致。适合互联网高并发场景比如下单、支付回调。用生活化的话说强一致就像两夫妻必须同时到场才能办手续少一个人就整个流程卡住最终一致就像你先签了字对方后面补签最后总归能把流程补完。理解了这个取舍后面看每种方案的设计逻辑就会顺很多。2. 方案一2PC两阶段提交——强一致的老牌方案2PCTwo-Phase Commit两阶段提交是分布式事务里资格最老的方案也是理论课上必讲的经典模型。它把事务提交过程分成两个阶段准备阶段Prepare Phase和提交阶段Commit Phase参与者是多个资源管理器通常是数据库协调者Coordinator负责统一调度。2.1 2PC的完整执行流程Phase 1准备阶段协调者向所有参与者发送Prepare请求询问“你们能不能提交这个事务”参与者收到后执行本地事务但先不提交把资源锁住并且如果执行成功返回“可以提交”Yes如果执行失败返回“不能提交”No。这一阶段的目的是把所有参与者的本地操作先做一遍确保没有谁在最后阶段掉链子。Phase 2提交阶段协调者根据所有参与者的反馈做决策如果所有参与者都返回Yes协调者向所有参与者发送Commit请求各参与者收到后正式提交本地事务并释放锁。如果任何一个参与者返回No或者协调者等待超时协调者向所有参与者发送Rollback请求各参与者回滚本地操作并释放锁。文字可能有点抽象我画个简化流程模拟一下协调者 参与者A 参与者B |------- Prepare --------| | |------- Prepare --------| | |------- Yes -------------| | |------- Yes -------------|-------------------------| |------- Commit --------| | |------- Commit --------| |一个容易忽略的细节是在准备阶段参与者已经执行了本地事务只是没有提交。这意味着它持有资源的锁而且这个锁可能在等待所有参与者返回期间一直不释放。如果某个参与者响应特别慢整个系统的吞吐量都会被拖垮。2.2 2PC的三大痛点与适用场景2PC理论很简洁但工程落地时问题不少我总结为三个核心痛点。痛点一同步阻塞。参与者持有资源锁期间其他事务访问这些资源都会被阻塞。2PC的性能瓶颈通常就卡在“等待最慢的那个参与者”上。之前在一个支付系统里压测有个库存服务因为慢查询导致Prepare超时其它几个服务全跟着被拖住现场非常难看。痛点二协调者单点故障。如果协调者在发送Commit之前宕机所有参与者会一直停在“已准备好但未提交”的状态锁无法释放事务无法推进。虽然可以通过日志恢复但恢复逻辑本身也很复杂。痛点三脑裂和数据不一致。第2阶段协调者发送Commit时如果网络出现分区部分参与者收到并提交了事务另一部分没收到就会产生“部分提交、部分未提交”的不一致状态。而且这个状态无法通过2PC协议本身解决需要人工或额外工具介入。正因为这些痛点2PC更适合用在参与者数量少、网络稳定、并发量不大但一致性要求极高的内部系统里。比如银行核心转账、证券结算这类场景。互联网那种动辄每秒几千下单量的场景基本很少直接用裸的2PC因为性能和可用性都扛不住。3. 方案二3PC三段式提交——减少同步阻塞的尝试3PCThree-Phase Commit三阶段提交是2PC的改良版主要思路是把2PC的准备阶段拆成两步引入超时机制降低阻塞的可能。3.1 3PC比2PC多了什么3PC把流程拆成CanCommit、PreCommit、DoCommit三个阶段。CanCommit阶段协调者先向所有参与者发请求问“你们能不能提交”参与者此时还没有执行本地事务只检查自身状态比如连接是否正常、资源是否允许然后返回反馈。这个阶段主要用来排除那些一开始就没法参与的节点。PreCommit阶段如果所有参与者都返回Yes协调者发送PreCommit请求参与者执行本地事务但不提交返回Ack。如果这个阶段有参与者返回No协调者就会提前中止。DoCommit阶段协调者收到所有Ack后发送DoCommit请求参与者正式提交事务。比2PC多出来的CanCommit阶段以及参与者在等待协调者指令时引入的超时机制是3PC最大的变化。参与者如果在PreCommit之后长时间收不到DoCommit指令会自行决定提交。因为这个设计单个参与者在等待期间不再无限期锁死阻塞问题得到一定缓解。3.2 3PC依然存在的问题3PC并没有从根本上解决分布式事务的一致性问题。在DoCommit阶段如果网络分区导致部分参与者收不到指令有的靠超时自动提交了有的回滚了系统照样会出现数据不一致。你会发现3PC的改良方向偏重于“减少阻塞”和“提高可用性”但一致性反而因为“参与者有超时自决权”变得更脆弱了。工程上3PC用得比2PC还少。它的教学意义大于落地意义很多分布式数据库和事务中间件会借鉴它的思想但很少有系统完全按3PC协议实现。理解3PC的核心价值在于让你明白一致性协议的设计永远在“一致性的可靠性”和“系统的可用性”之间做取舍没有银弹。4. 方案三TCC补偿事务——业务侵入换来的柔性TCCTry-Confirm-Cancel是互联网领域用得比较多的一种柔性事务方案。它不像2PC那样依赖数据库的锁机制而是把事务控制权交给业务代码通过三个业务方法来保证最终一致性。TCC解决的问题是“在不能长时间锁资源的高并发场景下怎么把多个服务的操作编排成事务”。4.1 Try/Confirm/Cancel三段式模型TCC把每个事务操作抽象成三个动作Try尝试执行业务完成资源检查和预留。比如扣库存时不直接扣减库存而是冻结一部分库存记账时不直接入账而是冻结一部分金额。Confirm确认执行业务真正执行业务操作。比如把冻结的库存变成已售出把冻结的金额变成已入账。Confirm操作要求幂等因为网络重试可能导致它被多次调用。Cancel取消业务操作释放预留的资源。比如解冻库存、退还冻结金额。Cancel也要求幂等。我用一个典型的“下单冻结库存”流程来说明订单服务 库存服务 账户服务 |------- Try(冻结库存) ------| | |----------- OK --------------| | |------- Try(冻结余额) ------| | |----------- OK --------------|----------------------| |------- Confirm(扣减库存) --| | |------- Confirm(扣减余额) --| |如果Try阶段有任何一个参与者失败所有参与者都会执行Cancel释放预留资源。这样既避免了2PC的长时间锁资源问题又让事务具备可控性。TCC的优点是性能好、可用性高因为Try阶段做完之后所有节点在自己可控范围内操作不需要长时间持有分布式锁。缺点是业务侵入非常强——你需要为每个事务操作写三个方法相当于把事务逻辑拆成业务代码的一部分开发和维护成本都不低。4.2 空回滚、悬挂与幂等的坑TCC方案有几个很经典的工程问题稍不注意就会埋雷。坑一空回滚。如果Try请求因为网络超时没到达库存服务订单服务以为Try失败直接触发了Cancel。这时候库存服务其实没冻结过任何库存Cancel操作就是在“空回滚”。解决办法是在Try操作里记录事务状态或预留痕迹Cancel执行前先检查是否存在可回滚的记录没有就直接返回成功。坑二悬挂。与空回滚对应如果Try请求被延迟执行后面Cancel已经执行完了Try才到达库存服务预留操作就会“悬挂”——预留的资源永远没人释放。做了不干净还容易导致库存占用。解决办法通常是在Try执行前检查当前事务是否已经处于Cancel状态是则拒绝执行Try。坑三幂等。网络重试可能导致Confirm和Cancel被多次执行。如果Confirm不是幂等的重复执行会导致超扣或重复入账。常见的做法是通过事务ID加唯一索引或状态机判断保证每个事务只执行一次成功操作。从我个人的经验看TCC适合那些“需要预留资源”的业务典型场景是电商下单冻结库存、充值冻结余额。但团队要对TCC有足够的认知并且愿意投入精力处理上面这些边界问题否则很容易在线上出问题。5. 方案四本地消息表——最朴素的最终一致本地消息表也称“本地事件表”是一种非常朴素但又极其经典的最终一致方案。它的核心思想是让业务操作和消息写入在同一个本地事务中完成然后通过异步任务把消息可靠地投递出去。5.1 本地消息表的工作原理假设场景是下单后要通知积分服务加积分。流程大致是这样订单服务在本地数据库开启事务。写入订单数据。在同一条事务里写入一条“待发送积分消息”到本地消息表。事务提交。后台有一个定时任务或者消息发送线程不断扫描本地消息表中状态为“待发送”的记录把消息发送到MQ。如果MQ发送成功把消息状态标记为“已发送”如果发送失败则保留记录等待下一次扫描重试。积分服务消费消息增加积分。这个方案巧妙的地方在于订单数据和消息数据在同一个本地事务里要么一起成功要么一起失败。这就保证了“业务操作成功消息一定落库消息落库业务操作一定成功”从源头上避免了“业务做完了但消息丢了”的问题。流程图大致长这样我用伪代码描述一下核心逻辑本地事务开始 insert into orders(...) insert into outbox(message_id, status, payload) values(..., PENDING, ...) 本地事务提交 后台定时任务 select * from outbox where status PENDING limit 100 for each message: try: 发送到MQ 更新 status SENT except: 记录日志,等待下次重试5.2 为什么说它是“对账”的雏形本地消息表虽然简单但它已经具备了“最终对账”的关键要素持久化消息状态、定期重试、通过状态字段追踪进度。后面要讲的MQ事务消息本质上也是“本地消息表思想”的中间件化实现。本地消息表的优点是实现简单、不依赖特定中间件、可靠性高。缺点是消息表的写入会占用数据库资源定时任务扫描发送的实时性不高如果业务量很大消息表本身可能成为瓶颈。另外它只解决“上游消息可靠发送”的问题下游消费失败、业务处理失败怎么办还需要配合重试和补偿机制。我最早接触这个方案是在一个老系统中当时没有RocketMQ团队用一个定时任务扫描Oracle的消息表跑了很多年都没出过大问题。现在有了成熟的消息中间件很多人反而把这个基础方案忘了其实在做技术选型时它依然是一个极好用的兜底方案。6. 方案五MQ事务消息——把最终一致交给消息中间件前面说了本地消息表MQ事务消息就是把那个“本地写消息”的动作从业务库搬到消息中间件里让中间件自己来保证消息的可靠性。目前最典型的是RocketMQ的事务消息实现Kafka原本事务机制也能做但实现复杂度要高一些。6.1 RocketMQ事务消息的核心设计RocketMQ事务消息的核心是“半消息”和“消息回查”机制。大致流程如下订单服务发送一条“半消息”Half Message到RocketMQ。半消息对消费者不可见只是暂存在Broker里。RocketMQ存储半消息成功后返回消息发送成功订单服务收到响应后开始执行本地事务写订单表、扣库存等。本地事务执行完成后订单服务向RocketMQ发送Commit或Rollback指令CommitBroker把半消息标记为可投递状态消费者就能消费了RollbackBroker删除半消息消费者永远不会看到它。如果本地事务执行完后订单服务挂了或者Commit指令丢失了半消息会一直存留在Broker里。RocketMQ会根据消息内的业务标识主动调用订单服务接口回查Check本地事务的结果根据返回结果决定是Commit还是Rollback。流程可以简化为生产者 RocketMQ 消费者 |--- 1. 发送半消息 --------| | |-- 2. 返回成功 -----------| | |--- 3. 执行本地事务 -------| | |--- 4. 发送Commit ---------| | | |--- 5. 消息可见 ---------|这个设计很像本地消息表但把“消息存储”和“重试机制”都交给了消息中间件业务系统只需要实现回查接口即可。6.2 事务消息与本地消息表的对比很多初学者会问既然本地消息表能解决问题为什么还要用MQ事务消息我整理了一下两者的差异对比项本地消息表MQ事务消息消息存储业务数据库的本地表消息中间件内部实现复杂度需要自己写定时任务扫描中间件自动处理但依赖特定产品实时性受扫描周期影响通常有秒级延迟Broker确认后立即可投递延迟更低数据库压力消息表增加数据库压力不占用业务库资源通用性任何数据库任何MQ都能做强依赖支持事务消息的MQ如RocketMQ事务消息并不是万能的。它解决了“上游可靠投递”的问题但下游消费者拿到消息后执行失败怎么办这依然要靠消费者自己的重试、幂等和拒绝策略来解决。所以我对事务消息的定位是把本地消息表里的“消息发送和确认”这一环交给中间件让你的代码更干净但不能因此忽略下游消费的保障。7. 方案六最大努力通知与对账补偿——兜底一切的人工智能到这一步你会发现前面所有方案都有在自己的框架内保证一致性但它们都无法100%避免极端情况下的不一致。比如TCC的Cancel失败了怎么办事务消息发出了但消费者处理失败且重试也失败了怎么办这些“最后一道防线”就是最大努力通知和对账补偿。7.1 最大努力通知的适用边界最大努力通知的精髓不在于“保证成功”而在于“尽最大努力通知直到对方确认”。它的典型场景是支付结果通知支付平台回调商户系统商户没收到或处理失败支付平台会定时重试重试若干次后仍失败则停止由人工介入。第三方接口同步比如把订单数据同步给物流平台对方暂时不可用我这边就按间隔重试直到对方确认收到。实现方式通常是发起方定期扫描“待通知”的记录按递增间隔1分钟、5分钟、15分钟、1小时通知接收方直到接收方返回确认超过最大次数后标记为“人工处理”。接收方需要保证接口的幂等性因为同样的通知可能收到很多次。它的优点是完全解耦接收方不需要反向调用发起方实现简单缺点是实时性差、最多只能做到“尽力而为”不能保证一致性本身。7.2 对账系统最终一致的“保底防线”对账补偿是把分布式事务数据一致性兜住的关键一招。很多系统平时运行看起来一切正常但一旦出现极端情况——网络抖动、MQ消息丢失、服务宕机——只有对账才能发现并修复问题。对账的思路很简单粗暴拿一个权威数据源比如支付平台的账单、数据库里的订单状态和本地系统数据做比对找出差异记录再按规则自动或人工补偿。以支付系统为例典型对账流程是下载支付渠道的日账单。将账单与本地支付流水逐笔比对。找出“本地有、渠道无”或“渠道有、本地无”或“金额不一致”的异常记录。对异常记录执行自动补账、退款、重发通知等操作。无法自动处理的人工介入。对账看起来“土”但它有效。我之前参与过一个分账系统上线初期因为消息重复消费导致部分商户分账金额异常就是靠日终对账发现的。没有对账这一层的话那些系统自认为“一致”但实际上已经分歧的数据可能几个月都无人察觉。8. 六种方案对比与选型建议前面把6种方案单个讲完了现在把它们放在一起做一个横向对比方便你在具体项目里做选型。8.1 方案对比表方案一致性级别业务侵入度性能表现复杂度典型应用场景2PC强一致低依赖资源管理器差资源锁持有时间长中银行转账、证券结算3PC强一致改良低较差但阻塞有所缓解高教学/实验场景居多TCC最终一致高需写Try/Confirm/Cancel较好无长锁高电商下单冻结库存、支付本地消息表最终一致中需维护消息表较好但数据库压力增加低任意需要可靠消息投递的场景MQ事务消息最终一致低仅需实现回查好中高并发订单、积分、账务最大努力通知对账最终一致低好低支付回调、渠道同步、兜底补偿从表中能看出一个规律强一致方案以牺牲性能为代价最终一致方案以接受短暂不一致为代价换取更好的扩展性和吞吐量。8.2 我的一些选型体会这么多年踩下来我的选型思路一直很朴素。第一能不分库就不分库。分布式事务的所有方案都比本地事务昂贵避免跨库事务的最好方式是在架构设计上尽量避免产生跨库操作或者按业务域把需要强一致的写操作聚合到同一个服务里。第二核心链路优先考虑TCC或MQ事务消息。比如电商的核心下单链路既要保证性能又要能追溯TCC的冻结确认模式很契合非核心的积分、消息通知链路用MQ事务消息把数据投递可靠化就足够了。第三永远不要省掉对账。不管前面用了哪种方案最终一致都依赖一套对账机制来兜底。对账不是备选方案而是分布式事务体系的标配。我见过太多项目上线时不做对账等出了问题才匆忙补这其实是不应该的。第四控制参与方的数量。下游参与方越多失败概率成倍增加。不是说不能接入很多系统而是要让核心事务链条尽量短。一个事务里最好只有一个上游和一个下游如果非要串联多个可以考虑拆成多个小事务而不是捏成一个大事务。分布式事务没有放之四海而皆准的方案。每个方案都是对一致性、可用性、性能、开发成本的折中。我在实际项目里最常用的一套组合拳是核心链路用TCC保证业务状态可控非核心链路用MQ事务消息做数据分发每天凌晨再用对账任务把几个关键链路跑一遍确保没有漏网之鱼。这套组合拳看上去没有单一某个方案那么“高级”但胜在经得起线上环境的反复捶打。如果你还在为技术选型纠结不妨先画一下自己的核心链路、非核心链路和对账链路分别在哪再往这几个方案里套答案会清晰很多。
返回列表