浅谈分布式事务#2

2026/7/16 17:00:00Henry5 阅读0 点赞0 评论

续前文

前文已经介绍了事务的基本概念,引出了分布式事务的具体场景和存在的难点。

基于此,之前提出了基于事务消息的解决方案,它能够利用消息队列 At lease once 的特性达到接近百分之百的消费可靠度,但是这个方案依然存在一个无法接近且严重的问题:依然无法完成下游失败上游回滚的需求。这一点是 TX Msg 中存在的最大的局限性。

所以我们引出一个全新的分布式事务解决方案:TCC。

TCC 简述

TCC,全称 Try-Confirm-Cancel,指的是将一笔状态数据的修改操作拆分成两个阶段:

  • 第一个阶段是 Try,指的是先对资源进行锁定,资源处于中间态但不处于最终态;
  • 第二个阶段分为 Confirm 和 Cancel,指的是在 Try 操作的基础上,真正提交这次修改操作还是回滚这次变更操作。
sequenceDiagram
autonumber
actor Client as 客户端
participant TM as TCC事务协调器
participant A as 服务A
participant B as 服务B

Client->>TM: 发起分布式事务

rect rgb(235, 245, 255)
    Note over TM,B: 第一阶段:Try 资源预留
    TM->>A: Try:检查并预留资源
    A-->>TM: Try成功

    TM->>B: Try:检查并预留资源
    B-->>TM: Try成功
end

alt 所有 Try 都成功
    rect rgb(235, 255, 235)
        Note over TM,B: 第二阶段:Confirm 正式提交
        TM->>A: Confirm:正式使用预留资源
        A-->>TM: Confirm成功

        TM->>B: Confirm:正式使用预留资源
        B-->>TM: Confirm成功
    end

    TM-->>Client: 事务成功

else 任意 Try 失败
    rect rgb(255, 240, 240)
        Note over TM,B: 第二阶段:Cancel 释放资源
        TM->>A: Cancel:释放已预留资源
        A-->>TM: Cancel成功

        TM->>B: Cancel:释放已预留资源
        B-->>TM: Cancel成功
    end

    TM-->>Client: 事务失败
end

在 TCC 分布式事务架构中,包含三类角色:

  • 应用方 Application:指的是需要使用到分布式事务能力的应用方。
  • TCC 组件 TCC Component:指的是需要完成分布式事务中某个特定步骤的子模块. 这个模块通常负责一些状态数据的维护和更新操作,需要对外暴露出 Try、Confirm 和 Cancel 三个 API:
    • Try:锁定资源,通常以类似【冻结】的语义对资源的状态进行描述,保留后续变化的可能性;
    • Confirm:对 Try 操作进行二次确认,将记录中的【冻结】态改为【成功】态;
    • Cancel:对 Try 操作进行回滚,将记录中的【冻结】状消除或者改为【失败】态,其底层对应的状态数据会进行回滚。
  • 事务协调器 TX Manager:负责统筹分布式事务的执行:
    • 实现 TCC Component 的注册管理功能;
    • 负责和 Application 交互,提供分布式事务的创建入口,给予 Application 事务执行结果的响应;
    • 串联 Try -> Confirm/Cancel 的两阶段流程. 在第一阶段中批量调用 TCC Component 的 Try 接口,根据其结果,决定第二阶段是批量调用 TCC Component 的 Confirm 接口还是 Cancel 接口。

TCC 案例分析

下面我们引入一个具体的分布式事务场景问题,并通过 TCC 架构加以实现,帮助大家进一步提高对 TCC 分布式事务方案的认识。

现在假设我们需要维护一个电商后台系统,需要处理来自用户的支付请求。每当有一笔支付请求到达,我们需要执行下述三步操作,并要求其前后状态保持一致性:

  • 在订单模块中,创建出这笔订单流水记录
  • 在账户模块中,对用户的账户进行相应金额的扣减
  • 在库存模块中,对商品的库存数量进行扣减

