分布式系统中的最终一致性:Saga模式与TCC的工程实现对比
一、分布式事务的取舍之痛
创业团队从单体制裁迁至微服务后,第一个遇到的硬骨头就是分布式事务。订单服务创建了订单,库存服务扣减了库存,但支付服务超时了。数据不一致就这样产生了。
二阶段提交(2PC)是传统方案,但它在微服务架构中有致命问题:锁资源时间长、单点故障阻塞、性能极差。工程实践中,最终一致性方案才是主流选择。Saga和TCC是两大核心模式,选型直接决定了数据一致性、代码复杂度和运维成本。
行业案例:一次订单超卖事故。某电商创业团队在促销活动期间,订单服务调用 Saga 模式创建订单、扣减库存、生成物流单。前两步成功了,第三步物流服务因第三方接口超时失败。Saga 补偿逻辑正确执行了:取消物流单、恢复库存。但问题出在中间状态——库存显示为 0 持续了 3 秒,这 3 秒内又进来了 12 个订单,页面却显示"已售罄"。这是 Saga 的隔离性缺陷:中间状态对并发用户可见。后续方案是库存服务在扣减接口上加分布式锁,牺牲了部分并发换取了隔离性。另一个金融科技团队,账户转账场景用了 Saga,结果一次网络超时导致扣了用户余额但未完成转账,补偿恢复余额时用户已经看到扣款记录,投诉量暴增。金融场景下最终改用了 TCC,Try 阶段冻结资金但不实际扣款,Confirm 才执行扣款。代价是代码量翻倍,但用户不再看到异常扣款记录。这两个案例的共同教训:分布式事务模式的选择不是在真空中做,要基于业务的容忍度和用户的可见性来决策。
二、Saga与TCC的原理对比
sequenceDiagram participant Order as 订单服务 participant Pay as 支付服务 participant Inv as 库存服务 participant Bus as 事件总线 Note over Order,Bus: Saga编排模式(Orchestration) Order->>Pay: 创建支付 Pay-->>Order: 支付成功 Order->>Inv: 扣减库存 Inv--xOrder: 库存不足 Order->>Pay: 取消支付(补偿) Pay-->>Order: 补偿成功 Order->>Bus: 订单取消事件Saga将长事务拆分为多个本地事务。每个本地事务完成后发布事件,触发下一个事务。如果某个步骤失败,执行对应的补偿操作回滚。
TCC则是两阶段的变体。Try阶段预留资源,Confirm阶段提交,Cancel阶段释放。关键区别在于:TCC在Try阶段就锁定了资源,而Saga不在执行阶段锁定。
sequenceDiagram participant Svc as 协调器 participant Res as 资源服务 Svc->>Res: Try(预留资源) Res-->>Svc: 预留成功 Note over Svc: 等待所有Try完成 alt 全部成功 Svc->>Res: Confirm(确认提交) Res-->>Svc: 提交成功 else 任一失败 Svc->>Res: Cancel(释放资源) Res-->>Svc: 释放成功 end三、生产级实现
3.1 Saga编排模式的实现
import asyncio import json from typing import Dict, List, Callable, Any from dataclasses import dataclass, field from enum import Enum import logging logger = logging.getLogger(__name__) class SagaStepStatus(Enum): """Saga步骤状态""" PENDING = "pending" RUNNING = "running" COMPLETED = "completed" COMPENSATING = "compensating" COMPENSATED = "compensated" FAILED = "failed" @dataclass class SagaStep: """Saga事务步骤定义""" name: str # execute和compensate必须成对定义 # 为什么必须成对?没有补偿步骤的Saga无法回滚,等同于裸奔 execute: Callable # 执行函数 compensate: Callable # 补偿函数 status: SagaStepStatus = SagaStepStatus.PENDING result: Any = None class SagaOrchestrator: """ Saga编排器 核心职责:按顺序执行步骤,失败时反向补偿。 为什么选择编排模式而非协同模式? 编排模式将流程逻辑集中在协调器中,可读性和可维护性更高。 协同模式(Choreography)通过事件驱动分散在各服务中, 流程逻辑散落各处,调试困难。创业团队应对复杂业务时, 编排模式更适合。 """ def __init__(self, saga_name: str): self.name = saga_name self.steps: List[SagaStep] = [] self.executed_steps: List[SagaStep] = [] # 已执行步骤的出栈 def add_step(self, step: SagaStep) -> None: """添加步骤。执行顺序即添加顺序。""" self.steps.append(step) async def execute(self) -> Dict: """ 执行Saga事务。 为什么用async?分布式事务中的每个步骤通常涉及远程调用, 同步等待会阻塞整个事务。异步模式可以配合超时控制。 """ logger.info(f"Saga[{self.name}]: 开始执行,共{len(self.steps)}步") for step in self.steps: try: logger.info(f"Saga[{self.name}]: 执行步骤[{step.name}]") step.status = SagaStepStatus.RUNNING step.result = await step.execute() step.status = SagaStepStatus.COMPLETED self.executed_steps.append(step) except Exception as e: logger.error( f"Saga[{self.name}]: 步骤[{step.name}]执行失败: {e}" ) step.status = SagaStepStatus.FAILED # 步骤失败,立即启动补偿流程 await self._compensate() return { "success": False, "error": str(e), "failed_step": step.name, "compensated_steps": len(self.executed_steps) } logger.info(f"Saga[{self.name}]: 所有步骤执行成功") return {"success": True, "steps": len(self.executed_steps)} async def _compensate(self) -> None: """ 反向补偿已执行的步骤。 为什么是反序补偿? 后执行的步骤依赖先执行的步骤,补偿必须反序撤销。 比如先扣库存后扣积分,补偿时先还积分后还库存。 """ logger.info(f"Saga[{self.name}]: 开始补偿,共{len(self.executed_steps)}步") for step in reversed(self.executed_steps): try: logger.info(f"Saga[{self.name}]: 补偿步骤[{step.name}]") step.status = SagaStepStatus.COMPENSATING await step.compensate(step.result) step.status = SagaStepStatus.COMPENSATED except Exception as e: # 补偿失败是最坏情况,需要人工介入 # 为什么不能自动重试? # 补偿操作本身可能有副作用,无限重试可能造成数据混乱 logger.critical( f"Saga[{self.name}]: 补偿步骤[{step.name}]失败: {e},需要人工处理" ) # 发送告警到On-Call self._send_alert(step.name, str(e)) def _send_alert(self, step_name: str, error: str) -> None: """补偿失败告警。生产环境对接PagerDuty或飞书。""" # 实际实现应调用告警服务 pass3.2 TCC模式(Try-Confirm-Cancel)的实现
from abc import ABC, abstractmethod from typing import Optional from redis import Redis import time class TCCResource(ABC): """ TCC资源接口 为什么需要frozen_timeout? Try阶段预留的资源不能永久冻结,超时后自动释放。 这是TCC的保底机制,防止预留资源泄漏。 """ @abstractmethod async def try_phase(self, biz_id: str, amount: int) -> bool: """Try: 预留资源""" pass @abstractmethod async def confirm_phase(self, biz_id: str) -> bool: """Confirm: 确认使用资源""" pass @abstractmethod async def cancel_phase(self, biz_id: str) -> bool: """Cancel: 释放预留资源""" pass class InventoryTCCResource(TCCResource): """ 库存TCC资源实现 核心思路:Try阶段不扣减真实库存,而是扣减"可锁定库存"。 为什么区分"真实库存"和"可锁定库存"? 允许多个TCC事务并发预留,提高并发能力。 """ def __init__(self, redis: Redis): self.redis = redis self.frozen_timeout = 300 # 冻结超时5分钟 async def try_phase(self, biz_id: str, amount: int) -> bool: """预留库存""" # Lua脚本保证原子性 # 为什么用Lua脚本?多步Redis操作需要原子执行, # 否则在高并发下可能出现超卖 lua_script = """ local available = tonumber(redis.call('GET', KEYS[1]) or '0') local frozen = tonumber(redis.call('GET', KEYS[2]) or '0') local amount = tonumber(ARGV[1]) -- 真实库存 - 已冻结库存 必须 >= 请求量 if available - frozen >= amount then -- 增加已冻结库存 redis.call('INCRBY', KEYS[2], amount) -- 记录业务冻结明细,用于超时释放 redis.call('SETEX', KEYS[3], ARGV[2], amount) return 1 end return 0 """ keys = [ f"inv:real:{biz_id}", # 真实库存 f"inv:frozen:{biz_id}", # 已冻结库存 f"inv:frozen_detail:{biz_id}" # 冻结明细 ] result = self.redis.eval(lua_script, 3, *keys, amount, self.frozen_timeout) return result == 1 async def confirm_phase(self, biz_id: str) -> bool: """确认扣库存""" lua_script = """ local frozen_amount = tonumber(redis.call('GET', KEYS[2]) or '0') -- 扣减真实库存并释放冻结 -- 为什么同时操作?保持一致性 redis.call('DECRBY', KEYS[1], frozen_amount) redis.call('DEL', KEYS[2]) redis.call('DEL', KEYS[3]) return 1 """ keys = [ f"inv:real:{biz_id}", f"inv:frozen:{biz_id}", f"inv:frozen_detail:{biz_id}" ] self.redis.eval(lua_script, 3, *keys) return True async def cancel_phase(self, biz_id: str) -> bool: """释放预留库存""" lua_script = """ local frozen_amount = tonumber(redis.call('GET', KEYS[2]) or '0') -- 释放冻结,恢复可锁定库存 redis.call('DEL', KEYS[2]) redis.call('DEL', KEYS[3]) return frozen_amount """ keys = [ f"inv:real:{biz_id}", f"inv:frozen:{biz_id}", f"inv:frozen_detail:{biz_id}" ] self.redis.eval(lua_script, 3, *keys) return True class TCCCoordinator: """ TCC事务协调器 为什么协调器需要幂等? 网络重试时Confirm/Cancel可能被重复调用。 """ def __init__(self, resources: Dict[str, TCCResource]): self.resources = resources async def execute(self, operations: list) -> bool: """ 执行TCC事务。 两阶段流程: Phase 1: 所有Try全部成功 → Phase 2: Confirm Phase 1: 任一Try失败 → Phase 2: Cancel所有已Try的资源 """ # Phase 1: Try tried = [] # 记录已Try的资源,用于回滚 for op in operations: resource = self.resources[op["resource"]] success = await resource.try_phase(op["biz_id"], op["amount"]) if not success: # Try失败,Cancel所有已Try的资源 for t in reversed(tried): await self.resources[t].cancel_phase(op["biz_id"]) return False tried.append(op["resource"]) # Phase 2: Confirm for op in operations: resource = self.resources[op["resource"]] confirmed = await resource.confirm_phase(op["biz_id"]) if not confirmed: # Confirm失败理论上不应该发生,但出现时全量Cancel # 注意:这里可能产生不一致,建议Confirm支持幂等重试 for t in reversed(tried): await self.resources[t].cancel_phase(op["biz_id"]) return False return True四、边界权衡
Saga的优缺点
优点:
- 实现简单,每个步骤独立开发
- 松耦合,适合跨服务、跨团队
- 无锁竞争,并发性能好
缺点:
- 缺乏隔离性,中间状态对用户可见
- 补偿逻辑是业务代码,易出错
- 补偿失败需要人工介入
TCC的优缺点
优点:
- 隔离性好,Try阶段锁定资源
- 最终一致性有强保证
- 适合金融等高一致性场景
缺点:
- 代码侵入性强,每个接口需要三个方法
- 锁资源时间较长,并发能力受限
- 协调器成为瓶颈
选型建议
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| 电商订单、旅行预订 | Saga | 步骤多、跨服务、容忍中间态 |
| 账户转账、积分扣除 | TCC | 资源锁定、强一致性 |
| 审批流、通知分发 | Saga | 补偿成本低、流程驱动 |
| 秒杀库存扣减 | TCC | 高并发、精确控制 |
取舍决策:何时从 Saga 升级到 TCC
创业团队起步时几乎都选 Saga,因为实现成本低。但有两个信号表明需要引入 TCC:
信号一:中间态暴露给用户引起投诉。当 Saga 的补偿窗口(从失败到补偿完成的时间)内,用户能看到不一致状态如余额显示错误、库存反复跳变,且这类投诉开始影响 NPS 评分时,需要引入 TCC 的 Try 阶段来预锁定状态。
信号二:补偿逻辑变得极其复杂。Saga 的美好在于补偿简单,但如果补偿本身涉及多个系统调用、依赖顺序,补偿成本超过了正面业务逻辑的成本,说明 Saga 已不适合。此时 TCC 的两阶段锁定虽然侵入性强,但每个阶段的逻辑更可控。
迁移策略建议:不是全系统切换到 TCC,而是对特定资源引入 TCC 接口,Saga 步骤中可以调用 TCC 资源的 Try/Confirm/Cancel。这样既保持了 Saga 编排的简洁性,又保护了关键资源的一致性。混用是性价比最高的实践。
五、总结
Saga和TCC解决的是同一个问题的两个方向。Saga追求简洁和松耦合,代价是中间状态可见。TCC追求强一致和隔离性,代价是代码复杂度和锁等待。
对于创业团队而言,首选Saga。它的实现成本更低,出问题时的排查路径更清晰。只有在涉及金钱、积分等不可妥协的场景时,才引入TCC。关键在于:选择一个模式后全线统一,不要让同一个系统同时使用两种模式,否则维护成本将成倍增长。