分布式系统与分布式事务

分布式系统的难题来自三个事实:网络不可靠、节点会部分失败、没有全局时钟。 共识协议解决"多个副本对同一串操作达成一致",分布式事务解决"一个操作跨多个独立资源时的原子性", 全局时间戳解决"跨节点的事务如何排序"。本文按"概念 → 分类 → 一致性 → 共识 → 时间戳 → 事务 → Seata → 选型"的顺序展开。

适用范围:通用分布式原理;工程实现以 Raft/etcd、ZooKeeper、TiDB/PD、Spanner、Seata 为例。 带默认值的参数都是实现细节,可能随版本变化。文中代码未实测。

相关文章:时钟、复制、分片、服务治理、容灾的完整展开见 分布式系统全景; 分布式锁见 分布式锁。


目录


1. 什么是分布式系统

1.1 定义

分布式系统是由多台通过网络连接的计算机组成的系统。节点之间只靠消息传递协作,对外表现为一个整体。

1.2 为什么需要分布式

动机 说明
水平扩展 单机的 CPU、内存、磁盘有上限,加机器比换更大的机器便宜,也没有天花板
高可用 单机故障服务就停;多副本让单点故障不影响服务
就近访问 用户分布在各地,数据和服务部署在离用户近的地方,降低延迟

1.3 三个根本难点

难点 表现 后果
网络不可靠 消息会丢失、延迟、重复、乱序,还会出现网络分区 发出请求后收不到回复时,无法区分"对方没收到""对方处理了但回复丢了""对方还在处理"
部分失败 一部分节点宕机,其余节点正常 系统必须在部分节点不可用时继续工作,并且不能出错
没有全局时钟 各节点的时钟有偏差,NTP 只能把偏差控制在毫秒级 不能用本地时间给跨节点的事件排序

1.4 面试回答模板(30 秒版)

分布式系统是由多台通过网络连接的计算机组成的系统,节点之间只靠消息传递协作,对外表现为一个整体。 用分布式是为了水平扩展、高可用和就近访问。

它的难点来自三个事实:网络不可靠,节点会部分失败,没有全局时钟。围绕这些难点形成了几类核心技术: 复制与一致性模型;共识协议,包括崩溃容错的 Paxos、Raft 和拜占庭容错的 PBFT、HotStuff; 分布式事务,包括 2PC、TCC、Saga、Percolator;以及分片、全局时钟、容错等基础机制。

讲完这段,按面试官追问的方向展开后面的某一节。


2. 理论基础:FLP、CAP、PACELC

结论 内容 工程含义
FLP 不可能性(Fischer、Lynch、Paterson,1985) 异步网络中,只要可能有 1 个节点崩溃,就不存在一定能在有限时间内达成共识的确定性算法 Paxos、Raft 用"超时 + 随机化"绕开:安全性始终保证,活性只在网络基本稳定时保证
CAP(Brewer 2000 年提出,Gilbert & Lynch 2002 年证明) 发生网络分区(P)时,一致性(C,指线性一致)和可用性(A)只能二选一 ZooKeeper、etcd 选 CP;Cassandra、Dynamo 选 AP
PACELC(Abadi) 有分区(P)时在 A 和 C 之间取舍;没有分区(Else)时在延迟(L)和一致性(C)之间取舍 没有分区时同样要权衡:同步复制换一致性,代价是写延迟

CAP 里的 C 是线性一致,ACID 里的 C 是数据满足业务约束,两者不是同一个概念。


3. 分布式系统的分类

3.1 按用途分

类别 解决什么问题 代表
分布式计算 批处理、流处理、并行计算 MapReduce、Spark、Flink、MPI
分布式文件/对象存储 海量非结构化数据 GFS/HDFS、Ceph、S3
分布式 KV/NoSQL 高吞吐读写 Dynamo、Cassandra、Redis Cluster、HBase
分布式数据库(NewSQL) 在 SQL 和 ACID 的基础上水平扩展 Spanner、TiDB、CockroachDB、OceanBase
协调服务 选主、配置管理、分布式锁、服务发现 ZooKeeper(ZAB)、etcd(Raft)、Chubby(Paxos)
消息/日志系统 解耦、削峰、事件流 Kafka(KRaft)、RocketMQ、Pulsar
服务化架构 业务拆分、远程调用 微服务、gRPC、Dubbo、Service Mesh
区块链/BFT 系统 互不信任的参与方之间达成一致 Bitcoin(PoW)、以太坊(PoS)、Aptos(HotStuff 系)

3.2 按其他维度分