上面这三步操作分别需要对接订单、账户、库存三个不同的子模块,底层的状态数据是基于不同的数据库和存储组件实现的,并且我们这套后台系统是基于当前流行的微服务架构实现的,这三子个模块本身对应的就是三个相互独立的微服务,因此如何实现在一笔支付请求处理流程中,使得这三笔操作对应的状态数据始终保持高度一致性,就成了一个非常具有技术挑战性的问题。

首先,我们基于 TCC 的设计理念,将订单模块、账户模块、库存模块分别改造成三个 TCC Component,每个 Component 对应需要暴露出 Try、Confirm、Cancel 三个 API,对应于冻结资源、确认更新资源、回滚解冻资源三个行为。

同时,为了能够简化后续 TX Manager 和 Application 之间的交互协议,每个 TCC Component 会以插件的形式提前注册到 TX Manager 维护的组件市场 Component Market 中,并提前声明好一个全局唯一键与之进行映射关联。

由于每个 TCC Component 需要支持 Try 接口的锁定操作,因此其中维护的数据需要在明细记录中拆出一个用于标识 “冻结” 状态的标签,或者在状态机中拆出一个 “冻结” 状态。

最终在第二阶段的 Confirm 或者 Cancel 请求到达时,再把 ”冻结“ 状态调整为 ”成功“ 或者 ”失败“ 的终态。

基于 TCC 架构实现后,对应于一次支付请求的分布式事务处理流程:

  • Application 调用 TX Manager 的接口,创建一轮分布式事务:

    • Application 需要向 TX Manager 声明,这次操作涉及到的 TCC Component 范围,包括订单组件、账户组件和库存组件;
    • Application 需要向 TX Manager 提前传递好,用于和每个 TCC Component 交互的请求参数( TX Manager 调用 Component Try 接口时需要传递)。
  • TX Manager 需要为这笔新开启的分布式事务分配一个全局唯一的事务主键 Transaction ID;

  • TX Manager 将这笔分布式事务的明细记录添加到事务日志表中;

  • TX Manager 分别调用订单、账户、库存组件的 Try 接口,试探各个子模块的响应状况,并尝试锁定对应的资源;

  • TX Manager 收集每个 TCC Component Try 接口的响应结果,根据结果决定下一轮的动作是 Confirm 还是 Cancel:

    • 倘若三笔 Try 请求中,有任意一笔未请求成功:

      • TX Manager 给予 Application 事务执行失败的 Response;
      • TX Manager 批量调用订单、账户、库存 Component 的 Cancel 接口,回滚释放对应的资源
      • 在三笔 Cancel 请求都响应成功后,TX Manager 在事务日志表中将这笔事务记录置为 [失败] 状态。
    • 倘若三笔 Try 请求均响应成功了:

      • TX Manager 给予 Application 事务执行成功的 ACK;
      • TX Manager 批量调用订单、账户、库存 Component 的 Confirm 接口,使得对应的变更记录实际生效;
      • 在三笔 Confirm 请求都响应成功后,TX Manager 将这笔事务日志置为 [成功] 状态。

在上述流程中,有一个很重要的环节需要补充说明:

首先,TCC 本质上是一个两阶段提交(Two Phase Commitment Protocol,2PC)的实现方案,分为 Try 和 Confirm/Cancel 的两个阶段:

  • Try 操作的容错率是比较高的,原因在于有人帮它兜底. Try 只是一个试探性的操作,不论成功或失败,后续可以通过第二轮的 Confirm 或 Cancel 操作对最终结果进行修正;
  • Confirm/Cancel 操作是没有容错的,倘若在第二阶段出现问题,可能会导致 Component 中的状态数据被长时间”冻结“或者数据状态不一致的问题。

针对于这个场景,TCC 架构中采用的解决方案是:在第二阶段中,TX Manager 轮询重试 + TCC Component 幂等去重。 通过这两套动作形成的组合拳,保证 Confirm/ Cancel 操作至少会被 TCC Component 执行一次。

