分布式事务没有银弹:从CAP定理到AT与TCC模式的选择指南

假设一个场景:线上一个用户下单,余额扣了、库存也扣了,订单却显示未支付。排查到最后,是订单库、账户库、库存库三个库各改各的,谁也管不了谁。这是分布式事务上的第一课:跨库操作没有本地事务兜底,出问题只是早晚的事。

一、一个扣款场景,把问题逼出来

分布式事务要解决的,不是"一次操作改多个地方",而是"多个地方要么同时成功、要么同时失败"。

本地事务(Local Transaction) :在同一个数据库内完成的事务,要么全部提交,要么全部回滚。你可以理解为"同一本账本上的记录,错了一整页都能涂掉重写"。

用生活里的场景类比:三个人合租,住在 1 楼、3 楼、5 楼,晚上要同时关灯,得挨个通知。只要中间有一层没听到,就有一个人摸黑进门。跨库操作就是这种"挨个通知"的活,而本地事务只会管自己那盏灯。

分布式事务(Distributed Transaction) :跨越多个数据库或服务的操作集合,要求所有参与方要么一起成功、要么一起回滚。你可以理解为"几本分处各地的账本,想在同一时刻改对"。

这篇文章我打算把分布式事务从原理到落地串一遍:先看 CAP 定理为什么让这条路这么难走,再看 BASE 理论给出的妥协方案,最后对比 AT 模式和 TCC 模式这两种主流实现,以及它们各自的坑。收藏一下,我们开始。

二、CAP定理:分布式系统的天花板

在 CAP 定理里,分区容错是必选项,一致性和可用性只能二选一。

CAP 定理(Consistency, Availability, Partition tolerance) :分布式系统不可能同时满足一致性、可用性、分区容错性,最多同时保证其中两个。你可以理解为"酒店的地点、价格、房间大小,最多让你挑两个满意"。

三个指标拆开看:

1)一致性(Consistency) :用户访问任意节点,读到的数据必须一样。node01 改了余额,node02 必须跟着变,否则用户换个节点就查到旧账。

2)可用性(Availability) :读或写总能成功。只能读不能写、只能写不能读,或者都执行不了,都算弱可用或不可用。

3)分区容错(Partition tolerance) :节点之间的网络断了,系统也要继续对外服务。Partition 就是网络故障把系统劈成几个互不相通的"孤岛"。

关键矛盾在这:网络不会 100% 保证畅通,分区一定会出现;而系统又必须持续运行。所以分区容错是硬指标,所有分布式系统都得满足。剩下的,只能在一致性和可用性里挑一个:

  • 选可用性(AP):节点继续读写,但同步不了的节点数据不一致,事后靠补偿慢慢收敛。
  • 选一致性(CP):把数据锁住,等网络恢复再放行,期间系统不可用或只能读。

现实就是这么不讲道理------只要网络会断,你就得在 A 和 C 之间二选一。

把取舍画成流程图,一眼就能看懂。看图 1:网络分区一旦出现,往左走是 AP,数据先乱一阵再收敛;往右走是 CP,先锁住不动,一致但不可用。

三、BASE理论:不强一致,但最终一致

BASE 理论放弃的不是一致性,而是强一致性------把"必须立刻一致"换成"最后一致就行"。

BASE 理论(Basically Available, Soft State, Eventually Consistent) :分布式系统允许损失部分可用性、出现中间状态,最终把数据收敛到一致。你可以理解为"先把事办了,睡前保证把账平掉"。

三个词各管一段:

  • 基本可用(Basically Available) :出故障时损失部分可用性,保住核心功能。抢购高峰让你排队等一会儿,而不是直接 502。
  • 软状态(Soft State) :允许存在中间状态,比如数据暂时对不上。
  • 最终一致性(Eventually Consistent) :不要求立刻一致,但中间状态结束之后,数据最终会一致。

顺着 BASE 的思路,分布式事务的解法分成两个方向:

  • AP 思想:各分支各自执行、各自提交,不锁数据。允许结果不一致,之后用补偿手段恢复,实现最终一致性。AT 模式走这条路。
  • CP 思想 :各分支执行完先不提交,等彼此的提交结果,再一起提交或回滚。期间锁住资源,数据不可用,但保证一致。XA 模式走这条路。

所以分布式事务从来没有"一种标准答案",关键是先想清楚:你打算牺牲一致性,还是可用性?

四、AT模式:框架帮你补偿,但别忽视脏写

AT 模式最大的卖点是不用写补偿代码,代价是并发场景下容易踩脏写的坑。

AT 模式(Automatic Transaction)Seata 提供的一种分布式事务实现,利用数据快照自动回滚,开发者几乎无感。你可以理解为"系统定期给你的数据拍照,出事就按照片恢复"。

坦白讲,我最早就是无脑选了 AT 模式,因为它接入成本最低,几个注解就搞定。它分两个阶段走:

阶段一:每个分支事务执行前,先记录数据快照,再正常执行、正常提交。

阶段二 :全局协调者汇总各分支的结果------全部成功,说明事务在阶段一已经提交完了,这里只需要删掉快照;只要有分支失败,就按快照把数据恢复到更新前,再删快照。

这个两阶段流程用文字说容易绕,看图 2,重点看阶段二的两个出口。

大多数场景下,AT 模式确实省心。但在极端情况,尤其是多线程并发访问同一个 AT 事务里的数据时,会出现脏写------某个分支回滚,把另一个事务刚写入的新值,一并覆盖回旧值了。