维度 类型 A 类型 B
架构 中心化/主从:有 master 或 leader(HDFS NameNode、Raft leader) 去中心化/对等:没有主节点,用 Gossip 和一致性哈希(Dynamo、Cassandra)
数据分布 复制:同一份数据存多个副本,换取可用性和读扩展 分片:数据切开分到不同节点,换取写扩展和容量。实际系统通常两者都用
一致性 强一致(CP):线性一致 弱一致/最终一致(AP):BASE
故障模型 CFT(崩溃容错):节点只会宕机,不会作恶,需要 n = 2f+1 BFT(拜占庭容错):节点可能撒谎或作恶,需要 n = 3f+1
网络模型 同步 / 部分同步 异步

4. 一致性模型

从强到弱:

模型 含义 例子
线性一致(Linearizability) 所有操作看起来按一个全局顺序执行,且这个顺序符合真实时间:一个操作完成后,之后开始的操作一定能看到它的结果 etcd、Spanner
顺序一致(Sequential) 存在一个全局顺序,每个进程内部的顺序得到保持,但不要求符合真实时间 ZooKeeper:写线性一致,读默认顺序一致(可能读到旧值,读前调用 sync 可读到最新)
因果一致(Causal) 只保证有因果关系的操作在所有节点上顺序一致 COPS
最终一致(Eventual) 不再有新写入后,各副本最终收敛到同一个值 DNS、Cassandra(默认配置)
会话级保证 读自己的写、单调读、单调写 很多系统在最终一致之上提供

5. 共识协议

5.1 要解决的问题:复制状态机

共识让多个节点对一串值(日志)达成一致。每个节点按相同顺序执行相同的日志,得到相同的状态,这就是复制状态机:

客户端写请求 ──> Leader ──复制日志──> 多数派节点持久化 ──> 提交 ──> 各节点按序应用到状态机
                 [1:set x=1][2:set y=2][3:del x] ...   (所有节点上这串日志相同)

只要多数派(⌊n/2⌋+1)存活,系统就能继续提交新日志。

5.2 Paxos

Lamport 1998 年发表《The Part-Time Parliament》,2001 年发表简化版《Paxos Made Simple》。

角色:Proposer(提议者)、Acceptor(接受者)、Learner(学习者)。

Basic Paxos 对单个值达成一致,分两个阶段:

sequenceDiagram
    participant P as Proposer
    participant A as Acceptor 多数派
    P->>A: 阶段一 Prepare(n)
    A-->>P: Promise: 不再接受编号小于 n 的提案,附带已接受过的编号最大的值
    Note over P: 有人返回已接受的值,就必须沿用其中编号最大的那个,否则可用自己的值
    P->>A: 阶段二 Accept(n, v)
    A-->>P: Accepted
    Note over P,A: 多数派接受后,v 被选定

Multi-Paxos:选出一个稳定的 leader,之后省掉 Prepare 阶段,每条日志只需一轮往返。

难点:论文只把单值共识讲清楚了,选主、日志空洞、成员变更、快照等工程问题需要实现者自己设计,所以 Paxos 难懂,也难写对。

5.3 Raft

Ongaro 和 Ousterhout 2014 年提出,设计目标是容易理解,把共识拆成三个子问题。

(1)Leader 选举

  • 时间被划分为任期(term),每个任期最多一个 leader,term 单调递增
  • Follower 在选举超时内收不到 leader 心跳,就把 term 加 1,变成 Candidate 并请求投票。超时时间随机化(论文建议 150~300ms),避免多个节点同时参选导致选票被瓜分
  • 拿到多数派选票才能当选
  • 投票限制:候选人的日志必须至少和投票者一样新(先比最后一条日志的 term,term 相同再比 index)。这保证新 leader 拥有所有已提交的日志

(2)日志复制

  • Leader 通过 AppendEntries 把日志发给 follower,日志条目被多数派持久化后即为已提交
  • 每次 AppendEntries 携带 prevLogIndex 和 prevLogTerm,follower 发现对不上就拒绝;leader 回退 index 重试,直到找到一致点,然后用自己的日志覆盖 follower 之后的日志

(3)安全性

  • Leader 只通过计数副本的方式提交本任期的日志;之前任期遗留的日志,随本任期的一条日志被提交而间接提交。这条规则针对论文 Figure 8 的场景:旧任期的日志即使已复制到多数派,也可能被后来的 leader 覆盖

其他机制:成员变更(joint consensus 或单步变更)、快照与日志压缩、线性一致读(ReadIndex、Lease Read)。

5.4 拜占庭容错(BFT)

