分布式事务工程实战:Saga 编排与 TCC 协议在微服务架构中的生产落地

在微服务架构中,数据一致性是最棘手的技术挑战之一。本文深入拆解 Saga 与 TCC 两大主流分布式事务模式,从原理到代码,从设计陷阱到生产优化,给出可直接落地的工程实践方案。

一、为什么分布式事务如此棘手

单体架构中,一个 @Transactional 就能搞定的事务,在微服务拆分后变成了跨网络的分布式难题。银行转账场景:A 账户扣款成功,B 入账时网络超时,钱"消失了"——这就是典型的分布式事务不一致。

传统两阶段提交(2PC)在理论上能保证强一致性,但在生产环境中几乎不可用:

  • 同步阻塞:Coordinator 必须等待所有 Participant 响应,RT 急剧上升
  • 单点故障:Coordinator 宕机导致参与者持有锁无限等待
  • 脑裂风险:网络分区时部分节点 Commit 另一部分 Abort
  • 数据不一致:第二阶段 Commit 消息丢失导致永久不一致

因此,业界主流转向最终一致性方案——Saga 和 TCC。

二、Saga 模式:长事务的拆解哲学

2.1 核心思想

Saga 将一个大事务拆分为一系列本地事务 T1, T2, ..., Tn,每个本地事务都有对应的补偿事务 C1, C2, ..., Cn。如果 T3 失败,则逆序执行 C2、C1 进行回滚。

关键公式:T1 → T2 → ... → Tn(正向);Cn → ... → C2 → C1(补偿)

2.2 两种实现架构

编排式(Orchestration)

由一个中央协调者(Saga Orchestrator)控制事务流程,指挥各服务执行本地事务或补偿操作。

// Saga 编排器核心结构
type SagaOrchestrator struct {
    steps       []SagaStep
    currentIdx  int
    status      SagaStatus
    compensations []CompensationRecord
}

type SagaStep struct {
    Service     string          // 目标服务
    Action      func() error    // 正向操作
    Compensate  func() error    // 补偿操作
    ActionURL   string          // HTTP 端点
    CancelURL   string          // 补偿端点
}

// 执行 Saga
func (s *SagaOrchestrator) Execute(ctx context.Context) error {
    for i, step := range s.steps {
        s.currentIdx = i
        s.status = Executing
        
        if err := s.executeStep(ctx, step); err != nil {
            s.status = Failed
            // 补偿已完成的步骤
            return s.compensate(ctx, i-1)
        }
    }
    s.status = Completed
    return nil
}

// 逆序补偿
func (s *SagaOrchestrator) compensations(ctx context.Context, lastCompleted int) error {
    for i := lastCompleted; i >= 0; i-- {
        step := s.steps[i]
        if err := s.executeCompensation(ctx, step); err != nil {
            // 补偿失败需要人工介入 + 告警
            s.status = CompensationFailed
            return fmt.Errorf("compensation failed at step %d: %v", i, err)
        }
    }
    s.status = Compensated
    return nil
}

优点:流程清晰、调试方便、适合复杂业务流。

缺点:Orchestrator 成为逻辑上帝类,所有业务逻辑集中于一处。

协同式(Choreography)

无中心协调者,每个服务完成本地事务后发布事件,其他服务监听事件触发下一个事务。

// 协同式 saga:事件驱动
// 订单服务
func (s *OrderService) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    // 1. 创建订单(本地事务)
    orderID, err := s.orderRepo.Create(ctx, req)
    if err != nil {
        return err
    }
    
    // 2. 发布"订单已创建"事件
    event := OrderCreatedEvent{
        OrderID:    orderID,
        UserID:     req.UserID,
        TotalPrice: req.TotalPrice,
        Items:      req.Items,
    }
    
    return s.eventBus.Publish(ctx, "order.created", event)
}

// 支付服务:监听订单创建事件
func (s *PaymentService) HandleOrderCreated(ctx context.Context, event OrderCreatedEvent) error {
    // 尝试扣款
    err := s.paymentGateway.Charge(ctx, event.UserID, event.TotalPrice)
    
    if err != nil {
        // 发布支付失败事件,触发补偿
        return s.eventBus.Publish(ctx, "payment.failed", PaymentFailedEvent{
            OrderID: event.OrderID,
            Reason:  err.Error(),
        })
    }
    
    return s.eventBus.Publish(ctx, "payment.success", PaymentSuccessEvent{
        OrderID:   event.OrderID,
        PaidAmount: event.TotalPrice,
    })
}

优点:去中心化、服务松耦合、扩展性好。

缺点:流程隐含在事件流中难以追踪、调试困难、循环依赖风险。

2.3 Saga 设计的三大陷阱

陷阱一:补偿事务未考虑数据可见性

假设 T1 提交了订单创建,补偿 C1 执行"订单取消"。但在 T2(支付)执行期间,用户看到订单状态为"已完成",随后却变成了"已取消"——用户体感极差。

解决方案:引入中间状态(Processing/Cancelling),补偿时不直接翻转状态,而是设置 Cancelling,前端轮询时返回"处理中"。

