分布式系统做到事务这块,就俩字:妥协。或者说得好听一点,叫实用主义。单体应用里由数据库一把梭的ACID事务,拆成微服务之后直接碎了一地——本地事务只能管自己那一个库,跨服务的数据一致性瞬间变成无主之地。这时候再死磕强一致,代价高得能把团队劝退,所以基于最终一致性的SAGA、TCC这类方案,就成了分布式事务里的主流选择。
这篇文章我把SAGA和TCC两种模式讲透:它们解决什么问题、底层思路是什么、落到代码上怎么写、跑起来之后会踩哪些坑。适合正在设计订单、支付、库存这类跨服务数据一致性方案的同学,也适合把分布式事务当成知识盲区、想系统补课的读者。看完之后你至少能回答一个问题:下个项目里,我到底该用SAGA还是TCC。
1. 为什么最终一致性成了“实用主义”的胜利
1.1 从2PC到SAGA/TCC:演进背后的逻辑
先快速回顾一下经典方案。XA协议和2PC(两阶段提交)在分布式事务里算是“学院派”的代表,思路很直接:带一个全局协调者,先让所有参与者把资源锁住、做好准备工作(Prepare),全部OK再统一提交(Commit),有任何一个失败就全体回滚(Rollback)。听起来天衣无缝,但在真实的互联网架构里,2PC几乎是没法用的。
为什么?问题出在“锁”。Prepare阶段要把资源锁住,这个锁要一直持有到全局Commit或者Rollback。跨服务调用本来就有网络延迟,再加上数据库连接、业务处理时间,这个锁动不动就持有个几百毫秒甚至几秒。放在高并发的秒杀、下单场景里,库存表那一行数据被锁住几秒钟,后面的请求全部堆积,数据库连接池直接被打爆。
更麻烦的是协调者本身成了单点。协调者挂了,所有参与者都处于“锁着等结果”的状态,既不敢提交也不敢回滚,整个业务链路全部卡死。就算协调者恢复了,分布在各数据库里的事务状态也可能不一致,需要额外做对账和人工修复。
所以SAGA和TCC这种“应用层事务”才站出来。它们的共同点是不再依赖数据库底层的锁机制,而是把事务拆成一个个可以独立执行的本地事务,配合补偿逻辑来兜底。进程内本地事务该提交就提交,提交完之后如果后续步骤失败,再通过补偿把前面的操作撤销。资源不长期占用,性能自然就上去了。
1.2 最终一致性到底“一致”在哪里
很多人一听到“最终一致性”就下意识觉得不靠谱,其实是对它的理解还停留在字面。最终一致性并不是说数据永远处于不一致的状态,而是允许系统在一段时间内处于中间状态,经过一段时间、经过一系列补偿和重试之后,数据最终回到一致。
用生活里的例子类比一下。你旅游订了机票、酒店和接机服务,这是一个跨三个商家的事务。如果航班取消了,你需要退机票、退酒店、取消接机。这个过程不会在你按下一个按钮的瞬间全部完成,可能是机票先退款到账,两天后酒店也退了,接机服务最后取消。这中间的三四天,你手里的钱和订单状态是“不一致”的——但从最终结果看,所有订单都正确关闭,钱也退干净了。这就是最终一致性在现实世界的形态。
落到系统设计里,最终一致性有几个关键点要拎清楚:一是中间状态必须“可控”,业务上能解释得通,不能出现订单已支付但库存没扣这种无法收敛的状态;二是必须有明确的补偿路径,每个正向操作都要有对应的逆向操作;三是系统要容忍短暂的不一致窗口,并能在后台通过重试、对账把数据拉回正轨。这三点想清楚了,再去看SAGA和TCC,就会觉得它们其实非常工程化,一点都不玄。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. SAGA:把大事务拆成一条带补偿的链
2.1 核心思想:事务拆解与逆向补偿
SAGA这个模式是1987年就提出的老古董了,但在微服务架构兴起之后才真正大放异彩。它的核心思想很简单:把一个跨服务的分布式事务,拆成一组有序的本地事务。每个本地事务执行完就提交,不持有锁;同时为每个本地事务配置一个“补偿事务”,当某一步失败时,逆序执行前面所有步骤的补偿事务。
正向流程是 T1 → T2 → T3,如果T3失败,就执行 C2 → C1,把T1、T2的结果翻回去。这里的C是Compensation,也就是补偿事务。补偿事务必须能真正“撤销”掉原来操作的业务影响:扣掉的库存要加回来、扣掉的钱要退回、创建的订单要取消。要特别提醒的是,补偿事务本身的业务逻辑不能依赖原事务的返回值,因为失败时你可能根本拿不到那个返回值。
这套思路在订单场景里非常典型。创建订单、扣库存、扣款,三个步骤分别由三个服务承担。扣款失败时,订单要取消,库存要回补。要是库存扣减成功了但创建订单失败,那只需要把库存加回去就行。因为是按顺序执行的本地事务,每一步的提交都很快,不存在长期锁资源的问题。
2.2 两种编排方式:编舞与编排
SAGA落地时有两条路线:Choreography(编舞模式)和Orchestration(编排模式)。很多文章翻译得很绕,其实就是“各服务自己通过事件协作”和“有一个中心协调者统一指挥”的区别。
编舞模式下,没有中心控制节点。服务A完成本地事务后,向消息队列发一个事件,服务B订阅到这个事件后开始执行自己的事务,执行完再发事件触发服务C。链路像舞蹈演员一样各跳各的,靠信号配合。优点是没有单点,服务间解耦,新增一个步骤只需要订阅对应事件就行。缺点是业务流程分散在各个服务的代码里,出了问题很难追踪——你可能要翻三四个服务的日志,才能拼出整个事务的完整轨迹。
编排模式则是一个中心化的SagaOrchestrator统筹管理。它像一个总导演,调用服务A的接口完成T1,再调用服务B完成T2,协调器本身可以基于状态机实现,每一步的状态流转都有据可查。哪个步骤失败,该触发哪个补偿,由协调器集中决策。
我给团队做技术选型时,第一选择永远是编排模式。原因很简单:分布式系统排障已经够痛苦了,能集中管理就别散落各处。编舞模式虽然更“优雅”,但线上事故时它会让你的排查时间成倍增加。为了省掉一个协调器的部署成本,不值得。
| 对比维度 | 编舞模式 Choreography | 编排模式 Orchestration |
|---|---|---|
| 中心节点 | 无 | 有(Saga Orchestrator) |
| 业务逻辑位置 | 分散在各服务 | 集中在编排器 |
| 新增步骤成本 | 低,只需订阅事件 | 中,需改编排器状态机 |
| 故障排查难度 | 高,逻辑分散 | 低,链路集中 |
| 单点风险 | 无 | 有,需做高可用 |
2.3 订单与库存的SAGA落地:一段可以直接抄的代码思路
用一个最经典的订单-扣库存-扣款场景来演示。下面是一段编排模式的Java伪代码,重点不是语法,而是它的结构。
java复制public class OrderSagaOrchestrator {
private final OrderService orderService;
private final InventoryService inventoryService;
private final PaymentService paymentService;
public void createOrder(OrderDTO order) {
String txId = UUID.randomUUID().toString();
try {
orderService.create(order, txId); // T1 创建订单
inventoryService.deduct(order, txId); // T2 扣减库存
paymentService.pay(order, txId); // T3 支付扣款
} catch (BusinessException ex) {
// 哪一步抛异常了,就逆序补偿
paymentService.compensatePay(order, txId); // C3 退回款项
inventoryService.compensateDeduct(order, txId); // C2 回补库存
orderService.compensateCreate(order, txId); // C1 取消订单
throw new DistributedTxException("订单创建失败,已回滚", ex);
}
}
}
注意几个细节。每个正向下游接口都接收一个txId,这个全局事务ID是整个链路串起来的关键,补偿和日志追踪都靠它。补偿操作不一定是全部执行,更健壮的做法是:百分百确定哪个步骤失败了,只补偿它之前的步骤。比如T2就失败了,根本不需要调用C3,因为T3压根没执行,补偿它反而会出问题。
实操中我更推荐把编排器和业务服务解耦,做一个独立事务状态表,每一笔SAGA事务都记录当前所处的步骤和状态。这样即使协调器进程重启了,也能从表里恢复未完成的事务继续跑。别小看这个设计,生产环境里协调器宕机恢复后能自动续跑,这是救命的。
3. TCC:把业务操作显式拆成Try/Confirm/Cancel
3.1 三个阶段和一个经典例子
TCC是Try-Confirm-Cancel三个单词的缩写,思路比SAGA更进一步——它不满足于出错后补偿,而是在业务执行前就先把资源“预留”出来。每个参与TCC事务的业务操作都要提供三个方法。
Try阶段负责资源预留和业务校验。比如扣款场景,就是检查余额够不够,然后冻结这部分钱;库存场景就是检查库存是否充足,然后锁定一部分库存。这段操作不真正扣减,而是把资源从“可用”状态变成“冻结”状态。Confirm阶段在Try全部成功后执行,把冻结的资源真正扣掉。Cancel阶段在Try阶段任一步失败时执行,把冻结的资源释放回可用状态。
我用一个钱包扣款例子来说明,这也是TCC最经典的应用场景。
| 阶段 | 数据库操作 | 业务效果 |
|---|---|---|
| Try | update account set frozen_amount = frozen_amount + 100 where id = 1 and available_amount >= 100 | 冻结100元,用户看到的可用余额减少,但总余额不变 |
| Confirm | update account set frozen_amount = frozen_amount - 100, available_amount = available_amount - 100 where id = 1 | 冻结资金转正,真正扣款 |
| Cancel | update account set frozen_amount = frozen_amount - 100 where id = 1 | 解冻100元,可用余额恢复 |
看到了吗,TCC的每一步都是业务里“真实存在”的操作,而不是抽象的数据库锁。它天然规避了2PC的长锁问题,因为Try阶段只是更新一条数据的状态字段,既不加数据库锁,也不占连接,即使跨服务调用慢一点,也不会拖垮连接池。
3.2 TCC的三个技术难点,每一个都是血泪教训
TCC看起来清晰,实际落地时最难的不是写代码,而是处理边界情况。三个难点我逐个说,全网能讲清楚的都不多。
第一个是幂等控制。Confirm和Cancel在超时、重试、网络抖动的情况下,可能被框架调用多次。如果Confirm里执行了“扣款100元”,重复执行就是扣两次款,这是重大事故。解决办法是在记录里加事务状态字段,每次执行Confirm或Cancel前先查状态,只有状态匹配才继续执行,然后立刻把状态改成已完成。用数据库的update影响行数来判断更稳妥——比如update status = 'CONFIRMED' where tx_id = ? and status = 'TRYING',影响行数为0就不处理。
第二个是空回滚。什么叫空回滚?就是Try还没有执行成功,Cancel先到了。比如协调器和服务A之间网络超时,协调器判断Try失败,直接发起了Cancel;实际上服务A的Try请求还在路上,或者刚到达还没执行完。如果不加保护,Cancel把冻结的100元解冻了,Try才姗姗来迟又把100元冻结了——这一冻就解不掉了,因为后面没人再给你发Cancel了。解决办法是在事务日志里记录Try是否执行过。Cancel执行时先查日志,如果Try没有执行记录,就写入一条标记,告诉后续的Try请求“这个事务已经被取消了”,Try到了也直接拒绝。
第三个是悬挂问题,跟空回滚正相反。Cancel先执行完了,Try迟到了。Cancel执行时发现Try没执行过,正常做了空回滚处理。但迟到的Try如果还继续执行,就会把资源冻结住,而且永远不会被释放。这就像人已经走了,饭才送到,结果饭菜晾在那里浪费了。处理办法是在空回滚的标记里记录事务ID,Try执行前先去检查这个标记,检测到事务已被取消就直接抛异常或者静默跳过。
这三个问题,任何一个不处理,TCC都有可能产生无法自动修复的数据问题。而且它们不像代码编译错误那样能静态发现,都是要在高并发、网络抖动下才会暴露,等线上出了事故再来补,代价就大了。
3.3 SAGA与TCC的对比选型
SAGA和TCC都是基于最终一致性,但适用场景差别很大,选错了后患无穷。我做了个对比表,这是我自己项目中总结出来的经验排序。
| 对比维度 | SAGA | TCC |
|---|---|---|
| 资源锁定 | 无预留,直接操作业务数据 | Try阶段显式预留资源 |
| 隔离性 | 弱,中间状态可见 | 相对强,冻结资源对外不可用 |
| 代码侵入性 | 较低,只需额外写补偿逻辑 | 较高,业务表要加状态字段 |
| 复杂度 | 中等,补偿逻辑要细心 | 较高,幂等/空回滚/悬挂都要处理 |
| 典型场景 | 订单、优惠券、积分等非资金链路 | 支付、账户、钱包等资金链路 |
我个人的选型逻辑很简单:账户、钱包、余额这类资金敏感操作,选TCC,因为TCC的“冻结”机制能避免在事务中间状态出现钱的超扣或短款;订单、库存、积分这类操作,SAGA就够了,业务上容忍短暂不一致,而且SAGA的代码侵入性小很多,不用为了一个订单流程把三套业务表全部加上冻结字段。
有个反直觉的提醒:不要因为TCC“更安全”就无脑选TCC。TCC要求参与者必须实现三个方法,意味着每个服务都要为事务专门开发代码,业务表还要维护额外的冻结字段,开发成本和维护成本都会直线上升。能用SAGA解决的场景,不要引入TCC。
4. 实操落地:全局事务ID、状态机与超时设计
4.1 全局事务ID与上下文传递
前面提到每笔分布式事务都需要一个全局事务ID,它是整个链路的“关联键”,不管是SAGA还是TCC都离不开。这个ID不是简单地传一次两次,而是要贯穿整个调用链,包括数据库记录、日志、补偿事件、消息队列消息。
我的做法是直接用UUID或者雪花算法生成事务ID,在入口网关处生成并放入RPC上下文。比如在Spring Cloud环境下用Feign的RequestInterceptor把txId塞进Header,下游服务通过HandlerInterceptor取出,存到ThreadLocal里。业务代码里每次写事务日志、调用补偿接口、打印关键日志,都要带上txId。这样排查问题时,拿着一个事务ID就能把从订单系统到支付系统到库存系统的所有日志串起来。
数据库层面至少要有一张分布式事务日志表,记录每笔事务的状态流转。这是最容易被忽略的设计,但经验告诉我,哪怕你暂时没做分布式事务框架,只是在代码里硬编码补偿逻辑,也应该附带上这张表。
sql复制CREATE TABLE t_transaction_log (
tx_id VARCHAR(64) PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL COMMENT 'PENDING/TRYING/CONFIRMING/CANCELLING/DONE',
current_step INT DEFAULT 0,
try_count INT DEFAULT 0,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
KEY idx_biz_type (biz_type, status)
) COMMENT '分布式事务状态日志表';
这张表的价值在于:协调器宕机了可以恢复,业务可以定时任务扫描状态异常的事务做兜底,财务对账也有据可查。我见过不少团队把分布式事务的状态都放在内存里,进程一重启就失忆,出了问题只能人工对数据库看。
4.2 超时与重试机制的设计
分布式事务里,网络超时和进程崩溃是常态,所以超时和重试机制必须提前设计好。刚开始做的时候很容易犯两个错误:超时时间设得太短导致大量误判,或者重试不做幂等导致重复操作。
我的经验值是这样的:跨服务调用的超时时间设在500ms到1秒之间,但分布式事务框架里的判定超时最好在3秒到5秒。为什么差这么多?因为普通RPC调用超时了可以直接失败返回,但分布式事务里你超时了并不能确定对方是否已经执行成功。不确定的情况下你就不能贸然补偿,正确的做法是启动“查询状态”流程,主动问下游服务这笔事务到底成没成功。
重试要遵循两个原则:一是必须有上限,不能无限重试,默认3次,重试间退避间隔按1秒、2秒、4秒递增;二是每次重试都要基于事务日志里查询到的下游状态来决策,而不是盲目重发。如果查询发现下游已经执行成功了,那就接着走下一步,不能返回失败再补偿。
4.3 与CQRS搭配时怎么处理异常和超时
最近很多团队在做CQRS+Event Sourcing架构,异步化程度高,SAGA和CQRS的搭配也成了热门的议题。这里的核心区别是:CQRS架构里写操作和读操作分离,命令和事件分开存储,SAGA编排的每一步都通过发事件来传递,而不是同步RPC调用。
在这种架构下,超时和异常的处理思路要调整。同步调用的补偿相对简单,按顺序逆序补偿就行。但事件驱动里,消息的消费顺序不是严格可控的,处理不好就会出现经典的“乱序事件”问题。比如扣款事件还没到,订单取消事件先到了,反过来把订单数据给改了。
我的经验是给每个领域事件都带上业务唯一键,消费者要做事件幂等校验,同一个事件只处理一次。SAGA编排器改成事件驱动加状态机的混合模式:接收事件触发状态流转,每个状态对应一个明确的处理动作和下一个要发布的事件。这样即使事件乱序,状态机也能识别出无效事件并丢弃。超时则通过延迟消息队列或者定时扫描事务日志来驱动,把迟迟没有触发下一步的事务挑出来查状态、续跑。
5. 踩坑实录与问题排查
5.1 四个真实线上问题,我一个个说
第一个坑是TCC的悬挂问题在压测时才暴露。当时测试团队用脚本并发压测下单接口,压了一轮之后对账发现,账户表里有几十笔“资金冻结”记录没有释放。查了半天,发现就是网络超时后Cancel先执行了,迟到的Try又成功冻结了资金,而后续代码没有任何机制拒绝这个迟到的Try。后来在Try方法入口加了“是否已取消”的校验才解决。这个坑告诉我,千万不要把TCC的边界问题留给测试去发现,上线前就要主动把空回滚和悬挂的处理代码写完。
第二个坑是SAGA的补偿操作没有做幂等。当时的一个订单场景,库存服务扣减超时,SAGA编排器判定失败,触发了补偿。但上游服务因为网络问题转入了重试,导致扣减操作又执行了一遍。补偿的结果是把一份库存补回去,实际扣掉的是两份库存——库存记录直接对不上了。后来我给所有补偿操作都加上了请求ID和幂等校验字段,并在数据库里加了唯一约束,才把这个问题根治。
第三个坑是事务状态表没有定时清理机制。线上跑了三个月,表数据膨胀到几百万行,查询这表都要几十毫秒了。当时设计时只想着记录状态,没考虑归档。后来加了定时任务,把完成状态超过30天的日志迁移到归档表,主表查询才恢复清爽。所有做事务日志表的团队,都要提前规划好数据的生命周期。
第四个坑是重试参数设置不当导致补偿风暴。有一次出现下游数据库抖动,所有事务都失败了,每笔事务在短时间内疯狂触发重试和补偿,把本来已经恢复的服务又压垮了。问题出在重试没有加“断路器”机制,失败率超过阈值时应该直接暂停分布式事务的执行,等下游恢复后再慢慢消化积压的事务。
5.2 常见问题速查表
我把日常群里、评论区里问得最多的问题整理成了表格,直接对应排查思路。
| 问题现象 | 可能原因 | 排查思路 | 解决方案 |
|---|---|---|---|
| 库存数据对不上,存在重复扣减或回补 | 补偿/重试未做幂等 | 查日志里同一txId是否被处理多次 | 给关键操作加唯一键和幂等校验 |
| 账户资金被冻结后一直没释放 | TCC悬挂,Try迟到 | 查事务日志确认Cancel是否先于Try执行 | Try入口检查“已取消”标记,主动拒绝 |
| 协调器重启后,未完成事务全部丢失 | 事务状态只存在内存里 | 检查是否有持久化的事务日志表 | 状态落库,启动时扫描恢复 |
| 一个下游服务故障,拖垮整个链路 | 重试无节制,触发补偿风暴 | 查看重试次数和频率指标 | 加重试上限+断路器,失败时熔断 |
| 数据库事务日志表查询越来越慢 | 数据无归档,膨胀严重 | 查看表数据量和慢SQL | 定时归档,完成态数据转历史表 |
| Spring本地事务和分布式事务叠加,自己调用自己方法事务失效 | 默认事务代理生效范围限制 | 检查Controller是否直接调Service内部方法 | 手动获取代理对象或用TransactionTemplate |
最后再补一个很多同学都会问的细节:TCC和Spring事务注解的关系。TCC里的Try、Confirm、Cancel三个方法,内部通常还是要依赖数据库本地事务的,也就是方法上照样需要加@Transactional注解。但要注意,本地事务只保证单服务里的数据一致,跨服务的分布式事务一致统一由TCC框架来协调。两者是叠加关系,不要搞混了。
我个人在做实际项目时的体会是,SAGA和TCC没有高低之分,只有合不合适。SAGA轻便灵活,适合大部分业务;TCC稳扎稳打,适合资金和强隔离场景。选型之前先把业务方的真实需求问清楚:能不能容忍短暂的不一致窗口?如果不能,就老老实实上TCC;如果能,别给自己找麻烦,SAGA就够用了。
最后再分享一个小技巧:不管用哪种模式,一定要把事务状态日志表建好,字段不用太多,但状态流转必须清晰。这张表是凌晨两点线上出问题时,唯一能救你的东西。