首先,针对于 TX Manager 而言:

  • 需要启动一个定时轮询任务;
  • 对于事务日志表中,所有未被更新为 [成功/失败] 对应终态的事务,需要摘出进行检查;
  • 检查时查看其涉及的每个组件的 Try 接口的响应状态以及这笔事务的持续时长;
  • 倘若事务应该被置为 [失败](存在某个 TCC Component Try 接口请求失败),但状态却并未更新,说明之前批量执行 Cancel 操作时可能发生了错误. 此时需要补偿性地批量调用事务所涉及的所有 Component 的 Cancel 操作,待所有 Cancel 操作都成功后,将事务置为 [失败] 状态;
  • 倘若事务应该被置为 [成功](所有 TCC Component Try 接口均请求成功),但状态却并未更新,说明之前批量执行 Confirm 操作时可能发生了错误. 此时需要补偿性地批量调用事务所涉及的所有 Component 的 Confirm 操作,待所有 Confirm 操作都成功后,将事务置为 [成功] 状态;
  • 倘若事务仍处于 [进行中] 状态(TCC Component Try 接口请求未出现失败,但并非所有 Component Try 接口都请求成功),则检查事务的创建时间,倘若其耗时过长,同样需要按照事务失败的方式进行处理。

需要注意,在 TX Manager 轮询重试的流程中,针对下游 TCC Component 的 Confirm 和 Cancel 请求只能保证 At least once 的语义,换句话说,这部分请求是可能出现重复的。

因此,在下游 TCC Component 中,需要在接收到 Confirm/Cancel 请求时,执行幂等去重操作。 幂等去重操作需要有一个唯一键作为去重的标识,这个标识键就是 TX Manager 在开启事务时为其分配的全局唯一的 Transaction ID,它既要作为这项事务在事务日志表中的唯一键,同时在 TX Manager 每次向 TCC Component 发起请求时,都要携带上 Transaction ID。

TX Manager

通过上面这个实际案例的流程剖析后,相信大家已经对这套 TCC 架构有了一定的理解。接下来我们再从 TX Manager 和 TCC Component 职责领域划分的视角出发,进行一轮梳理。

首先针对于事务协调器 TX Manager,其核心要点包括:

  • 暴露出注册 TCC Component 的接口,进行 Component 的注册和管理;
  • 暴露出启动分布式事务的接口,作为和 Application 交互的唯一入口,并基于 Application 事务执行结果的反馈;
  • 为每个事务维护全局唯一的 Transaction ID,基于事务日志表记录每项分布式事务的进展明细;
  • 串联 Try——Confirm/Cancel 的两阶段流程,根据 Try 的结果,推进执行 Confirm 或 Cancel 流程;
  • 持续运行轮询检查任务,推进每个处于中间态的分布式事务流转到终态。

TCC Component

对于 TCC Component 而言,其需要关心和处理的工作包括:

  • 暴露出 Try、Confirm、Cancel 三个入口,对应于 TCC 的语义;
  • 针对数据记录,新增出一个对应于 Try 操作的中间状态枚举值;
  • 针对于同一笔事务的重复请求,需要执行幂等性校验;
  • 需要支持空回滚操作。即针对于一笔新的 Transaction ID,在没收到 Try 的前提下,若提前收到了 Cancel 操作,也需要将这个信息记录下来,但不需要对真实的状态数据发生变更。

下面针对最后一点提到的空回滚操作,进一步加以说明:

这个空回滚机制本质上是为了解决 TCC 流程中出现的悬挂问题,下面我们举个具体例子加以说明:

  • TX Manager 在向 Component A 发起 Try 请求时,由于出现网络拥堵,导致请求超时;
  • TX Manager 发现存在 Try 请求超时,将其判定为失败,因此批量执行 Component 的 Cancel 操作;
  • Component A 率先收到了后发先至的 Cancel 请求;
  • 过了一会儿,之前阻塞在网络链路中的 Try 请求也到达了 Component A。