节点可能发送错误或矛盾的消息时,需要 n ≥ 3f+1,法定人数为 2f+1:

  • 等待回复时,最多 f 个节点故障,所以只能等 n−f 个回复
  • 收到的 n−f 个回复中,最多 f 个来自拜占庭节点
  • 任意两个法定人数必须至少交于一个诚实节点:2(n−f) − n ≥ f+1,解得 n ≥ 3f+1

以 n = 21 为例:最多容忍 f = 6 个拜占庭节点,法定人数 2f+1 = 15。

协议 特点
PBFT(1999) 三阶段(pre-prepare、prepare、commit),消息复杂度 O(n²),换 leader(view change)复杂
Tendermint 轮换提议者,用于 Cosmos
HotStuff 及其变体(Jolteon、DiemBFT 等) 用门限签名或聚合签名把投票汇聚到 leader,消息复杂度 O(n),换 leader 简单;Aptos 使用这一系

5.5 对比

Paxos Raft ZAB PBFT / HotStuff
故障模型 CFT CFT CFT BFT
节点数 2f+1 2f+1 2f+1 3f+1
Leader Multi-Paxos 有,Basic Paxos 没有 强 leader 强 leader 有,会轮换
日志 允许空洞 必须连续 连续 —
消息复杂度 O(n) O(n) O(n) PBFT O(n²),HotStuff O(n)
代表 Chubby、Spanner etcd、TiKV、Consul、Kafka KRaft ZooKeeper Tendermint、Aptos

Raft 是在 Multi-Paxos 的基础上加了约束:强 leader、日志连续、leader 必须拥有所有已提交日志。这些约束让它更容易实现,共识的本质没有变。

5.6 Gossip 不是共识协议

Gossip 让每个节点周期性地随机挑几个节点交换信息,信息像流言一样在 O(log n) 轮内传遍集群。它只保证最终一致,不保证所有节点对同一个值达成一致。Cassandra、Redis Cluster、Consul 用它做成员管理和故障检测。


6. 全局时间戳:TiDB 的 TSO

快照隔离需要一个全局单调递增的时间戳,给每个事务分配读快照版本(start_ts)和提交版本(commit_ts)。 TiDB 采用 Percolator 的做法:由一个集中的授时服务 TSO(Timestamp Oracle)分配时间戳,这个服务由 PD(Placement Driver)的 leader 提供。

6.1 时间戳格式

 63                                  18 17               0
+--------------------------------------+------------------+
|      physical(46 bit,Unix 毫秒)    | logical(18 bit) |
+--------------------------------------+------------------+
ts = physical << 18 | logical
  • 每毫秒最多分配 2^18 = 262144 个时间戳
  • 46 位毫秒可以表示到大约 2230 年
  • 在 TiDB 中解析:SELECT TIDB_PARSE_TSO(@@tidb_current_ts);

6.2 谁来分配

PD 内嵌 etcd,用 etcd 的选举(Raft + 租约)选出 PD leader。只有 PD leader 分配 TSO,follower 收到请求会转发或拒绝。

6.3 内存分配 + 持久化窗口

PD leader 在内存中维护 (physical, logical),在 etcd 中保存一个窗口上界 saved_limit。 以下逻辑和默认值来自 PD 的实现,可能随版本变化:

分配 count 个时间戳:
    logical += count
    if logical >= 2^18:              // 这一毫秒的逻辑位用完
        等待物理时间推进后重试
    return physical<<18 | logical

后台每 tso-update-physical-interval(默认 50ms)执行一次:
    now = 本机时钟
    if now - physical > 1ms:
        physical = now; logical = 0          // 正常推进
    else if logical > 2^18 / 2:
        physical += 1ms; logical = 0         // 逻辑位用掉一半,主动推进物理时间
    // 时钟回拨(now < physical)时 physical 保持不变,只增加 logical

    if saved_limit - physical <= 1ms:         // 接近窗口上界
        saved_limit = physical + tso-save-interval(默认 3s)
        写入 etcd                             // 写成功后才能分配新窗口内的时间戳

规则:分配出去的时间戳永远小于 etcd 中已持久化的 saved_limit。分配本身是纯内存操作,etcd 大约每 3 秒才写一次。

6.4 Leader 切换时如何保证单调

新 leader 上任:
    last = 从 etcd 读出 saved_limit
    physical = max(now, last + 1ms)      // 大于旧 leader 可能分配过的任何时间戳
    saved_limit = physical + 3s,写入 etcd
    之后才开始对外提供服务
  • 旧 leader 分配过的时间戳都小于 last,新 leader 从 last 之后开始分配,即使新 leader 的时钟比旧 leader 慢,时间戳也不会回退
  • 代价一:时间戳的物理部分可能比真实时间快几秒,不影响正确性
  • 代价二:leader 故障时要等租约过期再选出新 leader,这段时间(秒级)整个集群拿不到 TSO,所有事务都会卡住
  • 旧 leader 失去租约后必须立即停止分配,否则会出现两个 leader 同时分配

