分布式事务模式总结

136 阅读13分钟

分布式基本概念

CAP定理

  • Consistency(一致性): 用户访问分布式系统中的任意节点,得到的数据必须一致
  • Availability(可用性): 用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
  • Partition(分区): 因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。tolerance(容错): 在集群出现分区时,整个系统也要持续对外提供服务

BASE理论

  • Basically Available (基本可用): 分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State(软状态): 在一定时间内,允许出现中间状态,比如数据临时的不一致状态。(MQ
  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

XA模式

Seata的架构

Seata事务管理中有三个重要的角色:

  • TC (Transaction Coordinator) - 事务协调者: 维护全局和分支事务的状态,协调全局事务提交或回滚。
  • TM (Transaction Manager) - 事务管理器: 定义全局事务的范围、开始全局事务、提交或回滚全局事务。
  • RM (Resource Manager) - 资源管理器: 管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

image.png

XA模式

image.png

(二十七)舞动手指速写一个Seata-XA框架解决棘手的分布式事务问题

AT模式

AT模式是为了弥补XA模式占用数据库锁的问题。本质是两阶段提交。

image.png

阶段一RM的工作:

  1. 先会注册一个分支事务到事务协调者TC中
  2. 记录一个SQL更新前的快照和一个更新后的快照到undo_log日志表中
  3. 执行SQL并提交数据库事务
  4. 报告事务状态

阶段二RM的工作:

  1. 如果此时所有微服务都执行完,并且没有出现异常情况,事务协调者TC通知RM删除undo-log记录。
  2. 如果此时中途有微服务出现异常情况,则TC会通知RM根据undo-log记录的对应快照恢复数据到更新前。

实验

我们通过demo理解这个过程G:\Github\incubator-seata-samples\at-sample\dubbo-samples-seata。

镜像日志表:undo_log

我们在这里打个断点,发起请求:http://127.0.0.1:9999/test/commit?userId=ACC_001&commodityCode=STOCK_001&orderCount=1

image.png

数据库的undo_log表,在这个表中各自生成了3条日志记录,xid都一样,这说明他们确实是在同一个全局事务当中。

image.png

我们再把断点放开,可以看到日志表的两条记录都被删除了,这也印证了我们上面的原理图。

rollback_info分支撤销日志(BranchUndoLog)以格式化后的 JSON 结构展示

{
  "@class": "org.apache.seata.rm.datasource.undo.BranchUndoLog",
  "xid": "192.168.181.1:8091:72703312445587458",
  "branchId": 72703312445587459,
  "sqlUndoLogs": [
    {
      "@class": "org.apache.seata.rm.datasource.undo.SQLUndoLog",
      "sqlType": "UPDATE",
      "tableName": "stock_tbl",
      "beforeImage": {
        "@class": "org.apache.seata.rm.datasource.sql.struct.TableRecords",
        "tableName": "stock_tbl",
        "rows": [
          {
            "@class": "org.apache.seata.rm.datasource.sql.struct.Row",
            "fields": [
              {
                "@class": "org.apache.seata.rm.datasource.sql.struct.Field",
                "name": "count",
                "keyType": "NULL",
                "type": 4,
                "value": 99
              },
              {
                "@class": "org.apache.seata.rm.datasource.sql.struct.Field",
                "name": "id",
                "keyType": "PRIMARY_KEY",
                "type": 4,
                "value": 1
              }
            ]
          }
        ]
      },
      "afterImage": {
        "@class": "org.apache.seata.rm.datasource.sql.struct.TableRecords",
        "tableName": "stock_tbl",
        "rows": [
          {
            "@class": "org.apache.seata.rm.datasource.sql.struct.Row",
            "fields": [
              {
                "@class": "org.apache.seata.rm.datasource.sql.struct.Field",
                "name": "count",
                "keyType": "NULL",
                "type": 4,
                "value": 98
              },
              {
                "@class": "org.apache.seata.rm.datasource.sql.struct.Field",
                "name": "id",
                "keyType": "PRIMARY_KEY",
                "type": 4,
                "value": 1
              }
            ]
          }
        ]
      }
    }
  ]
}

这个结构表明:

  • 执行的是一条 UPDATE 操作;
  • 更新的是 stock_tbl 表;
  • 事务前(beforeImage)count = 99,更新后(afterImage)变为 count = 98
  • id = 1 是该行的主键。

通过这个实验,大致就能理解AT模式的基本思想。

官网示例说明

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

FieldTypeKey
idbigint(20)PRI
namevarchar(100)
sincevarchar(100)

AT 分支事务的业务逻辑:

update product set name = 'GTS' where name = 'TXC';

一阶段

过程:

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';

得到前镜像:

idnamesince
1TXC2014
  1. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。

  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。

select id, name, since from product where id = 1;

得到后镜像:

idnamesince
1GTS2014
  1. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}

6. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。

  1. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。

  2. 将本地事务提交的结果上报给 TC。

二阶段-回滚

  1. 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。

  2. 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。

  3. 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,默认会有重试机制。

  4. 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:

update product set name = 'TXC' where id = 1;

5. 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

二阶段-提交

  1. 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。

  2. 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

示例中存在并发请求,当线程A刚完成扣减余额1000-200=800,但库存还没扣减,这时候线程B来了,线程B读到的余额为800,它也进行了扣减800-200=600,而这时候线程A扣库存出现了异常,线程A回滚了,那这里线程B是不是就脏读了?

官网开发者指南里AT模式的读隔离描述:

先看这篇文章:AT模式脏读问题解决方案

理清下概念:

名词属于哪里本质
本地锁数据库行锁(Row Lock)
全局锁Seata逻辑锁,防止多个事务同时操作同一资源

Q:对于那假如一个事务中有两个业务呢(比如事务TX有2个业务逻辑)?这个全局锁是怎么分发的,是针对业务分发一把还是什么?(代验证)

image.png

A:Seata 全局锁的底层表现

全局锁的元数据一般记录在 Seata Server 的内存 + 数据库的 lock_table 中:

xidresource_idtable_namepk
192.168.1.1:8091:abc123ds1stock_tbl1
192.168.1.1:8091:abc123ds1order_tbl123

🔒 Seata 是基于“资源ID+表名+主键值”来加全局锁的,而不是对整个事务统一加一把锁。

image.png

在分布式事务中实现读已提交的代价是很高的,效率比起读未提交差别很大,所以 Seata 默认并没有开启,当只有你业务上确实需要数据强一致时才有开启的必要。

TCC

TCC 基于分布式事务中的二阶段提交协议实现,它的全称为 Try-Confirm-Cancel,即资源预留(Try)、确认操作(Confirm)、取消操作(Cancel),他们的具体含义如下:

  1. Try(prepare 行为): 对业务资源的检查并预留。

  2. Confirm(commit行为): 对业务处理进行提交,只要 Try 成功,那么该步骤一定成功。

  3. Cancel(rollback 行为): 对业务处理进行取消,即回滚操作,该步骤回对 Try 预留的资源进行释放。

TCC 是一种侵入式的分布式事务解决方案,以上三个操作都需要业务系统自行实现,对业务系统有着非常大的入侵性,设计相对复杂,对比AT,它的优点是 TCC 完全不依赖数据库,并且因为数据回滚问题都是在业务层面解决的,所以不需要使用全局锁,故执行速度更快。

image.png 在Seata中,AT模式与TCC模式事实上都是两阶段提交的具体实现,他们的区别在于:

AT 模式基于 支持本地 ACID 事务的关系型数据库:

  • 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录。
  • 二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志。
  • 二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚。

TCC 模式不依赖于底层数据资源的事务支持:

  • 一阶段 prepare 行为:调用自定义的 prepare 逻辑。
  • 二阶段 commit 行为:调用自定义的 commit 逻辑。
  • 二阶段 rollback 行为:调用自定义的 rollback 逻辑。

简单点概括,SEATA的TCC模式就是手工的AT模式,由我们自己实现prepare 、commit、rollback方法,不依赖AT模式的undo_log。

TCC 模式存在的问题

seata 的 TCC 模式存在一些异常场景会导致出现空回滚、幂等、悬挂等问题。seata帮我们解决了这些问题。方式就是添加 tcc_fence_log 事务控制表。

幂等性问题

TC执行confirm或cancel后,因为网络问题,没有收到RM返回的通知,TC会以为没有执行成功,这时候TC就会再次进行调用confirm或cancel,多次对数据做修改,导致幂等性问题。

image.png

同样的也是在 TCC 事务控制表中增加一个记录状态的字段 status,该字段有 3 个值,分别为:

  1. tried:1

  2. committed:2

  3. rollbacked:3

二阶段 Confirm/Cancel 方法执行后,将状态改为 committed 或 rollbacked 状态。当重复调用二阶段 Confirm/Cancel 方法时,判断事务状态即可解决幂等问题。

悬挂问题

悬挂简单点理解就是 cancel 比 try 先执行,造成 try 的资源无法回滚。

🎯 场景:A 转账给 B(使用 TCC)

  • A 扣钱(A 服务)
  • B 加钱(B 服务)

假设我们在给 B 加钱的阶段出现了悬挂。

