ARTICLE DETAIL

资讯详情

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

从CAP定理看分布式事务:2PC、TCC、Saga等方案对比与实战选型

从CAP定理看分布式事务:2PC、TCC、Saga等方案对比与实战选型 周六晚上十一点我正刷着手机准备睡觉值班群突然弹出一条告警库存超卖了。用户下单时明明预占了库存结果支付回调超时订单被系统自动取消但库存却一直没释放。订单服务和库存服务早被拆成了两个独立微服务数据库也各存各的原本在一个本地事务里就能搞定的事硬生生变成了谁也管不了谁的烂摊子。这个场景就是典型的分布式事务问题。这类问题我在这十来年的后端和大数据研发里遇过太多次。网上随手搜“订单与库存分布式事务”能搜出一堆踩坑实录。今天恰好从CAP定理的视角把主流的分布式事务方案完整梳理一遍2PC、TCC、Saga、本地消息表、事务消息以及大数据场景下的湖仓ACID和离线对账。讲清楚每种方案站在CAP天平的哪一侧适合什么业务有哪些必须提前知道的坑。如果你正在写微服务或者在做大数据平台设计需要给团队定一套事务选型标准这篇东西应该能帮你省下不少调研时间。1. CAP定理不是一道选择题而是系统设计的边界条件1.1 先搞清楚CAP到底在说什么CAP定理是Eric Brewer在2000年提出的核心含义很简单分布式数据系统在网络发生分区时无法同时保证一致性、可用性和分区容错性这三个属性。这句话看着容易理解起来特别容易跑偏。很多文章喜欢出“三选二”的选择题CA、CP、AP搞得好像设计系统时可以挑俩。真正的含义其实是当网络分区发生时你只能在C和A之间做取舍而P根本由不得你选——网络故障是常态不是意外。拿生活场景打比方。一家公司有两个办公室各自部署了一套IT系统中间靠专线连接。某天专线断了两个办公室互不通信。这时客户在A办公室下单A的数据库写入了订单B的数据库完全不知道。这个瞬间你只有两个选择选择可用性继续接受客户订单哪怕B办公室的数据暂时落后选择一致性暂停接单告诉客户“系统维护中”等专线恢复、数据同步完成后再开放服务。你不可能既接受新订单又让两边数据库瞬间完全一致。这就是CAP的矛盾所在。三个属性的具体含义值得说细一点一致性所有节点在同一时刻看到相同的数据。A节点写入后B节点要能立刻读到这次写入的结果读不到就算不一致。可用性每个请求都能在合理时间内获得响应而不是超时或报错。注意这里强调的是“能响应”允许返回失败结果但请求不能被无限挂起。分区容错性系统在发生网络分区时依然能够继续运行。节点之间通信断了但整个系统不能瘫掉。只要是分布式系统P就必须选。因为系统一旦分布在多个节点上网络就不再可靠。机房的交换机故障、跨机房的专线抖动、云厂商可用区异常、节点Full GC导致的心跳超时都算分区。既然这些情况必然发生架构师每天的工作实质上就是在回答分区发生时你要一致性还是可用性1.2 大数据场景为什么绕不开CAP大数据平台比普通微服务更躲不开CAP。一个生产环境的Hadoop或Spark集群动辄几十上百个节点数据分散在多台机器上跨机架、跨机房的部署是常态。HDFS的NameNode元数据、Kafka的副本同步、HBase的Region分布每一个组件都在做分布式数据管理。集群规模越大发生分区的概率越高。大数据集群部署策略里有一个经典问题一个集群需要跨两个机房做容灾主集群写入、备份集群同步。如果两个机房之间的专线抖动你是为了保证一致性暂停写入还是为了保证写入可用性继续接受数据、等网络恢复后再补同步这不就是CAP的问题么再往深处看分布式事务的一致性难题本质上是把单机数据库的ACID特性搬到分布式环境下发现根本无法同时满足——ACID里的Atomicity要求所有节点要么全成功要么全失败Consistency要求每个节点数据一致但网络分区会不断打破这些假设。我经常跟团队说一句话不要觉得CAP是个学术名词你部署的每一套分布式系统都在潜意识地做CAP取舍。比如大数据平台里NameNode的高可用方案主备切换时选择“我还能不能继续服务”这就是在C和A之间做决策。1.3 关于CAP的三个高频误解过去几年我在技术评审会上听过太多对CAP的错误解读总结成三个高频误解。误解一CAP是三选二。上文已经讲了P在分布式系统中无法放弃。准确说法是分布式系统在网络分区时只能在C和A中二选一。系统正常运行时C和A其实可以同时满足但这不改变分区时的抉择。误解二不分区时系统是完美CA。不分区时一个单副本系统确实可以同时提供一致性和可用性。但一旦网络抖动系统就面临选择。如果你没有提前设计好转C还是转A系统会默认做出无意识的选择——大多数时候是错误的选择比如进入不可用的假死状态。误解三最终一致性就是“弱一致性”意味着数据随时会对不上。实际不然。最终一致性是有承诺的系统在稳定运行后数据最终会达到一致状态这个收敛时间通常是秒级甚至毫秒级。配合幂等设计用户体验基本不受影响。大数据领域的大部分实时链路走的都是最终一致性路线。2. 分布式事务为什么这么难从订单与库存场景说起2.1 一个订单引发的跨服务难题我开头讲的库存超卖就是一个完整的分布式事务教科书案例。整个下单链路拆开后长这样用户提交订单订单服务创建订单状态为“待支付”库存服务扣减/预占库存支付服务等待第三方支付回调回调后更新订单状态为“已支付”订单状态变化后触发物流、积分、短信等后续动作。每一步都是独立服务、独立数据库。注意一个细节第一步写订单第二步扣库存这两个动作在本地事务时代是同一个事务里完成的。拆开后变成了两次跨服务调用数据库的ACID原子性不复存在。问题紧跟而来如果订单创建成功库存扣减失败怎么办如果库存扣了支付回调超时订单被取消库存要不要回补如果回补消息丢了怎么办这就是分布式事务要解决的核心问题——让分散在多个服务、多套数据库中的数据变更在业务上保持原子和一致。2.2 分布式事务的四个核心难点分布式事务难难在四个地方。第一原子性跨越了进程边界。单库事务里要么全部提交要么全部回滚。跨进程之后没有全局锁也没有全局事务管理器无法像一个数据库连接那样统一控制所有资源的提交和回滚。第二网络是不确定的。一个服务调用另一个服务超时了调用方并不知道被调方是成功了还是失败了。重试可能造成重复扣款不重试可能造成订单丢失。这个“超时后的未知状态”是分布式事务里最让人头疼的问题。第三幂等性被反复考验。超时重试、消息重投、数据补发任何环节都可能把同一个请求发送两次。如果接口本身不具备幂等性轻则生成重复数据重则引发资金或库存级别的重大事故。第四异构系统的协调成本高。订单服务可能是Java写的库存服务可能是Go数据库有的用MySQL有的用Redis有的走Kafka。要在一套事务框架里协调这么多异构组件复杂度呈指数上升。2.3 大数据平台的分布式事务不止一种提到分布式事务很多人只想到微服务场景。其实大数据平台上同样有分布式事务问题只是表现形式不同。举几个我实际遇到过的例子。业务库通过CDC同步数据到Kafka再经过Flink清洗后写入数仓。任何一个环节失败都可能导致数仓数据与业务库不一致。同步任务跑了一半挂了重新启动时是全量重跑还是增量续跑这就是典型的数据链路事务问题。再比如数仓里一张宽表由多个上游任务写入。任务A写完了任务B还没写完下游报表恰好在这个时间窗口查询读到了半成品数据。数据分析师跑出来的结果一会儿一个数气得直接拍桌子。这就是大数据场景下的“脏读”。还有ETL批处理里的一个经典场景每天凌晨跑同步任务先清空目标表再写入新数据。清空成功、写入失败就等于生产事故——目标表空了而业务还在等数据。这些问题的本质和微服务里的分布式事务一模一样只是被包装成了“数据同步”“批处理调度”这些名词。3. 主流方案全景对比CAP天平上的不同位置3.1 强一致路线2PC/3PC与XA先看站在一致性这一侧的老牌方案两阶段提交。2PC原理不难理解。引入一个协调者所有参与者分成两个阶段工作第一阶段协调者向所有参与者发prepare请求每个参与者执行本地事务但不提交写好后返回“准备好了”或“失败”第二阶段协调者汇总结果如果全部准备好就给所有人发commit指令否则发rollback指令。这个机制保证了所有节点要么一起提交要么一起回滚是强一致性的代表。数据库的XA规范就是按这个思路实现的MySQL、Oracle等主流数据库原生支持。很多分布式事务中间件比如Seata的AT模式本质上也借鉴了2PC的流程只是把锁粒度细化到业务行降低了对数据库的侵入。但2PC的缺点同样致命。一是同步阻塞第一阶段的prepare完成之后所有资源一直锁着直到协调者发出最终指令。协调者如果宕机所有参与者会一直阻塞等待业务直接挂死。二是协调者单点协调者本身又是个分布式组件处理不好就是新的故障源。三是为了保证强一致系统的可用性在分区时直接归零。3PC是对2PC的改良引入了canCommit预询阶段和超时机制。参与者等待指令超时时不会像2PC那样傻等而是主动中断事务。3PC减少了阻塞窗口但并没有彻底解决网络分区带来的问题在极端情况下还是可能陷入数据不一致。这类方案在CAP里的坐标非常明确CP优先保一致性牺牲可用性。适合金融转账、账户扣款、余额变更这类打死也不能出错的场景。但说实话在互联网业务和大数据平台里我很少建议核心链路直接用裸的2PC因为它的吞吐和可用性实在扛不住高并发。3.2 业务侵入型路线TCCTCC是很多人提到分布式事务时第一个想到的方案全称Try-Confirm-Cancel把一次业务操作拆成三个阶段Try完成资源检查和预留比如库存够不够先冻结这5个库存Confirm真正执行业务把冻结的库存扣掉这一步假设Try已经成功不重复做业务校验Cancel释放预留资源把冻结的5个库存加回去。用订单库存来套下单时调用Try冻结库存订单支付成功调用Confirm扣减库存订单取消调用Cancel释放库存。每一步都由业务方自己写代码实现所以TCC的事务逻辑完全掌控在业务手里不像2PC那样依赖数据库锁。TCC在CAP里的位置比较特殊。它虽然同步调用但可以通过业务逻辑的宽松设计来提升可用性比纯2PC更接近“可用性优先”的一侧。它的优点是对资源粒度控制精细不会像2PC那样长时间锁表锁行吞吐表现明显更好。但代价是开发成本极高。一个原本只写一个方法的操作现在要写Try、Confirm、Cancel三个实现还要处理三个大坑空回滚Cancel先于Try到达。比如超时导致调用链断裂Cancel过来了但Try根本没执行成功。此时Cancel必须识别出来直接返回成功不能真的去释放一个从未预留的资源。悬挂Cancel先到了Try后到。拒绝执行这个迟到的Try否则预留的资源没人释放。幂等Try、Confirm、Cancel都可能被重试必须保证重复执行不产生副作用。我见过不少团队兴冲冲上了TCC结果被空回滚和悬挂问题折磨了大半年。TCC适合资金类场景比如支付、订单、票务但前提是团队有足够的研发资源并有完善的状态机记录每个事务的流转。3.3 异步最终一致路线Saga、本地消息表与事务消息TCC对业务侵入大2PC对可用性伤害高。大多数互联网业务真正需要的其实是一致性可以延迟几秒、最终对齐就够的方案。这就轮到CAP天平上可用性这一侧的重头戏。先说Saga。Saga的思想是把一个长事务拆分成多个本地事务每个本地事务提交后触发下一个。任何一个步骤失败就逆向执行前面所有步骤的补偿事务。比如下单扣库存通知发货第三步失败了就执行第二步的补偿回补库存再执行第一步的补偿取消订单。Saga有两种实现模式。一种是事件编排每个服务完成本地事务后发出事件下一个服务监听事件继续执行。没有中心协调者实现简单但整个事务流转分散在各个服务里排障时要把事件链路全部捞出来看一遍。另一种是命令协同引入一个中心协调器维护一张状态机表明确记录当前走到哪一步、下一步该调谁、失败时补偿谁。我实际做过的项目里Saga更适合命令协同因为可观测性对线上排障实在太重要了。本地消息表是另一种老牌方案思路非常朴素业务操作和写消息表放在同一个本地事务里。比如创建订单时同一事务里往消息表插入一条“扣库存消息”。本地事务提交后后台任务扫描消息表把消息投递到MQ。消息投递成功并收到ack就标记消息为已发送。消费方拿到消息后处理配合幂等保证不重复执行。这个方案的核心优势是可靠。因为业务数据和消息数据在同一个数据库事务里要么一起成功要么一起失败不存在“业务成功了消息丢了”的问题。它的缺点是每张业务表都要额外建一张消息表数据库压力变大而且后台任务轮询总会引入几秒延迟。不过实现简单逻辑直观我非常推荐中小团队从本地消息表做起。事务消息则是消息队列对本地消息表的“内置化”方案。以RocketMQ的事务消息为例生产者先发送一条半消息消息到达Broker后暂存但不投递给消费者。生产者执行本地事务执行成功则提交半消息Broker才会把消息投递给消费者执行失败则回滚半消息。如果生产者执行本地事务期间宕机了Broker会主动回查生产者的本地事务状态根据结果补提交或回滚。这套机制免去了业务方自己写消息表和轮询任务可靠性由MQ保证。我用RocketMQ的事务消息做过支付回调场景整体体验很好比本地消息表省了至少一半的维护成本。Kafka从0.11版本开始也支持跨分区的事务能力配合幂等生产者实现了精确一次语义主要用在流处理链路里。3.4 大数据场景的特有解湖仓ACID与离线对账大数据场景下的数据链路很多分布式事务问题不能拿2PC和TCC硬套因为数据量太大、链路太长、参与方太多。真正的解法往往是两条路湖仓ACID写入和离线对账兜底。先说湖仓ACID。以Delta Lake、Iceberg、Hudi为代表的数据湖表格式把一张大表拆成一系列不可变文件每次写操作生成新版本文件配套一个事务日志记录每次提交的分区、文件、元数据。读取数据时读的是某个确定版本的事务快照不会看到写了一半的数据。这解决了我在前面提到的数仓脏读问题上游任务A写完了任务B还没写完下游查询读的是任务A完成时那个稳定的快照版本不会读到半成品。这类方案在CAP里的位置是文件级别的一致性在线不可用性的代价换来了数据处理链路的最终一致。实际使用中我建议把Delta Lake或Iceberg作为数据的落地层统一管理批处理和流式写入的并发冲突再用快照隔离保证查询侧永远能看到一致的视图。再说离线对账。无论你用了多完善的事务方案最终兜底手段十有八九是对账。对账的思路非常简单粗暴每天凌晨跑一批批处理任务把交易流水、订单状态、库存流水、支付记录等关键数据按业务主键拉出来做集合比对发现差异就告警再由人工或者自动化流程修复。听起来土但任何系统都离不开它。我见过不止一个公司上了看起来完美的TCC最后排查问题还是靠对账表把数据对齐的。3.5 六类方案综合对比表把前面讲的方案放到一起对比可以得出下面这张表。这张表也是我在做技术选型时经常拿出来对照的参考依据。方案CAP倾向一致性强度可用性表现实时性开发成本代表实现典型场景2PC/XACP强一致分区时易不可用同步高较低数据库XA、Seata AT金融转账、账户扣款3PCCP强一致比2PC略有改善同步高较低较少落地2PC的改良场景TCCCP与AP之间业务保证最终一致较好同步为主高Seata TCC、自研订单库存、支付、票务SagaAP最终一致高异步为主中高自研编排、Camunda长流程预订、贷后、下单本地消息表AP最终一致高异步秒级中自研积分、通知、简单业务解耦事务消息AP最终一致高异步秒级较低RocketMQ事务消息支付回调、订单闭环湖仓ACID文件级一致快照一致高准实时/离线中Delta Lake、IcebergETL、实时/离线数仓4. 选型方法论如何从业务需求推导出正确的技术方案4.1 选型前先回答四个问题我在评审会上常用的方法是先逼大家回答四个问题答完方案基本就框定了。问题一业务能容忍多大的一致性延迟转账场景要求立刻看到最新余额延迟一秒都不行订单支付回调的场景延迟几秒到几十秒全员都能接受用户积分累加的场景延迟几分钟完全无感。容忍延迟越小方案越要往CP靠容忍越大越可以放心走AP。问题二系统对可用性的底线在哪里如果你的业务流量高峰期一刻也不能停那就别用协调者单点的2PC。分布式事务方案引入后会不会带来新的单点这个问题在设计阶段一定要想清楚。问题三上下游系统你可控吗TCC要求所有参与者都实现Try/Confirm/Cancel三个接口。如果有一个老系统是外包团队写的对方连接口都懒得给你加这个方案直接毙掉。DB的XA也一样你得保证所有库都开了XA。问题四团队能承担多少研发和维护成本TCC开发量是普通接口的三倍Saga需要自研状态机或付费组件本地消息表虽然简单但要写轮询任务。方案再好团队扛不住落地复杂度最后还是会返工。我见过太多“为了用分布式事务而用分布式事务”的项目。一个每天几万订单的小系统硬生生引入TCC结果线下环境测试了两个月上线后补偿逻辑一团乱麻。反过来一些看起来“土”的本地消息表方案在业务简单时反而跑得很稳。4.2 典型场景方案推荐基于上面的四个问题我总结一套按场景推荐的方案矩阵。金融资金类比如账户余额、转账、钱包充值选2PC/XA或TCC优先保一致性。这类业务对可用性的容忍度低但对一致性的要求是零容忍。别想着用Saga异步补偿解决资金问题补偿逻辑出错时你根本不知道找谁。电商订单与库存优先考虑TCC加事务消息的组合。下单链路用TCC控制库存扣减和订单状态的强关联支付回调链路用事务消息异步更新状态。注意支付回调的高峰期可能是平峰期的一百倍TCC的Try阶段要控制数据库锁粒度防止行锁风暴。积分、优惠券、通知、日志同步直接用本地消息表或RocketMQ事务消息。积分发多发少不致命通知晚到两分钟没人投诉完全没必要上TCC。长流程业务比如旅游套餐预订、贷款审批用Saga编排每个步骤独立本地事务加补偿中心状态机做追踪。这种场景的步骤可能有十几个2PC根本扛不住长事务的锁时长。大数据链路同步和数仓写入不要用在线事务方案控制用Delta Lake或Iceberg的文件ACID外加离线对账。批处理和实时任务定期做数据比对差异检测到后自动补数。记住一点大数据场景里对账永远比强行上事务靠谱。4.3 一个完整的选型推导案例用网约车综合项目作为例子走一遍推导流程。这个场景包含用户下单、司机接单、行程结算、轨迹入库几个核心环节数据量比普通电商大得多。先看行程结算乘客支付后平台要把订单金额拆分给司机、平台抽成、渠道补贴涉及订单系统、结算系统、账户系统。结算延迟几分钟是可以接受的但金额不能错。按这个需求推导结算链路适合用Saga编排正向流程是扣乘客账户、加司机账户、记录抽成任何一步失败就逆向补偿。用中心状态机记录每笔结算的状态再配合每日对账任务就能保证最终一致。再看轨迹入库网约车的轨迹数据每天几十GB从Kafka实时写入数据湖。这个链路要的是一致性和性能兼得实际做法是让实时任务写入Delta Lake事务表按订单ID分区写完一个订单的快照后再发布给下游分析。如果写入过程中发生任务崩溃下一轮任务从上次提交的版本继续写不会出现半截轨迹文件被下游读走的情况。订单状态主链路订单从待支付到已完成状态流转是短事务直接走本地事务加消息通知就可以了订单和司机操作的隔离通过业务状态机保证。这几个链路放在一个项目里方案完全不同。这就是选型的核心思路不要给整套系统选一个统一方案而是给每条业务链路单独做权衡。5. 实战中的坑与排查经验实录5.1 高频故障与避坑指南理论说再多不如实际踩坑印象深刻。这些年我亲身经历过的分布式事务故障挑几个典型的说一下。第一个坑2PC的协调者宕机。线上一个转账服务用了XA某次协调者所在节点因为磁盘故障重启所有参与者都停在那里等commit或rollback消息业务直接瘫痪两个多小时。后来才加上协调者高可用和超时决策脚本协调者迟迟不恢复时由备用节点根据事务日志人工裁定提交还是回滚。这个教训让我以后再也不敢在核心链路上裸用2PC了。第二个坑TCC的空回滚导致库存凭空增加。某个订单取消的异步消息在网络超时后重试Cancel被调了三次第一次释放了库存后两次没做幂等又把同一个资源释放了一遍。结果就是库存凭空多出来几十件。这个锅TCC本身不背是我们没在Cancel实现里加幂等键判断。从那以后我要求在TCC的每个接口都建立事务流水表以全局事务ID加分支ID做唯一约束来一个请求先查流水处理过就直接返回成功。第三个坑消息事务里消费者幂等没做好。用RocketMQ事务消息做支付回调通知消息确实一条没丢但消费者在回调处理成功后进程在提交offset之前宕机重启后重复消费了同一条支付消息导致订单的优惠券被发了两遍。教训是消息队列的精确一次语义不能保证消费端不重复消费端必须自己按业务主键做幂等。第四个坑数据湖的“半成品可见”问题。在用Delta Lake之前我们的实时数仓是直接往HDFS写Parquet文件写完一个文件就暴露给下游查询。结果上游任务崩溃重跑下游经常读到缺字段的数据。换成Delta Lake的事务日志和快照读取后这个问题基本绝迹。大数据链路上给数据加一道ACID边界比在业务代码里补洞有效得多。5.2 问题排查速查表把上面的经验整理成一张速查表方便出问题时快速定位。问题现象可能原因排查思路解决方案2PC参与者全部卡住协调者宕机或网络超时查协调者事务日志确认prepare阶段状态协调者高可用备用节点根据日志人工/自动决策TCC收到Cancel但Try未执行调用链超时流程未走到Try查事务流水表是否有Try记录实现空回滚识别无Try记录的Cancel直接返回成功Cancel先于Try到达异步消息乱序检查消息时间戳查流水表Cancel记录悬挂控制有Cancel记录时Try拒绝执行同一业务数据重复写入消费端未做幂等按业务主键查重看重复数据的时间戳加唯一约束消费前查重或使用deduplication表消息丢失、数据缺失消息未与业务同事务或ack丢失后重投不发对比业务流水表和消息表本地消息表轮询重投事务消息最终对账兜底数仓/实时报表读到半成品数据文件写入中暴露给下游查表版本和写入时间戳使用湖仓ACID表格式读取快照版本补偿执行后资源数不对补偿逻辑未幂等多次执行对比补偿流水与资源流水全局事务ID幂等记录每个分支执行状态5.3 三个压箱底的经验最后分享三个常年放在我技术决策底层的经验。第一能用单库事务解决的绝对不要拆分成分布式事务。很多项目根本不是必须拆库拆服务纯粹是照着微服务架构“标准姿势”硬拆结果把简单的库存扣减搞成了分布式事务难题。架构设计的优先级永远是能本地事务就别分布式能最终一致就别强一致能不自研就别自研。第二没有对账的最终一致性都是在裸奔。我一直坚持不管团队选了哪种分布式事务方案最终都必须有一条对账链路兜底。对账粒度可以按业务不同调整资金类对账要每天跑甚至实时跑非资金类可以每周抽查。对账逻辑本身要独立于业务代码只依赖最基础的业务事实表。第三方案选型不是一次性的。系统流量涨到一定程度后原来能扛的TCC可能变得扛不住了原来能忍几十秒延迟的消息方案可能业务也开始接受不了。每半年做一次“一致性方案复盘”把链路延迟、补偿成功率、对账差异量拉出来看一眼早发现问题早调整。我实际做完这些复盘之后最大的体会是分布式事务没有银弹所有方案都只是把“一致性难”“可用性难”“研发复杂”这三个问题放在天平上重新排列组合而已。真正靠谱的做法是把CAP的取舍逻辑内化成自己的架构直觉在每一条业务链路上独立做判断。希望这篇梳理能帮你把这条直觉的骨架搭起来。
返回列表