6.5 客户端优化

优化 做法 效果
批量请求 PD client 把一段时间内的并发请求合并为一个 gRPC stream 请求,携带 count = N;PD 返回最大时间戳 ts,客户端自行拆成 ts-N+1 … ts 一次 RPC 满足 N 个事务;单个 PD leader 可达每秒百万级分配(取决于硬件和版本)
异步获取 事务开始时调用 GetTSAsync() 拿到 future,同时解析和优化 SQL,用到 start_ts 时再 Wait() 隐藏一次 PD 往返
批量等待 tidb_tso_client_batch_max_wait_time(默认 0,最大 10ms),主动等一会儿攒更大的批 用一点延迟换 PD 更低的 CPU 占用
Follower 代理 tidb_enable_tso_follower_proxy:TiDB 把请求发给 PD follower,由 follower 合并后转给 leader TiDB 实例很多时减轻 PD leader 的连接压力

6.6 TSO 在事务中的用途

用途 说明
start_ts 事务开始时获取,作为 MVCC 快照读的版本号
commit_ts 2PC 中 prewrite 全部成功后获取,写入 primary key 的提交记录
Async Commit / 1PC commit_ts 不再从 PD 获取,而是取各 key 在 TiKV 上计算出的 min_commit_ts 的最大值。为保证线性一致,TiDB 在 prewrite 前仍获取一次 TSO 作为 min_commit_ts 的下限(tidb_guarantee_linearizability,默认开启)
Stale Read 用指定的时间戳读历史版本
GC safe point 早于 safe point 的旧版本可以回收

普通的乐观或悲观事务至少访问 PD 两次(start_ts 和 commit_ts)。跨地域部署时,到 PD 的往返是主要延迟来源。

6.7 和其他全局时钟方案对比

方案 系统 原理 优点 缺点
集中式 TSO TiDB、Percolator 单点分配严格递增的整数 实现简单,不依赖时钟精度,天然全序 单点瓶颈;跨地域延迟高;leader 切换期间不可用
TrueTime Spanner GPS + 原子钟给出误差区间 [earliest, latest],提交后等待误差过去(commit wait)再对外可见 没有中心节点,可全球部署 依赖专用硬件
HLC 混合逻辑时钟 CockroachDB、YugabyteDB 物理时钟 + 逻辑计数,随消息传播 去中心化,不需要专用硬件 依赖时钟最大偏移上限;存在不确定窗口,读可能需要重试

7. 什么时候需要分布式事务

7.1 判断标准

一个业务操作要修改多个独立的资源(不同数据库、不同服务、数据库加消息队列等),并且这些修改必须同时成功或同时失败。

7.2 典型场景

场景 例子 原因
微服务拆分后跨服务调用 下单同时修改订单库、库存库、账户库 每个服务有独立数据库,本地事务管不到别的库
分库分表后跨分片 用户 A 转账给用户 B,两人按 user_id 分到不同分片 同一个服务,数据在不同物理库
数据库 + 消息队列 订单写入数据库后发消息通知积分服务 可能数据库提交了消息没发出去,或消息发出去了数据库回滚了
数据库 + 缓存/搜索引擎 修改商品价格,同时更新 Redis 和 Elasticsearch 多个存储需要一致,通常接受最终一致
跨公司、跨系统 跨行转账;电商调用第三方支付和物流 外部系统的数据库不受你控制
分布式数据库内部 TiDB 一个事务修改了位于不同 Region 的多行 数据库内部用 Percolator 解决,对应用透明

7.3 能不用就不用

分布式事务会降低性能和可用性,增加实现复杂度。规避手段:

  1. 按业务选分片键,让一个事务涉及的数据落在同一分片(例如订单和订单明细都按 user_id 分片)
  2. 调整服务边界:两个服务总要一起修改,说明拆分粒度可能有问题,考虑合并
  3. 接受最终一致:多数业务不需要强一致,用本地消息表或事务消息 + 消费端幂等 + 定时对账即可
  4. 使用分布式数据库(TiDB、OceanBase、Spanner),把跨分片事务交给数据库

8. 分布式事务方案

8.1 总览

