ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

分布式事务基石:CAP与BASE理论详解与实战抉择

分布式事务基石:CAP与BASE理论详解与实战抉择

做后端开发这些年,真正让我对“分布式”这三个字产生敬畏之心的,不是高并发,不是缓存击穿,而是分布式事务。标题里写的是“分布式事务(1)之CAP与BASE理论”,说实话,这个系列开头选得很准。不管你是做微服务、做中间件,还是单纯想把系统拆得更细一点,CAP和BASE都是一道绕不过去的坎。我在真实项目里见过太多人拿着ACID的思维去做分布式接口,结果上线第一天就出大问题;也见过团队为了追求所谓“强一致”把可用性折腾没了,最后业务直接卡死。这篇文章就把这两块理论掰开揉碎,结合我实际踩过的坑,讲清楚它们到底是什么、为什么存在、以及落到代码和架构层面该怎么想。不管你是刚接触分布式的小白,还是被线上问题折磨过的老手,读完之后应该都能形成一套自己的判断标准。

1. 为什么需要聊分布式事务

1.1 单机事务的美好

先回到最熟悉的场景。单体应用配一个数据库,我们写事务写得很舒服:开启事务,执行几条SQL,要么全部提交,要么全部回滚。这个过程由数据库的ACID特性来保证。原子性让一组操作变成不可分割的整体,一致性让数据始终满足约束,隔离性让并发互不干扰,持久性保证提交之后不丢数据。

在你只有一个库、一个服务实例的时候,这套机制几乎是无敌的。我过去做电商后台,订单表、库存表、账户流水表放在同一个MySQL实例里,begin transaction之后扣库存、写订单、记流水,哪一步失败直接rollback,数据永远对得上。那时候我甚至觉得“事务”这个概念根本不需要多想,交给数据库就行。

可问题在于,这种美好是有前提的。前提就是:所有需要原子修改的数据,都必须住在同一个物理存储里。一旦系统开始拆分,这个前提就变得非常脆弱。

1.2 分布式之后矛盾浮现

拆分的动机很现实。要么是业务复杂到单个应用撑不住,要么是数据量大到一个库扛不动,要么是团队协作上必须按域划分。于是微服务架构出来了,订单服务管订单库,库存服务管库存库,用户服务管用户库。每个库独立部署、独立扩展,彼此通过网络通信。

表面上一切都很合理。但是当用户下单这个动作需要同时修改订单库和库存库时,问题出现了。怎么保证两个库里的数据要么一起成功,要么一起失败?单机事务管不到另一个数据库,你在订单服务里开启的事务,不可能让库存服务那边跟着一起提交或回滚。

跨服务的调用是一条网络链路,而网络是不可靠的。调用可能超时、可能被重试、可能对端处理到一半宕机。这些都不是异常分支,而是分布式环境下的常态。这就是分布式事务要解决的核心问题:在不可靠的网络和独立的数据节点之上,如何让一组操作从状态上保持一致。

更麻烦的是,为了处理网络问题,你必须容忍一些在单机环境下不可想象的现象:数据短暂不一致、操作被补偿、消息重复消费。CAP和BASE,就是对这一整套现象的理论概括和方法论沉淀。

2. CAP定理:分布式系统的“不可能三角”

2.1 三个字母背后的含义

CAP定理是分布式系统领域基石级的结论,由Eric Brewer在2000年提出。它说的是:一个分布式系统,在一致性(Consistency)、可用性(Availability)、分区容错性(Partition tolerance)这三个属性中,最多只能同时满足两个。

这里的关键是,不能把CAP简单理解成“三选二随便选”。分区容错性P是底线。什么叫分区?就是网络里两个节点之间断了,消息发不过去,可能是交换机故障、网络抖动、机房断电,甚至可能只是一次GC停顿造成的长时间无响应。在真实的广域网和机房环境里,分区不是“会不会发生”的问题,而是“什么时候发生”的问题。

所以P不是可选项。一个分布式系统如果不能容忍网络分区,那它在外界环境稍微恶化一点的时候就会整体瘫痪。讨论CAP的时候,真正的问题永远只有一个:当P发生时,你是保C还是保A。

