分布式事务:2PC、TCC、Saga 与本地消息表的取舍 — editorial cover photo

分布式事务:2PC、TCC、Saga 与本地消息表的取舍

跨服务的数据一致性没有银弹,只有强一致、最终一致、尽力而为的连续光谱。

2025年5月22日·返回文章列表
文章大纲

单机事务靠数据库的 ACID 保证,开发者几乎不用操心一致性。可服务一旦拆分,问题就来了。一笔下单操作,订单要写进订单库,库存要扣减库存库,账户要在账户库冻结金额,三个库分布在三个不同的数据源。单机事务管不到跨库,要么三件事全成,要么全不成,这件事数据库不会替你做。分布式事务解决的就是这个"跨"字。

这里没有银弹。所有方案都在同一个权衡轴上滑动,一致性强度、性能、可用性、实现复杂度,四者不可兼得。强一致的方案必然牺牲性能和可用性,高可用的方案必然放松一致性。选型的核心从来不是"哪个最好",而是"业务到底能容忍多长时间的不一致"。这个问题一旦问清楚,方案自然收敛。

图:强一致代价高,大多数业务选 Saga 或本地消息表走最终一致

2PC,理论上正确,实践中几乎不用

PlantUML
正在加载图表…
PlantUML diagram

图:2PC 的强一致靠长时间持锁换来,协调者单点故障会让参与者卡在 PREPARE——这是它几乎不被生产使用的原因

两阶段提交(2PC)是最经典的强一致方案。它引入一个协调者,第一阶段让所有参与者 PREPARE,每个参与者锁定资源并表态能不能提交;第二阶段,协调者收到全部 OK 就发 COMMIT,有任何一方 NO 就发 ROLLBACK。

理论上这套逻辑无懈可击,所有参与者要么一起提交要么一起回滚。但它在工程上的代价让人没法用。第一,资源锁定时间长,从 PREPARE 到 COMMIT 整个事务期间,参与者持有的锁都不释放,高并发下这等于把吞吐量按在地上摩擦。第二,协调者是单点,它宕机后参与者会一直卡在 PREPARE 状态,锁着资源进退两难。第三,同步阻塞,任何一个参与者慢一拍,全体都得等。XA 协议是 2PC 在数据库层面的标准实现,但我接触过的生产系统几乎没有真正用 XA 跑核心链路的,性能损耗动辄几倍甚至十倍,扛不住。

2PC 的价值在于它定义了问题的边界。理解了它为什么不可用,才能理解后面那些方案为什么是那个样子。

TCC,用业务侵入换准强一致

PlantUML
正在加载图表…
PlantUML diagram

图:TCC 把一致性挪到业务层,Try 软约束预留、Confirm/Cancle 短操作——代价是开发量翻倍且必须防三大坑

TCC(Try-Confirm-Cancel)把一致性保证从数据库层挪到了业务层。每个操作拆成三步,Try 阶段预留资源(比如冻结 100 元但不真扣),Confirm 阶段确认提交(把冻结金额真正扣掉),Cancel 阶段回滚(解冻那 100 元)。一个事务协调器负责驱动这个状态机,所有 Try 成功就 Confirm,任何一个 Try 失败就全部 Cancel。

TCC 的好处是性能好,它不长时间持有数据库锁,Try 阶段的"冻结"是业务层面的软约束,Confirm 和 Cancel 都是短操作。资金类业务喜欢它,因为整个事务生命周期内一致性由业务代码精确控制。代价是业务侵入极重,原本一个扣款接口要变成三个接口,每个服务都得实现 Try、Confirm、Cancel 三套逻辑,开发量翻倍不止。

TCC 还有三个经典坑,没踩过的人很容易忽略。空回滚,Try 还没执行就收到了 Cancel,可能是网络超时导致协调器误判失败,业务得能识别这种情况并安全跳过。幂等,Confirm 和 Cancel 都可能被重试,必须保证重复执行不产生副作用,通常用事务状态表记录每个分支的执行进度。悬挂,Cancel 先于 Try 到达,之后迟到的 Try 又执行了,导致资源被预留却永远不会被 Confirm 或 Cancel,业务得在 Try 入口检查是否已经 Cancel 过。

这三个问题每一个都要专门的防御逻辑,加起来 TCC 的复杂度是所有方案里最高的。所以我只在资金扣减这类"宁可慢不能错"的场景才考虑它。

Saga,长事务的最终一致

Saga 适合流程长、可以容忍短暂不一致的场景。它把一个长事务拆成一串本地事务,T1 创建订单、T2 扣库存、T3 扣账户,每一步都是独立的本地事务立即提交。如果 T3 失败,就反向执行 C2 回补库存、C1 取消订单的补偿事务。

Saga 不锁资源,每步提交立即可见,吞吐量高。代价是中间状态对外可见,用户在 T2 成功到 T3 失败的瞬间,可能看到"库存扣了但订单没成"的中间态。这对很多业务是不能接受的,但对订单履约、对账这类长流程通常没问题。另一个代价是补偿逻辑的开发量,每一步正向操作都要对应一个反向补偿,流程越长补偿越多。