两个线程同时改同一条库存记录,一个事务回滚,另一个线程刚提交的新值被快照覆盖,账又对不上。排查了整整一天,最后锁定位在脏写。好家伙,快照回滚居然能滚出脏数据,这谁能想到?

解决办法是引入全局锁:在分支释放数据库锁之前,先拿到全局锁,保证同一时刻只有一个事务能操作这条数据,回滚时就不会踩到别人刚写的东西。

可能有人会问:AT 模式加了全局锁,并发性能不就废了吗?

这就是取舍。全局锁把冲突行的并发度压到 1,多个线程抢同一行时吞吐明显下降。以我现在的经验,写冲突不严重、读多写少的业务用 AT 很划算;写热点集中的场景,老老实实考虑 TCC 或者消息队列

五、TCC模式:补偿逻辑自己写,更可控

TCC 模式牺牲了编码量,换来的是对补偿过程的完全掌控,适合高并发和补偿逻辑复杂的业务。

TCC 模式(Try-Confirm-Cancel) :把分布式事务拆成预留、确认、取消三个阶段,三个方法都由开发者手写。你可以理解为"先交定金把东西占住,再付尾款,反悔了就退定金"。

三个方法各管一件事:

  • try:检查并预留资源。比如检查余额够不够,够就先把要扣的金额冻结起来。
  • confirm:真正完成业务。前提是 try 成功了,confirm 一定要能成功------confirm 里只放不会失败的收尾动作。
  • cancel:释放预留的资源,是 try 的反向操作。

用一个扣款例子串一遍。账户 A 余额 100,要扣 30:

1)Try:余额充足,冻结 30,可用余额变 70。此时总余额(冻结 + 可用)还是 100,分支事务直接提交,不用等别的分支。

2)Confirm:可用余额已经扣过了,直接把冻结的 30 划走,总余额变 70。

3)Cancel:释放冻结,冻结的 30 归零,可用余额回到 100。

TCC 的核心思想一句话就能讲透:扣款不直接扣,先锁进一个"冻结"口袋。看图 4 会更直观。

但 TCC 的坑也藏在手写逻辑里。一个分布式事务里有两个分支,try 阶段分支 A 成功、分支 B 阻塞。阻塞太久,全局事务超时,二阶段 cancel 触发,两个分支都要执行 cancel------可分支 B 压根没执行过 try。

空回滚(Empty Rollback) :分支事务还没执行 try 就被要求 cancel,此时 cancel 必须什么都不做。你可以理解为"客人还没进店,店员就开始退他定金了"。

更麻烦的是,分支 B 的 try 阻塞结束后还会继续执行------但全局事务已经结束了,永远不会有 confirm 或 cancel 再来。这个事务只执行了一半,悬在那里:

事务悬挂(Transaction Suspension) :分支的 try 被阻塞,全局事务已超时结束,阻塞结束后 try 才执行,整个事务永远收不了尾。你可以理解为"主人全家都出门了,客人还站在门口按门铃"。

try 还没执行,cancel 先到了;主人走了,客人还在门口。这两个问题不处理,线上迟早出事。

好在解法也明确:写 try 和 cancel 时维护一张事务状态表。只有 try 成功记录过,cancel 才允许真正回滚;try 执行前先查状态,确认全局事务还活着再动手。

六、怎么选:没有银弹,只有取舍

分布式事务没有银弹,选方案的本质,是选"你能接受哪方面的损失"。

方案 补偿方式 代码量 并发表现 适合场景
XA(CP) 数据库两阶段提交 低(锁资源) 金融级强一致
AT 模式(AP) 框架快照自动回滚 常规业务快速接入
TCC 模式(AP) 手写 try/confirm/cancel 高并发、补偿逻辑复杂

绝大多数业务用 AT 模式就能撑住,重点别把它当银弹;写热点集中、补偿逻辑绕的场景,才值得为 TCC 多写那几个方法。

可能有人会问:消息队列(本地消息表、事务消息)算不算分布式事务方案?

算。它是另一种思路------先把本地改动落库,再把"要通知下游"这个动作也可靠地投递出去,靠消息做到最终一致。好处是彻底不锁资源,坏处是做不到同步强一致,下游消费有延迟。选哪种,看你的业务能接受多少延迟。

说白了,每一类分布式事务方案,都是在"一致性、可用性、代码量"三者里做减法。在我这边,能异步走消息队列的优先消息队列,其次 TCC,AT 模式适合快速接入,XA 只留给真正必须强一致的核心链路。

相关推荐
YuePeng2 小时前
不写一行接口,让 DBeaver 直连你的指标层——背后只用了一个端口
后端·架构·github
董员外4 小时前
RAG 系统进化论(七):Multimodal RAG(多模态 RAG),当知识存在于表格、图片和页面中
人工智能·后端·设计模式
用户667675093794 小时前
Java 是如何操作Redis的?从 Spring Data Redis中RedisTemplate 源码分析 ZSet 调用链
后端
凌虚4 小时前
Kubernetes 编年史:从 Borg 到云原生操作系统
后端·程序员·kubernetes
计算机魔术师5 小时前
传统基础架构 - 单体
后端
SelectDB5 小时前
金城银行基于 Apache Doris 构建实时数据平台:T+1 到分钟级的金融级实践
后端
Csvn5 小时前
📊 SQL 入门 Day 13:窗口函数进阶
后端·sql
卷无止境5 小时前
拯救乱码方块:pandas 绘图中文字体的一揽子解决方案
后端·python
SelectDB5 小时前
Apache Doris 4.1:面向 AI & Search 的统一数据底座怎么选?向量检索 + 全文搜索 + 100MB JSON 完整能力拆解
后端