先明确三个词在这里的准确定义。一致性C,指的是线性一致性,也就是所有节点在同一时刻看到的数据是一样的,并且任何读操作都能读到最近一次写入的结果。可用性A,指的是请求在限定时间内一定能得到响应,而且这个响应不是错误,只是结果可能是旧数据。分区容错性P,指的是系统在部分节点失去联系的情况下,依然能对外提供服务。

2.2 为什么只能三选二

把道理讲透其实很简单。假设你有两个节点,分别保存着一份数据的副本,它们之间网络断了。这时候来了一个写请求,落在了节点1上。节点1能不能把这个写请求同步给节点2?不能,因为网络断了。

于是你面临两个选择。如果选择保C,也就是一致性,节点1必须拒绝这个写请求,因为它没法保证节点2也更新成功,否则一旦网络恢复,两个节点数据就不一致了。但拒绝请求就违反了A,因为你没有在限定时间内返回一个成功或合理的处理结果。

如果选择保A,也就是可用性,节点1就继续接收写请求并返回成功,哪怕明知道这个写入暂时没法同步给节点2。但这样一来,如果网络恢复之前有读请求落到节点2,读到的就是旧数据,C就被破坏了。

在P已经发生的前提下,C和A之间的冲突是逻辑上不可避免的,不是靠技术优化能绕过去的。所以CAP真正的表述是:在存在网络分区的场景下,系统必须在一致性和可用性之间做取舍。

2.3 关于CAP的常见误解

第一个误解是“二选一是指任何时刻只能选一个”。不是这样。CAP说的是分区发生时你的行为倾向,而不是系统日常只有一个属性。绝大多数系统在正常状态下可以同时表现得又一致又可用,只有分区发生时才会暴露出取舍倾向。

第二个误解是“牺牲C的系统完全不一致”。牺牲了一致性,换来的是最终一致,也就是数据能在分区恢复后慢慢对齐。这个过程是BASE理论要讨论的。

第三个误解是“CP比AP高级,或者AP比CP实用”。没有绝对优劣,只看业务能不能接受。我在一个订单系统中选择在库存扣减上偏向一致性C,是因为超卖不能接受;在用户头像昵称这类数据上选择偏向可用性A,是因为看一个暂时旧的头像完全没关系。

记住这个判断框架之后,再去看常见的所谓“分布式事务方案”,就能明白一件事:它们本质上都是在“分区发生时”如何权衡C和A的具体策略,以及如何在分区恢复后把数据拉回正确状态。

3. BASE理论:从ACID到最终一致

3.1 BASE究竟在说什么

BASE是Basically Available(基本可用)、Soft state(软状态)、Eventually consistent(最终一致)三个词的缩写,由Dan Pritchett提出。它是ACID模型在分布式场景下的对立补充,核心思路是:放弃强一致性,换取可用性和扩展性,然后通过各种机制让数据在某个时间点之后达到最终一致。

很多人第一次接触BASE时会觉得这是在和稀泥,“最终一致”听起来像“以后再说”。但真正做过后端的人会明白,最终一致不是不保证一致性,而是把保证从“事务提交那一刻”延后到了“系统稳定后的某个有限时间窗口内”。窗口可以是几百毫秒,也可以到分钟级,关键看你愿意为一致付出多少代价。

基本可用和传统意义上的高可用不完全一样。它允许系统在极端情况下出现降级表现。比如大促时购物车接口响应变慢,但不至于完全不可用;比如某条数据暂时读不到最新版本,但过一会儿就好了;比如某个非核心功能被关闭,核心链路不受影响。这些都是基本可用,也就是BASE里的“BA”。

软状态指的是介于中间的数据状态。对应到代码里,就是订单状态可能从“待支付”变成“已支付”,但在支付回调还没完全同步到各服务之前,不同接口看到的状态可能不一样。这个“不一样”的窗口期,就是软状态。

最终一致是BASE最重要的落点。它表示系统经过一定的交互时间后,所有副本的数据最终都会一致。注意,它没有规定这个时间有多长,也没有规定中间经过哪些状态,只要求一个非常明确的结果:最终,数据是对的。