路线 方案 一致性
刚性事务(ACID) 2PC/XA、3PC、2PC + 共识(Spanner)、Percolator(TiDB) 强一致
柔性事务(BASE) TCC、Saga、本地消息表、事务消息、最大努力通知 最终一致

8.2 两阶段提交(2PC)

sequenceDiagram
    participant C as 协调者
    participant P1 as 参与者 1
    participant P2 as 参与者 2
    C->>P1: Prepare
    C->>P2: Prepare
    P1-->>C: Yes(已写 redo/undo,持有锁)
    P2-->>C: Yes
    Note over C: 全部 Yes 才提交,任一 No 或超时则回滚
    C->>P1: Commit
    C->>P2: Commit
    P1-->>C: Ack
    P2-->>C: Ack
问题 说明
同步阻塞 参与者从 Prepare 到收到最终决定,一直持有锁
协调者单点 协调者在参与者回复 Yes 之后宕机,参与者不知道该提交还是回滚,只能持锁等待协调者恢复
数据不一致 第二阶段部分 Commit 消息丢失,一部分参与者提交、一部分还在等待

XA 是 X/Open 组织定义的 2PC 接口规范,MySQL、Oracle、PostgreSQL 都支持。

8.3 三阶段提交(3PC)

在 Prepare 和 Commit 之间增加 PreCommit 阶段,并给参与者加超时:参与者进入 PreCommit 后等不到协调者,超时后自动提交。这减少了阻塞,但网络分区时仍可能不一致(分区两侧做出不同决定),实际系统中很少使用。

8.4 2PC + 共识:Spanner

2PC 的根本问题是协调者和参与者都是单点。Spanner 让每个参与者本身都是一个 Paxos 组,协调者也是其中一个 Paxos 组的 leader,Prepare/Commit 的决定写进 Paxos 日志。任何单个节点宕机,组内其他副本都能接着完成 2PC,不会阻塞。代价是每次写都要经过共识,延迟较高。

8.5 Percolator:TiDB

Percolator 基于 MVCC 和 TSO,没有独立的协调者。每个 key 有三列:data(数据)、lock(锁)、write(提交记录)。

1. 获取 start_ts
2. Prewrite:从要写的 key 里选一个作为 primary,其余为 secondary
     对每个 key 检查:start_ts 之后是否有别的事务提交过(写冲突)、是否已被别的事务加锁
     通过检查后写 data(版本 start_ts)和 lock(secondary 的 lock 指向 primary)
3. 获取 commit_ts
4. 提交 primary:写 write 记录(commit_ts → start_ts),删除 primary 的 lock
     ← 这一步成功,整个事务就提交了(原子提交点)
5. 异步提交 secondary:写 write 记录,删除 lock

读请求遇到 secondary 上残留的锁时,根据锁中记录的 primary 查看 primary 的状态:primary 已提交就帮它提交这个 secondary,primary 的锁已过期就回滚。整个事务的状态只由 primary key 决定,所以没有协调者单点。

8.6 TCC

每个服务提供 Try(预留资源)、Confirm(确认)、Cancel(释放) 三个接口。

示例:下单买 1 件商品,花 100 元。库存服务、账户服务、订单服务各自有独立数据库;库存表和账户表各增加一列 frozen。

服务 Try Confirm Cancel
库存 stock = stock - 1, frozen = frozen + 1(条件 stock >= 1) frozen = frozen - 1 stock = stock + 1, frozen = frozen - 1
账户 balance = balance - 100, frozen = frozen + 100(条件 balance >= 100) frozen = frozen - 100 balance = balance + 100, frozen = frozen - 100
订单 插入订单,状态 PENDING 状态改为 PAID 状态改为 CANCELLED
sequenceDiagram
    participant TM as 事务管理器
    participant S as 库存
    participant A as 账户
    participant O as 订单
    TM->>S: Try 冻结 1 件
    TM->>A: Try 冻结 100 元
    TM->>O: Try 创建 PENDING 订单
    alt 全部 Try 成功
        TM->>S: Confirm
        TM->>A: Confirm
        TM->>O: Confirm
    else 任一 Try 失败,例如余额不足
        TM->>S: Cancel
        TM->>A: Cancel
        TM->>O: Cancel
    end

Try 完成后,冻结的库存和余额别的事务用不了,隔离在业务层实现;整个过程不长时间持有数据库锁,适合高并发的资金和库存链路。

必须处理的三个问题:

问题 场景 解决
幂等 Confirm/Cancel 因网络超时被重试 事务控制表按 (xid, branch_id) 记录分支状态,已执行过就直接返回成功
空回滚 Try 请求丢失没有执行,事务管理器超时后调用 Cancel Cancel 发现没有 Try 记录,就插入一条"已取消"记录并直接返回成功
悬挂 Cancel 先执行完,迟到的 Try 才到达,冻结的资源再也没人释放 Try 执行前检查控制表,已有"已取消"记录就拒绝执行

