Skip to content

分布式事务

本文介绍分布式事务的理论基础,包括XA、2PC、TCC、SAGA概念。

1. 什么是分布式事务

在银行转账中,假如我们要从A银行的账户中转账给B银行的另一个账户,如果没有分布式事务,那么就会出现:A银行账户钱扣了,但B银行账户并没有到账,钱平白无故减少了。显然这是一个非常严重的问题,所以分布式事务技术就出现了。

在分布式事务中,主要关注的是原子性,即涉及多个数据源的子事务,要么全部提交,要么全部回滚。

注意,在分布式事务中,没法要求子事务同时提交或同时回滚,这是由于网络延迟的物理限制,也是由于数据源节点可能宕机下线导致无法执行事务提交或回滚。

本地事务需要满足ACID四大特性:原子性、隔离性、一致性与持久性,但是在分布式事务中,ACID特性没法完全满足。因此,可以把分布式事务和本地事务视为不完全相同的概念,需要区分理解。

2. 分布式事务原子性实现方案

2.1 2PC与XA

2.1.1 2PC

2PC全称Two-Phase Commit Protocol,即两阶段提交协议,在2PC中,将分布式事务的相关者分为两类:

  • 协调者(Coordinator):协调者负责“做决定”。它自己通常不直接修改业务数据,而是管理整个全局事务。
  • 参与者(Participant):参与者是真正持有和修改事务资源的一方。例如数据库、消息系统等。

2PC将分布式事务分为两个阶段:

  • 投票阶段:在这一阶段,协调者会尝试让分布式事务的所有参与者做好准备,即协调者会给各个参与者发送需要执行的业务操作(例如SQL中的DML语句)。每个参与者对接收到的操作,进行加锁执行,但是不提交,并进行投票:

    • 投票 “Yes”:表示同意提交。当该参与者负责的本地事务部分可以正常执行完成时,会投 Yes。
    • 投票 “No”:表示中止事务。当参与者发现自己负责的本地事务部分存在问题时,会投 No。
  • 提交阶段:在这一阶段,协调者根据所有参与者的投票结果决定整个事务是提交还是中止:

    • 只有当所有参与者都投 “Yes” 时,协调者才决定提交整个事务;
    • 只要有任何一个参与者没有投 “Yes”,协调者就决定中止整个事务。

    随后,协调者会把最终决定通知所有参与者。各参与者收到决定后,会对自己管理的本地事务资源执行相应操作,即提交或中止。

2PC示意图如下:

Two_phase_commit_seq_diagram_success_01

2PC协议最大的缺点是阻塞。当参与者在投票阶段选择“YES”,就会保留该事务相关资源,例如加锁,因此会影响其他事务。

假设现在参与者在投票阶段回复了“YES”,然后协调者宕机了,那参与者一直无法得到最终的结果,参与者不知道是该COMMIT还是ROLLBACK,只能一直阻塞在prepared状态,在这个等待期间,参与者要继续保留与该事务相关的资源,例如锁,因此继续影响其他事务。如果协调者永久宕机了,那参与者就要一直等待下去。

更麻烦的是,参与者和协调者一起宕机了。假设现在分布式事务涉及3个参与者:P1、P2和P3,并且所有参与者在投票阶段都回复了“YES”,协调者收到了所有参与者的回复,因此决定提交事务,然后开始发送COMMIT,假设存在以下顺序:

txt
Coordinator → COMMIT → P1

P1 执行 COMMIT

然后:
P2、P3 崩溃
Coordinator 也崩溃

现在系统状态是:

P1:已经 COMMIT
P2:宕机
P3:宕机
Coordinator:宕机

此时,该事务并没有全部提交,可以视为未提交的事务,如果此时其他事务查询P1的状态,会查到未提交事务的中间状态,就会造成不一致。

就算之后P2、P3恢复了,协调者没有恢复,也无法得知该事务需要提交还是回滚,因此P2和P3仍然处于阻塞状态。