3.2 最终一致性是怎么实现的

最终一致性不是软件自己蹦出来的,需要架构上有明确的机制去收敛数据。常见的路径包括异步队列、事件驱动、定时补偿、版本比对、可靠消息、状态机重试等。

举个例子,在真实项目里,用户支付成功之后,需要同步修改订单状态、增加用户积分、扣减库存、通知物流系统。如果我用的是强一致方案,这几个动作必须同时成功或同时失败,实现起来非常复杂且锁竞争严重。但如果我接受最终一致,就可以改成这样:支付回调先落库,确认支付记录;然后发一个“支付成功”的领域事件到消息队列;订单服务、积分服务、库存服务各自订阅事件,做自己的本地事务更新。某个服务处理失败,消息队列会重试;超过重试上限,就进入死信队列,由定时任务扫出来重新处理或者转人工。

这套链路里,每个消费者只保证本地事务的ACID,服务之间的一致性是靠消息的可靠性投递和消费方的幂等处理来拉齐的。

软状态在这里表现得很具体:订单服务已经改了状态,但积分服务还没到账,用户查询积分时看到的是旧值,过一会儿再查就对了。这种短暂不一致放大了用户体验的容忍度,但如果把窗口控制得足够短,用户根本感知不到。

3.3 ACID和BASE的尺度差异

ACID和BASE不是对立到势不两立的关系,而是不同粒度上的策略选择。同一个系统里,可以同时存在ACID风格和BASE风格的操作。

我的经验是:核心金融操作,比如账户扣款、余额校验,我会尽量维持强一致,即便在分布式环境里也要用分布式事务框架去硬扛;而非核心数据,比如用户标签、操作日志、推荐位缓存,我会放心大胆地走最终一致,不给自己找麻烦。

这里面有一个很重要的思维转换,就是事务边界的划分。单机里事务的边界是数据库连接;分布式里事务的边界变成了业务链路。ACID要求一条链路的所有步骤同时提交或回滚,BASE则允许这些步骤异步推进、逐步收敛。把这个边界想清楚,架构设计就顺了。

4. 真实业务里如何做取舍

4.1 先把业务需求想清楚

理论讲再多,最终还是要回到业务。很多人一上来就问“你们用什么分布式事务方案”,这其实是个顺序错误。正确的问题是:“我的业务能够容忍多长的数据不一致时间?”

如果答案是不能容忍,哪怕一分一秒都不行,那你就老老实实选带强一致语义的方案,比如TCC、两阶段提交(2PC)的变种,或者减少跨服务调用,把相关数据放在同一个库里用本地事务解决。如果答案是可以容忍几百毫秒或者分钟级,那消息队列配合重试和补偿就是最合适的选择。

我见过一个很有意思的反面案例:某个团队做裂变活动,用户参与活动领奖励的流程涉及活动服务、用户服务、奖励服务。他们为了追求理论上的完美一致性,用了2PC风格的分布式事务框架,结果奖励服务抖动一次,用户领奖请求就大面积失败,活动数据惨不忍睹。后来改成发消息异步处理,奖励放到最终一致,用户感知反而更好,因为奖励到账本来就是秒级延迟,没人会觉得奇怪。

这个案例让我明白了一个道理:在业务上区分“关键路径”和“非关键路径”非常重要。关键路径上的数据一致性要求高,可以付出更高代价去保证;非关键路径上的数据,宁可让用户稍等也不要阻塞主流程。

4.2 不同场景下的常见选型

选型没有万能公式,但有相对成熟的经验可以借鉴。我按分布式事务的常见实现方案,从强一致到最终一致列了一张表,方便对照:

方案一致性程度可用性代价典型场景
两阶段提交(2PC)强一致低,同步阻塞,资源锁持有时间长跨多个数据库的强一致SQL操作,主流中间件支持
TCC(Try-Confirm-Cancel)业务级强一致中等,需要编写三阶段代码跨服务的资金操作、订单+库存联动
本地消息表+消息队列最终一致高,主流程不阻塞异步解耦的订单、通知、积分、日志
事务消息(如RocketMQ事务消息)最终一致高,消息投递依赖Broker状态需要可靠异步通知的通用场景