8.7 Saga

把长事务拆成一串本地事务,每一步立即提交;某一步失败时,按相反顺序执行之前各步的补偿操作。没有预留阶段。

示例:下单 → 支付 → 扣库存 → 发货,其中支付调用第三方:

步骤 正向操作(本地事务,立即提交) 补偿操作
T1 订单服务:创建订单,状态 CREATED C1:订单状态改为 CANCELLED
T2 支付服务:调用第三方支付扣款 100 元 C2:调用第三方退款接口
T3 库存服务:扣 1 件库存 C3:加回 1 件库存
T4 物流服务:创建发货单 最后一步,失败就重试,不补偿
flowchart LR
    T1[T1 创建订单] --> T2[T2 第三方扣款] --> T3{T3 扣库存}
    T3 -- 成功 --> T4[T4 创建发货单]
    T3 -- 库存不足 --> C2[C2 退款] --> C1[C1 取消订单]

两种组织方式:

方式 做法 适用
编排(Orchestration) 中心协调器(状态机)按顺序调用各服务,失败时调用补偿 步骤多、流程复杂;Seata Saga、Temporal 采用这种方式
协同(Choreography) 各服务监听上一步的事件(消息队列),完成后发出下一个事件 步骤少、希望服务之间松耦合

适用原因:第三方支付不会配合实现 Try 接口,只能先真正扣款、失败后退款;流程可能持续几分钟到几天,不能一直持锁或冻结资源。

缺点与应对:

  • 没有隔离性:T2 已扣款、T3 还没执行时,用户能看到"已扣款",之后又被退款。用语义锁缓解:订单处于 CREATED、PAYING 等中间状态时,前端显示"处理中",其他操作不能修改它
  • 补偿操作必须幂等,并且最终一定成功(失败就重试,重试耗尽转人工)

8.8 本地消息表与事务消息

解决"写数据库 + 发消息"的一致性,只保证最终一致。

方案 做法
本地消息表(Transactional Outbox) 业务数据和一条"待发送消息"在同一个本地事务中写入;后台任务扫描消息表投递到 MQ,投递成功后标记已发送;消费端按消息 ID 做幂等
事务消息(RocketMQ) 先发"半消息"(消费者不可见)→ 执行本地事务 → 根据结果提交或回滚半消息;Broker 迟迟收不到结果时,回查生产者的本地事务状态

9. Seata

9.1 来历

Seata 由阿里开源,蚂蚁金服参与共建,现已捐给 Apache 软件基金会。

时间 事件
2014 阿里中间件团队内部推出 TXC(Taobao Transaction Constructor),服务淘宝内部业务
2016 TXC 改名 GTS(Global Transaction Service),在阿里云上作为商业服务提供
2019.01 开源,名为 Fescar(Fast & Easy Commit And Rollback),只有 AT 模式
2019 年 蚂蚁金服加入并贡献 TCC 模式,项目改名 Seata(Simple Extensible Autonomous Transaction Architecture);之后加入 Saga 模式
2020 1.2.0 版本加入 XA 模式
2023 年底 进入 Apache 孵化器,改名 Apache Seata (incubating)

AT 是 Seata 原创、最有代表性的模式,但只是它支持的四种模式之一。

9.2 架构

四种模式共用同一套角色:

角色 部署位置 职责
TC(Transaction Coordinator) 独立部署的 seata-server 维护全局事务和分支事务的状态,驱动全局提交或回滚
TM(Transaction Manager) 发起方服务(标注 @GlobalTransactional 的方法所在服务) 开启全局事务,最终决定提交或回滚
RM(Resource Manager) 每个参与的服务 注册分支、上报分支状态,执行分支的提交或回滚

四种模式的区别在于 RM 如何实现分支的提交和回滚。

9.3 AT 模式

AT 模式代理数据源,自动生成回滚日志,业务代码几乎不用改。

示例:订单服务创建订单,调用库存服务扣库存,两个服务各自有独立数据库。

// 订单服务
@GlobalTransactional
public void placeOrder(Long productId, Long userId) {
    orderMapper.insert(new Order(productId, userId, 100));
    stockClient.deduct(productId, 1);      // RPC 调用库存服务
}

// 库存服务:普通的本地事务,不需要 TCC 接口
public void deduct(Long productId, int count) {
    stockMapper.update("UPDATE stock SET count = count - ? WHERE id = ?", count, productId);
}