鉴于以上情况,要使用2PC,需要满足以下假设情况:

  • 每个节点都有稳定存储,并使用 WAL(Write-Ahead Log,预写日志)。节点崩溃不会导致 WAL 中的数据丢失或损坏。

    也就是说,协调者和参与者在改变事务状态之前,会先把关键状态持久化到日志中。例如参与者准备提交时,会先记录类似:

    PREPARED

    协调者决定提交时,也会先记录:

    GLOBAL COMMIT

    这样即使进程或机器崩溃,节点恢复之后也可以根据日志知道自己之前处于什么事务状态。

    这里的核心是:

    事务状态不能只存在内存里

    否则节点一崩溃,就不知道之前到底是 PREPAREDCOMMIT 还是 ABORT

  • 不允许某个节点永久宕机。

    这是非常重要的,因为 2PC 的阻塞问题本质上经常依赖“等某个节点恢复”。比如:

    P1 已经 PREPARED
    Coordinator 宕机

    P1 不知道应该提交还是回滚,因此只能等待协调者恢复。

    如果协调者最终会恢复:

    等待
    
    Coordinator 恢复
    
    Coordinator 读取日志
    
    重新发送 COMMIT / ABORT

    事务最终还有机会结束。但如果协调者永久消失,而系统又无法从其他地方确定事务最终决议,那么事务就可能永久阻塞。

2.1.2 XA

XA介绍

X/Open XA (XA 是 “eXtended Architecture”,即“扩展架构”的缩写)是一套面向分布式事务处理的标准规范,由 X/Open 在 1991 年提出。后来,X/Open 与 The Open Group 合并。

在XA的分布式事务处理模型中,有三个组件:

  • 应用程序(Application Program,AP):也就是业务代码。它负责定义事务的边界,也就是说AP决定这个分布式事务涉及哪些数据源、操作顺序等,但不负责分布式事务最终是提交还是回滚的决定。
  • 事务协调器(Transaction Manager,TM):全局大脑。它是XA协议的核心,负责创建全局事务ID,协调所有参与者,并发出最终的“提交”或“回滚”指令。
  • 资源管理器(Resource Manager,RM):具体执行者。通常指数据库(MySQL、Oracle等)或消息队列。它负责执行本地SQL操作,并管理自己的本地事务日志(Redo/Undo Log)。

在XA规范中,分布式事务流程一般如下:

  1. AP访问TM,开启分布式事务,获取分布式事务IDxid
  2. 将RM加入到分布式事务中,对应着XA规范中的TM调用RM的xa_start(xid),之后,在RM的该连接上执行的语句,都属于分布式事务的子事务;
  3. 当AP在每个RM上执行完业务逻辑后,调用xa_end(xid),标识着事务的业务逻辑完成,之后进入事务决策阶段;
  4. 之后就是进入2PC的投票阶段,TM向每个RM发出xa_prepare(xid)命令,询问事务是否能提交;
  5. 当TM收到每个RM的响应后,得到最终结果,向每个RM发出提交xa_commit(xid)或回滚xa_rollback(xid)指令;

可以看到,XA是一套分布式事务处理的规范,其核心采用了2PC协议。

因此,关于XA的优缺点,就是2PC的优缺点。优点是在正常情况下的强一致性,缺点是性能瓶颈严重(由于阻塞)、可用性风险高(协调者单点故障)。由于其缺点,在大多数应用中,基于XA的分布式事务解决方案并不受欢迎,只有在对一致性要求强且并发要求弱的应用中,XA方案才会得到考虑。

XA模型中,有TM和RM,目前有很多软件/组件实现了XA规范。

实现了RM的软件如下:

软件/组件XA 角色说明
MySQL/InnoDBRM支持 XA START/PREPARE/COMMIT/ROLLBACK
Oracle DatabaseRM支持 XA 分布式事务
PostgreSQLRM通过 prepared transaction 等机制参与两阶段事务,但与 MySQL 的 XA SQL 接口并不完全相同
IBM Db2RM支持 XA
SQL ServerRM支持分布式事务/XA 相关集成
ActiveMQRM可参与 JTA/XA 事务
IBM MQRM支持 XA/JTA 场景

在java生态中,实现了TM的组件如下:

软件/组件XA 角色典型场景
NarayanaTMSpring Boot、WildFly/JBoss
AtomikosTMSpring Boot 等 Java 应用
WebLogic JTATMOracle WebLogic
WebSphere Transaction ManagerTMIBM WebSphere
WildFly / JBossTM内置 Narayana

java语言规范中也提供了XA的相关抽象接口,例如:

txt
javax.sql.XADataSource:获取 XAConnection 的工厂
javax.sql.XAConnection:能够参与分布式事务的数据库连接
javax.transaction.xa.XAResource:可以被 Transaction Manager 控制的 XA 资源,其中定义了start()、end()、prepare()、commit()、rollback()等接口
javax.transaction.xa.Xid:Xid 表示的是 XA transaction identifier,而不是简单的一个“全局事务 ID 字符串”。
	Xid = Global Transaction ID + Branch Qualifier + Format ID