2PC在实际项目里用起来比较痛苦,因为它的协调者会变成性能瓶颈和单点风险。数据库连接会被长时间占用,业务高峰期事务吞吐量上不去。除非你对强一致有硬性要求,否则我不建议一上来就上2PC。

TCC是对2PC的一种改良,把“准备”和“提交”的阶段换成了业务自己实现的Try、Confirm、Cancel三个操作。Try阶段尝试预留资源和检查业务;Confirm阶段真正执行业务;Cancel阶段做补偿回滚。

TCC的关键在于“预留”和“补偿”要设计得足够细。比如扣库存时,Try做的事情不是直接扣掉库存,而是冻结指定数量的库存;Confirm再把冻结转为真正扣减;Cancel则把冻结释放掉。这种设计能避免在确认阶段才发现的资源不足问题,但代价是代码量成倍增加。很多团队最后都觉得TCC太累,除非业务确实需要,否则宁可用消息队列。

4.3 落地方案里的补偿与重试

不管选哪种方案,补偿和重试都是绕不开的。

先说重试。分布式系统里,很多失败其实是暂时性的,比如对方服务正在GC、网络短时抖动,过几秒再请求可能就好了。所以一定要为重试设置合理的退避策略,比如指数退避加抖动,避免所有重试请求同时打到一个服务上。我习惯的默认参数是:初始重试间隔200毫秒,倍率2,最大间隔15秒,最多重试5次。当然这只是一个起点,不同服务和业务的超时特征不同,要压测后调整。

再说补偿。补偿不是简单地把之前调用的接口反向调用一次,而是要尽量让业务链路在任意中间状态都能被拉回正轨。补偿的触发条件一般有两种:执行超时或明确失败。

补偿动作必须是幂等的,这是所有补偿逻辑的第一步。我第一次做补偿的时候踩过一个大坑:订单取消逻辑里,库存服务提供了“释放库存”接口,但接口本身不幂等,网络超时导致前一次释放已经成功,补偿任务又调用了一次,直接出现负库存。从那以后,我在设计补偿接口时都会要求调用方传入唯一的业务流水号,服务端以流水号为维度做去重,确保同一次补偿只会真正执行一次。

还要给补偿留一条人工兜底的路。自动化补偿终归有上限,比如依赖的下游系统持续故障,补偿任务反复失败。这时候不能把故障无限吞下去,应该让任务进入一个特殊的状态,同时触发告警,留给真实的人去判断和处理。没有人工介入机制的弱一致系统,本质上是不完整的。

4.4 监控与对账的兜底

最后一定不能省的是监控和对账。最终一致系统最怕的不是“暂时不一致”,而是“永远不一致”。如果没有任何机制去发现那些永远不一致的脏数据,系统再完美也是隐形炸弹。

对账最常见的手段是定时扫描任务。比如每天凌晨对一遍订单状态和支付平台账单,对不上的就标记出来,进问题池进一步处理。另一个手段是状态机驱动,把所有业务状态转换放进一个有限状态机里,每周死状态或者状态乱跳的记录就会自动暴露出来。

我在项目中会专门做一张“事务执行记录表”,每个分布式事务链路的每次尝试、成功、失败、补偿都记录一行。这张表不只是用来排查问题,还能辅助分析每个链路的失败率和平均对齐时间。如果异常率一直偏高,我就能及时发现某个下游服务是不是有性能问题。这套机制听起来很重,但对于一个线上跑着真实业务流量的系统来说,它就是安全网。

5. 常见问题排查与避坑实录

5.1 踩过的坑一:把强一致当成默认

我记得自己最早做微服务改造的时候,遇到跨服务写数据就下意识地想“这个必须同步成功”。结果为了两个服务的数据强一致,我在代码里加了一堆分布式锁和同步回调,链路上每一步都可能卡住,整个接口响应时间从80毫秒飙到2秒,还动不动超时。