陷阱二:补偿操作幂等性缺失

网络超时导致补偿重试,若 C1 将库存加两次,数据就错了。

// 补偿操作的幂等控制
func (s *InventoryService) CompensateReserve(ctx context.Context, txID string, items []Item) error {
    // 是否已补偿过
    if done, _ := s.compensationLog.IsCompensated(ctx, txID); done {
        return nil // 已处理过,幂等返回
    }
    
    // 记录补偿开始(防重入)
    if err := s.compensationLog.MarkCompensating(ctx, txID); err != nil {
        return err // 另一个线程正在补偿
    }
    
    // 执行库存释放
    for _, item := range items {
        s.inventoryRepo.Increase(ctx, item.SKU, item.Quantity)
    }
    
    return s.compensationLog.MarkCompensated(ctx, txID)
}

陷阱三:Saga 并发冲突

两个 Saga 实例同时操作同一商品库存,A 的 C1 补偿后 B 的 T2 又成功了,最终数据不一致。

解决方案:在 Saga 层面加乐观锁或使用业务时间戳校验(类似 Vector Clock)。

三、TCC 模式:三阶段精细控制

3.1 Try-Confirm-Cancel 哲学

TCC 通过三个阶段显式控制资源预留与确认:

  • Try:预留资源(检查 + 冻结),不是实际提交
  • Confirm:确认提交,真正完成业务
  • Cancel:取消预留,释放冻结资源

与 Saga 的区别:TCC 的 Try 阶段是"预占用",Confirm/Cancel 才是真正的提交/回滚。Saga 的每个 T 是真实提交,补偿是逆向回滚。

3.2 TCC 协议实现

// TCC 接口定义
type TCCParticipant interface {
    Try(ctx context.Context, bizID string, params interface{}) error
    Confirm(ctx context.Context, bizID string) error
    Cancel(ctx context.Context, bizID string) error
}

// 库存服务的 TCC 实现
type InventoryTCC struct {
    repo    InventoryRepository
    logger  *TCCLog // TCC 事务日志
}

// Try:检查库存并冻结
func (s *InventoryTCC) Try(ctx context.Context, bizID string, params interface{}) error {
    p := params.(ReserveParams)
    
    // 幂等检查——同一 bizID 是否已处理过
    state, _ := s.logger.GetState(ctx, bizID, "inventory")
    if state != "" {
        return nil // 已 Try 过
    }
    
    // 库存检查
    available := s.repo.GetAvailable(ctx, p.SKU)
    if available < p.Quantity {
        return ErrInsufficientInventory
    }
    
    // 冻结库存(可用减少、冻结增加、实际未扣减)
    s.repo.Freeze(ctx, p.SKU, p.Quantity)
    
    // 记录 Try 状态
    return s.logger.TryRecorded(ctx, bizID, "inventory")
}

// Confirm:真正扣减冻结的库存
func (s *InventoryTCC) Confirm(ctx context.Context, bizID string) error {
    // 是否已 Confirm
    if state, _ := s.logger.GetState(ctx, bizID, "inventory"); state == "confirmed" {
        return nil
    }
    
    // 确认是否已 Try
    if state, _ := s.logger.GetState(ctx, bizID, "inventory"); state != "tried" {
        return ErrInvalidState // 需要 Cancel 或处于不一致状态
    }
    
    // 将冻结转为真实扣减
    s.repo.ConfirmDeduction(ctx, bizID)
    
    return s.logger.ConfirmRecorded(ctx, bizID, "inventory")
}

// Cancel:释放冻结库存
func (s *InventoryTCC) Cancel(ctx context.Context, bizID string) error {
    state, _ := s.logger.GetState(ctx, bizID, "inventory")
    
    // 空回滚防护——如果 Try 未执行就收到 Cancel
    if state == "" {
        // 记录 Cancel 防止后续 Try 生效
        s.logger.CancelRecorded(ctx, bizID, "inventory")
        return nil
    }
    
    if state == "canceled" {
        return nil // 幂等
    }
    
    if state == "confirmed" {
        // 已 Confirm 的不能 Cancel
        return ErrAlreadyConfirmed
    }
    
    // 释放冻结库存
    s.repo.ReleaseFreeze(ctx, bizID)
    
    return s.logger.CancelRecorded(ctx, bizID, "inventory")
}

3.3 TCC 三大工程难题

难题一:空回滚(Empty Rollback)

Try 阶段因网络问题超时,TM 认为失败触发 Cancel,但实际 Try 还在执行中。Cancel 先执行造成了"空回滚",随后 Try 执行成功——资源被永久冻结。

解决:Cancel 阶段插入一条"取消记录",Try 时检查该记录是否存在。如存在则拒绝执行。

难题二:悬挂(Suspend)

Try 因网络慢未执行,Cancel 先执行成功(空回滚),随后 Try 到达——正常执行后永远无法 Confirm,资源被悬挂。