(未实测)任何一步抛出异常,两个库的修改都会被回滚。

第一阶段(以 UPDATE stock SET count = count - 1 WHERE id = 7 为例):

sequenceDiagram
    participant RM as 库存服务 RM
    participant DB as 库存库
    participant TC as TC
    RM->>DB: SELECT ... WHERE id = 7 FOR UPDATE,得到前镜像 count=10
    RM->>DB: 执行业务 UPDATE
    RM->>DB: 查询得到后镜像 count=9
    RM->>DB: 同一本地事务内插入 undo_log(前后镜像)
    RM->>TC: 注册分支,申请全局锁 stock:7
    TC-->>RM: 获得全局锁
    RM->>DB: 本地提交,数据库行锁释放

第二阶段:

全局结果 分支动作
提交 异步删除 undo_log,释放全局锁
回滚 比较当前数据和后镜像:一致,说明没有被修改过,用前镜像生成反向 SQL 恢复 count = 10;不一致,说明被全局事务之外的操作修改过(脏写),无法自动回滚,告警并人工处理

隔离性:

  • 写隔离:靠 TC 上的全局锁。另一个全局事务要修改 stock:7,必须等全局锁释放
  • 读隔离:默认读未提交,其他事务能读到第一阶段已提交、但之后可能回滚的 count = 9。需要读已提交时,使用 SELECT ... FOR UPDATE,Seata 会让它等待全局锁;不在全局事务中的本地操作可加 @GlobalLock 检查全局锁

9.4 TCC 模式

@LocalTCC
public interface StockTccAction {
    @TwoPhaseBusinessAction(name = "deductStock",
                            commitMethod = "confirm",
                            rollbackMethod = "cancel",
                            useTCCFence = true)
    boolean tryDeduct(@BusinessActionContextParameter(paramName = "productId") Long productId,
                      @BusinessActionContextParameter(paramName = "count") int count);

    boolean confirm(BusinessActionContext ctx);

    boolean cancel(BusinessActionContext ctx);
}

(未实测)Seata 1.5.0 起提供 useTCCFence = true,用一张 tcc_fence_log 表自动处理 8.6 节的幂等、空回滚、悬挂三个问题,业务方不用自己建事务控制表。

9.5 Saga 模式

Seata 实现的是编排式 Saga:用 JSON 定义状态机,写明每一步调用哪个服务方法、失败时用哪个方法补偿,由 Seata 的状态机引擎执行。官方提供可视化设计器绘制状态机。

9.6 XA 模式

业务代码与 AT 相同,把数据源代理从 DataSourceProxy 换成 DataSourceProxyXA。第一阶段执行 XA END + XA PREPARE,第二阶段由 TC 通知各分支执行 XA COMMIT 或 XA ROLLBACK。要求数据库支持 XA。

9.7 AT 与 XA 的区别

两者对业务都没有侵入,区别在于第一阶段之后数据库锁是否仍被持有:

AT:  [执行 SQL + 写 undo_log] → 本地提交,数据库锁释放 ── 全局锁(在 TC 上)持有到第二阶段 ── 删除 undo_log 或按镜像回滚
XA:  [执行 SQL] → XA PREPARE,数据库锁一直持有 ──────────────────────────────── XA COMMIT 或 XA ROLLBACK
  • AT 把锁从数据库移到 TC。全局事务之外的普通 SQL 不检查全局锁,可能产生脏写,导致回滚失败
  • XA 的锁在数据库里,任何操作都绕不过去,所以是强一致;代价是锁横跨两个阶段,并发度低

同一个全局事务中,AT 分支和 TCC 分支可以混用,例如订单服务用 AT、账户服务用 TCC。

9.8 四种模式对比

AT TCC Saga XA
分支回滚方式 自动:按 undo_log 前镜像恢复 手动:调用业务的 Cancel 手动:调用业务的补偿方法 数据库原生 XA ROLLBACK
第一阶段是否提交本地事务 提交,数据库锁立即释放 提交(Try 只预留资源) 提交 不提交,只 XA PREPARE,锁持有到第二阶段
业务侵入 低:加注解 高:每个接口写三个方法 中:状态机定义 + 补偿方法 低:加注解、换数据源
隔离性 全局锁做写隔离,读默认读未提交 业务层隔离 无 数据库原生隔离,强一致
性能 较高 最高 高 最低
资源要求 Seata 能解析其 SQL 的关系型数据库 无限制 无限制,可接外部系统 支持 XA 的数据库
适用场景 内部微服务的一般业务 资金、库存等高并发核心链路 长流程、涉及外部系统 强一致、低并发,或接入已有 XA 系统