从执行逻辑上,Try 应该先于 Cancel 到达和处理,然而在事实上,由于网络环境的不稳定性,请求到达的先后次序可能颠倒。在这个场景中,Component A 需要保证的是,针对于同一笔事务,只要接受过对应的 Cancel 请求,之后到来的 Try 请求需要被忽略。 这就是 TCC Component 需要支持空回滚操作的原因所在。

UML

classDiagram
    class TxManager {
        -TXStore txStore
        -TCCRegistryCenter registryCenter

        +Transaction(ctx, req) bool, error
        +Register(component) error
        +Stop()
        -twoPhaseCommit(ctx, txID, components) bool, error
        -batchAdvanceProgress(transactions)
        -run()
    }

    class TCCRegistryCenter {
        <<interface>>
        +Register(component) error
        +GetComponents(ids) TCCComponent[], error
    }

    class RegistryCenter {
        -RWMutex mux
        -map~string,TCCComponent~ components

        +Register(component) error
        +GetComponents(ids) TCCComponent[], error
    }

    class TXStore {
        <<interface>>
        +CreateTX(ctx, components) string, error
        +TXUpdate(ctx, txID, success) error
        +TXSubmit(ctx, txID, success) error
        +GetHangingTXs(ctx) Transaction[], error
        +GetTX(ctx, txID) Transaction, error
        +Lock(ctx, duration) error
        +Unlock(ctx) error
    }

    class TCCComponent {
        <<interface>>
        +ID() string
        +Try(ctx, req) response, error
        +Confirm(ctx, txID) response, error
        +Cancel(ctx, txID) response, error
    }

    TxManager --> TXStore : holds
    TxManager --> TCCRegistryCenter : holds

    RegistryCenter ..|> TCCRegistryCenter : implements
    RegistryCenter "1" --> "0..*" TCCComponent : registers

    TXStore ..> TCCComponent : uses

优劣势分析

优势:

  • TCC 可以称得上是真正意义上的分布式事务:任意一个 Component 的 Try 操作发生问题,都能支持事务的整体回滚操作;
  • TCC 流程中,分布式事务中数据的状态一致性能够趋近于 100%,这是因为第二阶段 Confirm/Cancel 的成功率是很高的,原因在于如下三个方面:
    • TX Manager 在此前刚和 Component 经历过一轮 Try 请求的交互并获得了成功的 ACK,因此短时间内,Component 出现网络问题或者自身节点状态问题的概率是比较小的;
    • TX Manager 已经通过 Try 操作,让 Component 提前锁定了对应的资源,因此确保了资源是充分的,且由于执行了状态锁定,出现并发问题的概率也会比较小;
    • TX Manager 中通过轮询重试机制,保证了在 Confirm 和 Cancel 操作执行失败时,也能够通过重试机制得到补偿。

劣势:

  • TCC 分布式事务中,涉及的状态数据变更只能趋近于最终一致性,无法做到即时一致性;
  • 事务的原子性只能做到趋近于 100%,而无法做到真正意义上的 100%,原因就在于第二阶段的 Confirm 和 Cancel 仍然存在极小概率发生失败,即便通过重试机制也无法挽救。这部分小概率事件,就需要通过人为介入进行兜底处理;
  • TCC 架构的实现成本是很高的,需要所有子模块改造成 TCC 组件的格式,且整个事务的处理流程是相对繁重且复杂的,因此在针对数据一致性要求不那么高的场景中,通常不会使用到这套架构。

事实上,上面提到的第二点劣势也并非是 TCC 方案的缺陷,而是所有分布式事务都存在的问题,由于网络请求以及第三方系统的不稳定性,分布式事务永远无法达到 100% 的原子性。

后面我应该会跟着小徐进行王者级手搓 TCC SDK!

评论区