javax.transaction.xa.XAException:XA 操作失败时使用的异常类型。

MySQL驱动器就实现了以上接口,例如MysqlXADataSourceMysqlXAConnection(即是XAConnection的实现,也是XAResource的实现)、MysqlXid

由于不同的RM实现(例如MySQL、PostgreSQL)内部机制不同,TM实现(例如Atomikos)不需要耦合具体RM实现,只需要通过驱动器发送标准指令,例如xa_start(),然后特定驱动器发送RM实现指令,实现解耦。

MySQL中的XA

首先准备环境,在docker中启动两个MySQL实例:

bash
docker run -d --name mysql-db1 -p 3306:3306 -e MYSQL_ROOT_PASSWORD=your_password_1 mysql:8.4
docker run -d --name mysql-db2 -p 3307:3306 -e MYSQL_ROOT_PASSWORD=your_password_2 mysql:8.4

mysql-db1中初始化数据:

sql
CREATE TABLE IF NOT EXISTS account (
    id      VARCHAR(32)     PRIMARY KEY,
    name    VARCHAR(64)     NOT NULL,
    balance DECIMAL(18, 2)  NOT NULL DEFAULT 0
) ENGINE = InnoDB;

INSERT INTO account (id, name, balance) VALUES ('A001', '张三', 1000.00)
    ON DUPLICATE KEY UPDATE balance = 1000.00;

mysql-db2中初始化数据:

sql
CREATE TABLE IF NOT EXISTS account (
    id      VARCHAR(32)     PRIMARY KEY,
    name    VARCHAR(64)     NOT NULL,
    balance DECIMAL(18, 2)  NOT NULL DEFAULT 0
) ENGINE = InnoDB;

INSERT INTO account (id, name, balance) VALUES ('B001', '李四', 500.00)
    ON DUPLICATE KEY UPDATE balance = 500.00;

接下来准备实验分布式事务,假设张三向李四转账100元。

TIP

注意,以下操作在MySQL官方客户端中实验

bash
mysql -h127.0.0.1 -P3307 -uroot -p

首先在mysql-db1mysql-db2中开启分布式事务:

sql
XA START 'xa001','b001',1;  -- mysql-db1
XA START 'xa001','b002',1;  -- mysql-db2

其中'xa001','b001',1为xid,分为三部分:gtrid,bqual,formatID

  • gtrid:全局事务ID,为字符串;
  • bqual:分支事务标识符,为字符串,可省略,默认值为''
  • formatIDgtridbqual 是按照什么格式编码/解释的,为无符号整数,可省略,默认值为1;

之后就可以执行业务逻辑了:

sql
UPDATE account SET balance=balance-100 where id='A001';  -- mysql-db1
UPDATE account SET balance=balance+100 where id='B001';  -- mysql-db2

执行完业务逻辑后,执行XA END标识业务逻辑的结束:

sql
XA END 'xa001','b001',1;  -- mysql-db1
XA END 'xa001','b002',1;  -- mysql-db2

然后进入2PC流程,首先是投票阶段:

sql
XA PREPARE 'xa001','b001',1;  -- mysql-db1
XA PREPARE 'xa001','b002',1;  -- mysql-db2

TIP

注意,XA PREPARE这一命令并不会返回YESNO,需要根据之前的业务逻辑执行情况,来决定最终分布式事务是提交还是回滚。

如果最终选择提交,执行:

sql
XA COMMIT 'xa001','b001',1;  -- mysql-db1
XA COMMIT 'xa001','b002',1;  -- mysql-db2

如果最终选择回滚,执行:

sql
XA ROLLBACK 'xa001','b001',1;  -- mysql-db1
XA ROLLBACK 'xa001','b002',1;  -- mysql-db2

2.2 TCC

2.2.1 TCC介绍

TCC全称Try-Confirm-Cancel,是资源管理器的一种服务化的实现。

在2PC(XA)中,资源管理器(Resource Manager, RM)主要由外部组件实现,例如MySQL等,而TCC允许我们在服务中实现自己的资源管理器。

TCC分为三个业务方法:

  • Try:负责资源的检查和预留,与2PC中的第一阶段相对应;
  • Confirm:执行真正的业务,与2PC中的第二阶段提交事务对应;
  • Cancel:执行预留资源的取消,使资源回到初始状态,与2PC中的第二阶段回滚事务对应;

TCC只是为资源管理器的服务化实现,仍然需要事务管理器(Transaction Manager, TM)的协调与管理。