时间操作描述
t1TC 发起调用 → 调用 B 服务的 Try 接口(加钱预处理)Try 很慢,没立即响应
t2由于超时,TC 认为 B 失败,立即调用 CancelCancel 执行完,回滚了加钱
t3此时 B 的 Try 继续执行成功了结果钱又被加回去了,但没人再来 Cancel 了
最终:钱多加了一次(数据不一致)就是典型的“悬挂”

Seata 处理悬挂问题:

在 TCC 事务控制表记录状态的字段 status 中增加一个状态:suspended:4

当执行二阶段 Cancel 方法时,如果发现 TCC 事务控制表有相关记录,说明二阶段 Cancel 方法优先一阶段 Try 方法执行,因此插入一条 status=4 状态的记录,当一阶段 Try 方法后面执行时,判断 status=4 ,则说明有二阶段 Cancel 已执行,并返回 false 以阻止一阶段 Try 方法执行成功。

空回滚问题

空回滚指的是在一个分布式事务中,在没有调用参与方的 Try 方法的情况下,TM 驱动二阶段回滚调用了参与方的 Cancel 方法。

解决方案:要想防止空回滚,那么必须在 Cancel 方法中识别这是一个空回滚,在二阶段执行回滚 rollback 的时候,需要先检查一阶段是否有执行过 try 方法,如果执行过才能执行回滚 rollback 方法,如果没有执行过就不任何操作,Seata 的做法是新增一个 TCC 事务控制表 tcc_fence_log,在 try 阶段执行成功后在 tcc_fence_log 表中插入一条记录,在 rollback 时去查询 tcc_fence_log 表是否有 try 阶段执行成功的记录,如果有,才会执行 rollback,如果不存在记录说明 Try 方法没有执行,则不再执行 rollback 方法。

🏦 业务背景:用户 A 转账给用户 B

我们看的是 B 服务(加钱方)的 TCC 处理过程,重点在防止 空回滚

💡 具体流程说明

✅ 1. Try 阶段(加钱业务预处理)

-- 假设 xid = 123456
BEGIN;

-- 1. 写入冻结金额逻辑(比如冻结 100 元)
UPDATE account SET freeze_amount = freeze_amount + 100 WHERE user_id = 'B';

-- 2. 插入 tcc_fence_log 表,表示 Try 成功执行过
INSERT INTO tcc_fence_log (xid, branch_id, action_name, status)
VALUES ('123456', '98765', 'addMoney', 'TRY');

COMMIT;

⚠️ 如果 Try 执行失败,就不会插入这条记录

❌ 2. Cancel 阶段(接收到 TC 发来的回滚请求)


function cancel(xid, userId, amount) {
    // 查询 tcc_fence_log 表
    FenceRecord record = select * from tcc_fence_log where xid = '123456' and action_name = 'addMoney';
    
    if (record == null) {
        // ➤ Try 根本没执行过 → 空回滚 → 什么都不做!
        return;
    }

    if (record.status == 'CANCEL') {
        return; // 幂等
    }

    // ➤ 回滚冻结金额
    update account set freeze_amount = freeze_amount - 100 where user_id = 'B';

    // 更新状态为 CANCEL
    update tcc_fence_log set status = 'CANCEL' where xid = '123456';
}

🧠 解释

场景tcc_fence_log 记录?Cancel 是否执行?
Try 成功✅ 执行 Cancel
Try 根本没执行(空回滚)❌ Cancel 拦截
Cancel 重复调用✅ 幂等

二阶段消息模式

DTM特有的这个模式

image.png

秒杀场景:当秒杀访问量很大时,多数系统都会选择在redis中扣减库存,扣减成功后再创建订单。这种场景下没有回滚,适合二阶段消息。二阶段消息还能够保证出现进程崩溃的情况下,扣减的库存量与创建的订单量是完全相等的,详情参见秒杀应用

SAGA模式

saga这种事务模式最早来自这篇论文:sagas

在这篇论文里,作者提出了将一个长事务,分拆成多个子事务,每个子事务有正向操作Ti,反向补偿操作Ci。

假如所有的子事务Ti依次成功完成,全局事务完成

假如子事务Ti失败,那么会调用Ci, Ci-1, Ci-2 ....进行补偿。

总结

Seata模式:常用的XA和AT模式

DTM框架

  • 二阶段消息模式: 适合不需要回滚的场景
  • saga模式: 适合需要回滚的场景
  • tcc事务模式: 适合一致性要求较高的场景
  • xa事务模式: 适合并发要求不高,没有数据库行锁争抢的场景

DTM官方文档【这篇中详细讲解了什么情况选用什么样子的分布式事务,选型】