后来我把业务重新梳理了一遍,发现那个“必须同步成功”的数据其实只是用户的消费积分,晚几分钟到账用户根本不会发现。改成消息异步通知之后,接口响应时间降回100毫秒以内,积分最终一致,脏数据率也低到可以忽略。

这里想强调一下:不是要你盲目牺牲一致性,而是要你去挑战需求。当你发现一个业务需求让你付出巨大一致性代价时,先停下来问自己:这个业务真的需要强一致吗?往往答案是不需要。

5.2 踩过的坑二:P不能牺牲

还有一个容易犯的错,是试图通过“阻止分区发生”来回避CAP的取舍。比如把两个服务部署在同一台高可用虚拟机里,或者用一个网络质量很好的专线连起来,然后就觉得“分区不会发生,我可以既保C又保A”。

我吃过的教训来自一次机房级别的网络抖动。我们当时把订单和支付服务放在同一套物理环境里,以为网络非常可靠,甚至没有做降级方案。结果一次迁移操作导致瞬间断网,所有跨服务调用全部超时,订单服务直接不可用。如果当时我在接口层面预留了本地的降级策略,至少能保住一部分读请求和查询能力。

CAP里的P,牺牲不得。你只能接受“分区一定会发生”这个前提,然后在这个前提下去设计你的系统。任何试图无视分区的架构,本质上都是在赌博。

5.3 踩过的坑三:补偿逻辑一定要幂等

前面已经提到过,这里再展开说。幂等设计最常用的手段是唯一业务流水号。每次分布式事务发起时生成一个全局唯一ID,后续所有参与方的幂等校验都以这个ID为标准。

比如支付回调,支付平台可能因为网络重试把同一个回调发好几次。你在回调接口里根据支付流水号查本地记录,如果记录已经存在且状态已经是成功,就直接返回成功,不再重复处理。这个过程在消息消费里也一样,消费时先按消息ID查询处理记录,有则跳过,无则处理并落记录。死锁不是问题,网络重试才是。

我当时整理过一份幂等设计检查清单,在这里可以分享:所有写接口都要求业务侧幂等键;所有入库操作都有唯一索引兜底;所有异步任务都做状态位防重;所有补偿操作都先读取当前状态再判断是否继续。这套清单后来成了团队内部的代码审查标准。

5.4 排查分布式事务问题的工具和思路

真到线上出问题时,你的大脑首先要冷静下来,不要一头扎进日志里瞎翻。我一般按这样的顺序排查:

先确认当前是哪种不一致形态。是数据完全不落库,还是数据落库但状态没更新,还是回调重复触发了多次。这个信息决定了排查方向。

然后看调用链路上的日志,重点是对齐三个时间点:发起的起始时间、下游接收的时间、最终补偿完成的时间。从时间差上可以判断是不是有超时被误判为失败,从而触发了本不该出现的补偿。

最后看对账任务的结果。如果对账任务已经跑了,直接看对账差异的分布。如果差异集中在某一个服务,大概率是这个服务的幂等逻辑有问题;如果差异是随机分布的,可能是网络超时导致的重试链路上消息乱序。

排查分布式事务没有银弹,但我发现一个通用的方法论:把“状态的幂等”和“过程的顺序”分开看。只要每条数据的状态是收敛的,过程再怎么乱,最终也能经得起对账;如果状态本身不可收敛,那排查时就要优先处理状态错误。

最后聊点个人体会

回到最初那个问题:为什么一定要搞懂CAP和BASE?

我的体会是,分布式事务从来不是一个可以“全都要”的问题,而是一个风险置换的问题。你选择强一致,就要承受可用性和性能的代价;你选择最终一致,就要具备很强的补偿、监控、对账能力。两者没有高下之分,只有是否匹配你的业务。

这个系列既然叫“分布式事务(1)”,后面肯定还有具体的方案落地和实战代码。但无论后面讲什么,都建立在这篇理论之上。我真心建议你把CAP和BASE多读几遍,最好能结合自己线上真实出过的事故去验证,那时候你会发现,这两个理论真的不是纸面知识,而是每一个分布式架构决定背后的底层逻辑。想清楚这一点,后面再折腾各种事务框架,心里都不会慌。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进