如下图所示,用户实现 TCC 服务之后,该 TCC 服务将作为分布式事务的其中一个资源,参与到整个分布式事务中;事务管理器分 2 阶段协调 TCC 服务,在第一阶段调用所有 TCC 服务的 Try 方法,在第二阶段执行所有 TCC 服务的 Confirm 或者 Cancel 方法;最终所有 TCC 服务要么全部都是提交的,要么全部都是回滚的。

tcc示意图

2.2.2 TCC问题说明

在实现TCC方法时,需要注意以下问题:

  1. 业务操作拆分为两阶段

    接入 TCC 前,业务操作只需要一步就能完成,但是在接入 TCC 之后,需要考虑如何将其分成 2 阶段完成,把资源的检查和预留放在一阶段的 Try 操作中进行,把真正的业务操作的执行放在二阶段的 Confirm 操作中进行。

    以转账场景为例,假设“账户A的余额中有 100 元,需要扣除其中 30 元”;

    在接入 TCC 之前,用户编写 SQL:“update 账户表 set 余额 = 余额 - 30 where 账户 = A”,便能一步完成扣款操作。

    在接入 TCC 之后,就需要考虑如何将扣款操作分成 2 步完成:

    • Try 操作:资源的检查和预留;

      在扣款场景,Try 操作要做的事情就是先检查 A 账户余额是否足够,再冻结要扣款的 30 元(预留资源);此阶段不会发生真正的扣款。

    • Confirm 操作:执行真正业务的提交;

      在扣款场景下,Confirm 阶段走的事情就是发生真正的扣款,把A账户中已经冻结的 30 元钱扣掉。

    • Cancel 操作:预留资源的是否释放;

      在扣款场景下,扣款取消,Cancel 操作执行的任务是释放 Try 操作冻结的 30 元钱,是 A 账户回到初始状态。

    image.png

  2. 并发控制

    在实现 TCC 时,应当考虑并发性问题,将锁的粒度降到最低,以最大限度的提高分布式事务的并发性。

    以下还是以A账户扣款为例,“账户 A 上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,出现并发”。

    在一阶段 Try 操作中,分布式事务 T1 和分布式事务 T2 分别冻结资金的那一部分资金,相互之间无干扰;这样在分布式事务的二阶段,无论 T1 是提交还是回滚,都不会对 T2 产生影响,这样 T1 和 T2 在同一笔业务数据上并行执行。

    image.png

  3. 空回滚问题

    事务协调器在调用 TCC 服务的一阶段 Try 操作时,可能会出现因为丢包而导致的网络超时,此时事务管理器会触发二阶段回滚,调用 TCC 服务的 Cancel 操作,而 Cancel 操作调用未出现超时。

    TCC 服务在未收到 Try 请求的情况下收到 Cancel 请求,这种场景被称为空回滚;空回滚在生产环境经常出现,用户在实现TCC服务时,应允许允许空回滚的执行,即收到空回滚时返回成功。

    image.png

  4. 悬挂问题

    事务协调器在调用 TCC 服务的一阶段 Try 操作时,可能会出现因网络拥堵而导致的超时,此时事务管理器会触发二阶段回滚,调用 TCC 服务的 Cancel 操作,Cancel 调用未超时;在此之后,拥堵在网络上的一阶段 Try 数据包被 TCC 服务收到,出现了二阶段 Cancel 请求比一阶段 Try 请求先执行的情况,此 TCC 服务在执行晚到的 Try 之后,将永远不会再收到二阶段的 Confirm 或者 Cancel ,造成 TCC 服务悬挂。

    用户在实现 TCC 服务时,要允许空回滚,但是要拒绝执行空回滚之后 Try 请求,要避免出现悬挂。

    image.png

  5. 幂等性控制

    无论是网络数据包重传,还是异常事务的补偿执行,都会导致 TCC 服务的 Try、Confirm 或者 Cancel 操作被重复执行;用户在实现 TCC 服务时,需要考虑幂等控制,即 Try、Confirm、Cancel 执行一次和执行多次的业务结果是一样的。

2.3 SAGA

2.3.1 SAGA介绍

SAGA 是一种长事务(Long-Lived Transaction)解决方案。它的核心思想是:把一个大的分布式事务拆成多个本地事务,每个本地事务一旦提交就立即生效;如果后续步骤失败,不是回滚数据库事务,而是执行对应的补偿事务。

例如一个“下单”流程:

T1:创建订单
T2:扣减库存
T3:扣款
T4:创建物流单

如果全部成功:

T1 → T2 → T3 → T4

如果执行到 T3 失败,那么需要执行T1和T2的补偿事务方法:

T1 → T2 → T3失败

C2:恢复库存
C1:取消订单

所以 SAGA 可以理解为将一个大事务拆分为按序执行的小事务Ti,并且定义每个小事务的补偿事务Ci

T1 → T2 → T3 → ... → Tn

依序执行成功所有的小事务Ti,则表示成功执行整个大事务。

如果执行Ti失败时,则逆序执行补偿事务,这里要求补偿方法Ci必须成功(可以通过重试、人工介入等方式实现)

Ci-1 → ... → C2 → C1

TIP

注意,并不是所有的事务方法Ti都需要补偿方法Ci,如果该事务方法是只读的,或者总是(必须)成功的事务方法,都不需要补偿方法。

2.3.2 SAGA与TCC的对比

SAGA与TCC的对比如下:

维度TCCSAGA
第一阶段Try,预留资源本地事务直接提交
第二阶段成功Confirm通常无需额外操作
第二阶段失败CancelCompensation 补偿
是否预留资源通常否
是否立即提交业务数据Try 通常只是冻结/预留
锁资源时间较短很短
一致性较强的业务一致性最终一致性
业务侵入很高较高

以扣除库存为例,TCC 实现如下:

txt
Try:
available = 90
frozen = 10

Confirm:
frozen = 0
真正确认扣除

Cancel:
available = 100
frozen = 0

而SAGA如下:

txt
T2:
库存 100 → 90
立即提交

之后,如果付款失败,执行补偿方法,需要加回库存
C2:
库存 90 → 100

因此 SAGA 存在一个非常重要的问题:

T2 成功到 C2 执行完成之间,其他事务可能已经看到库存是 90。

这意味着 SAGA 通常不提供传统 ACID 意义上的全局隔离性,主要追求的是最终一致性。

2.3.3 SAGA实现

SAGA实现有两种方式:协同式和编排式,二者的核心区别是:是否存在一个中心协调者来统一控制整个 Saga 流程。

  • 协同式SAGA:协同式 SAGA 没有中央协调器。每个服务完成自己的本地事务后,发布一个业务事件,其他服务监听这个事件,再决定是否执行自己的事务。
  • 编排式SAGA:编排式 SAGA 存在一个明确的 Saga Coordinator(Saga 协调器)。协调器知道整个业务流程,并主动告诉各个服务应该执行什么。

以下单流程为例:

txt
T1:创建订单
T2:扣减库存
T3:扣款
T4:创建物流单

协同式SAGA正常流程如下:

注意,上图的Message Queue表示整个消息队列服务的抽象,并不代表所有消息共用一个消息队列。

基于协同式的SAGA异常流程(假设扣款失败了,需要回滚扣减库存和创建订单):

基于编排式的SAGA正常流程如下:

从图上可以看出,有一个中央协调器Saga Coordinator,负责调度各个服务。

基于编排式SAGA的下单异常流程:

3. 总结

本文介绍了实现分布式事务的三种方式:XA/2PC、TCC和SAGA。三种实现方式对比如下:

方案核心方式一致性特点主要代价
XA / 2PC所有参与者准备完成后统一提交接近传统全局 ACID,强调原子提交阻塞、长时间持锁、可用性和性能较低
TCCTry 预留资源,Confirm/Cancel 确认或取消较强的业务一致性业务侵入大,需处理幂等、空回滚和悬挂
Saga各本地事务立即提交,失败后执行补偿允许中间状态,追求最终一致性通常缺少全局隔离,补偿逻辑复杂

分布式事务实现方式主要关注原子提交的实现,而隔离性、一致性需要根据实际业务场景,做出取舍。

参考资料

[1] 2PC:https://en.wikipedia.org/wiki/Two-phase_commit_protocol

[2] XA介绍:https://en.wikipedia.org/wiki/X/Open_XA

[3] XA规范:https://pubs.opengroup.org/onlinepubs/009680699/toc.pdf

[4] MySQL XA:https://dev.mysql.com/doc/refman/9.7/en/xa.html

[5] 3PC:https://en.wikipedia.org/wiki/Three-phase_commit_protocol

[6] TCC:https://seata.apache.org/zh-cn/blog/tcc-mode-design-principle

[7] SAGA:https://chatgpt.com/share/6a894b15-aedc-83ea-8fab-563a469fe4e4