解决:Try 时检查已有 Cancel 记录,若存在则放弃 Try 并记录异常日志。

难题三:幂等

Confirm/Cancel 因网络重试多次调用,可能导致重复扣款或重复释放。

解决:状态机校验,每次操作前检查当前状态,"tried" 只能转 "confirmed" 或 "canceled","confirmed" 不可再变。

四、Saga vs TCC:何时选用

维度 Saga TCC
一致性强度 最终一致,中间状态对外可见 更高,Try 阶段不暴露最终状态
实现复杂度 低(只需定义补偿) 高(需三阶段,处处幂等)
性能 高(无锁执行) 低(Confirm/Cancel 需交互两次)
适用场景 长流程、跨多系统(旅行预订) 短事务、需要严格预留(金融转账)
回滚语义 补偿式(逆向操作) 取消式(丢弃预留)
数据隔离 读未提交数据可能被看到 Try 阶段完全隔离

五、生产实战经验总结

5.1 选型指南

  • 用 Saga:订单流程、工单审批流、数据同步等长流程场景
  • 用 TCC:账户余额扣减、库存强预留、资金划转等短事务强一致场景
  • 混合使用:大流程内嵌 TCC 短事务(电商下单:Saga 管流程,TCC 管库存)

5.2 幂等三件套

// 1. 全局唯一事务ID(业务ID+操作类型)
// 2. 状态机严格流转(tried → confirmed/canceled)
// 3. 去重表(bloom filter + DB 唯一键)

const IdempotencyKey = "tx_{bizID}_{step}_{action}"

// Redis + Lua 脚本实现原子幂等检查与记录
const luaScript = `
if redis.call("EXISTS", KEYS[1]) == 1 then
    return 0 -- 已处理
end
redis.call("SET", KEYS[1], ARGV[1], "EX", 86400)
return 1 -- 首次处理
`

5.3 超时与重试策略

// 重试配置
retryPolicy := RetryConfig{
    MaxAttempts:  3,
    InitialDelay: 100 * time.Millisecond,
    MaxDelay:     5 * time.Second,
    BackoffCoeff: 2.0,
    // 超时后执行补偿而非无限重试
    OnTimeout:    "compensate",
    // 异常白名单——哪些异常值得重试
    RetryableErrors: []error{
        ErrNetworkTimeout,
        ErrServiceUnavailable,
    },
    // 非重试异常,立即触发补偿
    NonRetryableErrors: []error{
        ErrInsufficientBalance,
        ErrAccountFrozen,
    },
}

5.4 Saga 可视化与监控

分布式事务失败时,排查链路极其痛苦。生产环境必须有三件套:

  1. Saga 执行日志:记录每个步骤的起止时间、状态、异常信息
  2. 分布式追踪:将 saga-id 注入 TraceID,串联全链路
  3. 自动告警:补偿失败、悬挂事务、超时未完成必须实时告警
// Saga 审计日志
type SagaAuditLog struct {
    SagaID        string    `gorm:"primaryKey"`
    BizType       string
    Status        string    // executing/completed/compensating/compensated/failed
    CurrentStep   int
    StepDetails   JSON      // 每个步骤的执行记录
    StartedAt     time.Time
    CompletedAt   *time.Time
    ErrorMessage  string
    CompensationAttempts int
}

// 监控告警规则
func sagaHealthCheck() {
    // 1. 超过 30 秒的 Saga 仍在执行中 → 告警
    // 2. 任何 CompensationFailed → P1 告警(需人工介入)
    // 3. 单个集群 Saga 失败率 > 5% → 告警
    // 4. 悬挂事务(try confirmed 但无后续)→ 告警
}

5.5 Seata 框架实战经验

Seata 是国内最常用的分布式事务框架,支持 AT、TCC、Saga、XA 四种模式。核心经验:

  • AT 模式适合中小企业:自动化程度高、但全局锁在高并发下可能成为瓶颈
  • TCC 模式适合核心链路:但必须自己处理幂等、悬挂、空回滚
  • Saga 模式适合长流程:Seata 1.5+ 支持状态机引擎,JSON DSL 定义流程
  • 存储模式选择:file 仅适合测试,生产用 DB 或 Redis 模式保存事务日志

六、写在最后

分布式事务没有银弹。Saga 牺牲隔离性换取性能与吞吐量,TCC 以复杂度为代价换取更强的数据一致性。在工程实践中,关键不是追求理论完美,而是:

  1. 充分理解业务容忍度:是否允许中间状态被看到?用户能接受"处理中"吗?
  2. 幂等贯穿始终:从 Try 到 Cancel,从重试到补偿,每个操作必须有幂等保证
  3. 自愈能力优先于预防:设计好异常恢复路径,人工兜底 + 自动补偿双管齐下
  4. 监控驱动治理:事务悬挂、补偿失败必须能在 5 分钟内被发现和定位

只有把"最终一致性"从口号落地为可监控、可恢复、可审计的工程体系,分布式事务才算真正达标。


作者简介:ybb,专注底层系统、内核工程与高性能基础设施架构。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部