Saga 有两种协调方式。编排式(Orchestration)有一个中央协调器驱动流程,逻辑集中好维护,但协调器是单点。事件式(Choreography)每个服务监听上一步的事件触发下一步,完全去中心化,但流程分散在各服务里,出问题难追踪。生产里编排式更常见,可观测性更好。

本地消息表,最朴素却最实用

PlantUML
正在加载图表…
PlantUML diagram

图:本地消息表——业务和消息写入同一本地事务,轮询任务投递到 MQ,下游幂等消费,整条链路每一环都有兜底

说完前面三种重型方案,回到现实,90% 的业务场景用的其实是本地消息表。它的思路极其朴素,把业务操作和一条消息记录写在同一个本地事务里。比如下单时,一个事务内既写订单表又写一条"发送扣库存消息"的消息表,事务保证了两者要么都成功要么都失败。然后用一个独立的定时任务轮询消息表,把未发送的消息投递到 MQ,下游服务消费执行。投递成功就标记消息已发,失败就重试,下游消费保证幂等。

这套方案为什么好用。第一,它不需要引入任何分布式事务框架,一张消息表加一个轮询任务就搞定。第二,可靠性来自本地事务这个已经被验证过无数次的机制,业务成功则消息一定记录,消息一定记录则一定能投递出去,投递出去则下游一定能消费(幂等保证不会重复执行)。整条链路每一环都有兜底。第三,它天然支持重试和补偿,消息表就是天然的事务日志,出问题能查能补。

代价是最终一致而不是强一致。消息从记录到被下游消费有延迟,通常秒级,业务必须能容忍这个窗口。我做过的好几个系统,营销发券、订单履约、积分发放,全都用的本地消息表,从来没出过一致性问题。

方案一致性强度性能复杂度适用场景
2PC / XA强一致几乎不用
TCC准强一致很高资金、核心交易
Saga最终一致长流程业务
本地消息表最终一致绝大多数场景

RocketMQ 的事务消息是本地消息表的一种变体。它先发一条半消息到 MQ,执行本地事务,再根据本地事务结果决定 commit 或 rollback 这条半消息,用一个回查机制兜底超时的半消息。和本地消息表相比,它把消息表的事务保证能力下放给了 MQ 自己,省掉了自己维护消息表和轮询任务。如果项目本来就用 RocketMQ,事务消息是更省事的选择。

选型的第一问永远是容忍度

分布式事务最容易犯的错就是"为了强一致而上强一致"。很多团队一听到跨服务一致性,第一反应就是上 TCC 或者 Seata,完全没问过业务能不能容忍几秒的延迟。绝大多数业务,订单、库存、营销、消息推送,都能容忍几秒到几十秒的最终一致。这些场景上 TCC 是用十倍的复杂度换一个根本不需要的强一致,纯属自找麻烦。

我的实践顺序是这样的。先问业务能不能容忍延迟,答案几乎都是能。能容忍就用本地消息表加幂等消费,简单可靠。只有资金扣减这种"宁可慢不能错"的才考虑 TCC 的复杂度,而且要做好空回滚、幂等、悬挂的防御。长流程业务用 Saga,把补偿逻辑想清楚。

无论选哪个方案,有三条通用原则。每个步骤必须可补偿,失败时能回滚或抵消,没有补偿的步骤就是单点故障。每个操作必须幂等,重试不会产生副作用,这是分布式环境的基本功。全链路必须可观测,事务卡在哪一步、补偿执行到哪个分支,随时能查得到,否则出问题只能抓瞎。这三条做不到,再先进的框架也救不了你。

TCC 的空回滚和悬挂是隐蔽的坑

很多人写 TCC 只实现了 Happy Path,Try 成功后 Confirm 或 Cancel 正常走。生产里真正出事的都是异常路径。空回滚(Cancel 先于 Try 执行)、悬挂(Cancel 后迟到的 Try 又执行了)会悄悄制造脏数据,而且平时不发作,只在网络抖动时偶现,排查极难。每个 TCC 接口都必须有事务状态表记录分支进度,Try 入口检查是否已 Cancel,Cancel 入口检查是否真正 Try 过。

先问容忍度,再选方案

分布式事务选型的第一问不是"用哪个框架",而是"业务能容忍多长的不一致窗口"。这个答案决定了一切。能容忍秒级延迟,本地消息表足够,别碰 TCC。只有资金这种零容忍场景才值得为 TCC 买单。见过太多团队给营销发券这种场景套上 Seata AT 模式,结果全局锁把性能拖垮,最后又费大力气拆掉。

Continue Reading

关联文档推荐

查看全部
分布式事务一致性治理:最终一致的兜底闭环 — vibrant cover for technical blog article短链系统 — editorial cover photo多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog article
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。