10. Seata 以外的实现

类别 框架 说明
XA/JTA 事务管理器 Atomikos、Narayana(Red Hat) Java 标准 JTA 的实现,传统 2PC
分库分表中间件 Apache ShardingSphere 自身不实现事务协议:XA 模式集成 Atomikos/Narayana,柔性事务集成 Seata
多语言事务框架 DTM(Go 编写) 支持 Saga、TCC、XA、二阶段消息、Workflow,通过 HTTP/gRPC 支持多种语言
TCC 专用框架 Hmily、ByteTCC 国内开源,活跃度不如 Seata
事务消息 RocketMQ 事务消息 半消息 + 回查,实现"本地事务 + 发消息"的最终一致
Saga/工作流编排 Temporal、Cadence、Eventuate Tram 用工作流引擎编排长流程
分布式数据库内置 TiDB(Percolator)、Spanner(2PC + Paxos)、OceanBase 数据库内部解决,应用层不需要框架
已停止维护 TX-LCN 不建议在新项目中使用

11. 选型

业务要求 选择
单数据库 本地事务,不引入分布式事务
需要跨分片强一致 原生支持分布式事务的数据库(TiDB、Spanner、CockroachDB、OceanBase)
强一致、事务短、低并发,改造成本要低 XA
内部微服务的一般业务,希望无侵入 Seata AT(注意热点行的全局锁竞争和读隔离)
资金、库存等高并发核心链路 TCC
长流程、涉及第三方或遗留系统 Saga
允许延迟几秒,只需最终一致 本地消息表或事务消息 + 消费端幂等 + 对账

12. 常见追问

12.1 共识

问题 要点
Raft 如何防止脑裂 term 单调递增,当选需要多数派投票;旧 leader 收到更高 term 的消息就退位;旧 leader 的写入拿不到多数派确认,无法提交
Raft 如何实现线性一致读 读请求也走一遍日志(Log Read);或 ReadIndex:记录当前 commitIndex,向多数派确认自己仍是 leader,等状态机应用到该位置后读;或 Lease Read:在租约内直接读,依赖时钟
为什么共识集群用奇数个节点 多数派是 ⌊n/2⌋+1。4 个节点和 3 个节点都只能容忍 1 个故障,第 4 个节点不提高容错能力,只增加写入开销
为什么 BFT 需要 3f+1 见 5.4 节:只能等 n−f 个回复,其中最多 f 个来自拜占庭节点,两个法定人数必须交于至少一个诚实节点

12.2 TSO

问题 要点
PD 是单点,会成为瓶颈吗 分配是纯内存操作,加上客户端批量,单个 PD leader 可达每秒百万级;真正的问题是跨地域延迟和 leader 切换期间不可用
时钟回拨怎么办 物理部分只增不减,回拨期间只增加逻辑位,逻辑位用完就等待;持久化窗口保证切换 leader 后也不回退
为什么只持久化窗口上界 每次分配都写 etcd(一次 Raft 提交)太慢。只持久化上界,代价是 leader 切换后跳过一段号段(最多约 3 秒的物理时间),不影响正确性
18 位逻辑位够吗 每毫秒 26 万个,每秒 2.6 亿个,远超实际需求;逻辑位用掉一半时会主动把物理时间推进 1ms
多数据中心如何优化 PD leader 放在业务主中心;用 Stale Read 读本地副本。TiDB 5.x 曾有实验特性 Local TSO(每个数据中心各自分配),后续版本已移除(具体版本未核实)

12.3 分布式事务

问题 要点
2PC 协调者宕机怎么办 已回复 Yes 的参与者持锁阻塞;解决办法是把协调者状态写日志并通过共识复制(Spanner),或改用以 primary key 状态为准的 Percolator
TCC 和 Saga 的区别 TCC 有预留阶段,Try 后资源被冻结,有业务层隔离;Saga 每步直接提交,没有隔离,靠补偿回滚。TCC 要求每个参与方都实现三个接口,Saga 只要求有反向操作,所以能接入第三方
Seata AT 是强一致吗 不是。第一阶段已提交本地事务,读默认是读未提交;全局事务之外的操作可能造成脏写,导致回滚失败
AT 和 XA 都无侵入,怎么选 AT 第一阶段释放数据库锁,性能好,但隔离弱;XA 锁持有到第二阶段,强一致,但并发低。一致性要求高、并发低选 XA,否则选 AT
怎么保证"写库 + 发消息"一致 本地消息表或 RocketMQ 事务消息;消费端按消息 ID 幂等;定时对账兜底