分布式事务
1. CAP 定理
- Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。
- Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
- Partition tolerance (分区容错性):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区,在集群出现分区时,整个系统也要持续对外提供服务
这其中的组合就有 CP、AP,但是没有 CA,因为一旦出现网络分区,如果你要保证可用性,那么就可能会导致出现数据不一致,两者互相矛盾。
- ZooKeeper 保证的是 CP。 任何时刻对 ZooKeeper 的读请求都能得到一致性的结果,但是, ZooKeeper 不保证每次请求的可用性比如在 Leader 选举过程中或者半数以上的机器不可用的时候服务就是不可用的。
- Eureka 保证的则是 AP。 Eureka 在设计的时候就是优先保证 A (可用性)。在 Eureka 中不存在什么 Leader 节点,每个节点都是一样的、平等的。因此 Eureka 不会像 ZooKeeper 那样出现选举过程中或者半数以上的机器不可用的时候服务就是不可用的情况。Eureka 保证即使大部分节点挂掉也不会影响正常提供服务,只要有一个节点是可用的就行了。只不过这个节点上的数据可能并不是最新的。
- Nacos 两者都支持
2. BASE 理论
BASE 理论是对 CAP 的一种解决思路,包含三个思想:
- Basically Available (基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
- **Soft State(软状态):**在一定时间内,允许出现中间状态,比如临时的不一致状态。
- Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
3. 分布式事务解决方案
3.1 2PC(两阶段提交协议)
先看看分布式事务中的 XA 协议的 3 种角色
- AP(Application):应用系统(服务)
- TM(Transaction Manager):事务管理器(全局事务管理)
- RM(Resource Manager):资源管理器(数据库)
2PC 将事务划分为了 2 个阶段
- 准备阶段(Prepare)
TM 向各个 RM 发送预提交请求,RM 收到请求后开始执行本地事务(修改数据等),将事务日志记录到本地,并锁定需要修改的数据以确保一致性,根据执行结果返回 yes、no 给 TM - 提交阶段(commit)
TM 根据 RM 返回的结果进行相应操作- 如果全是 yes,那么就进行正式的事务提交操作,并在完成提交之后释放整个事务执行期间占用的事务资源。
- 如果有 no,则 TM 通知每个 RM 进行回滚,并释放锁定的资源,最后中断事务
存在的问题:
- **单点问题:**事务管理器在整个流程中扮演的角色很关键,如果其宕机,比如在第一阶段已经完成,在第二阶段正准备提交的时候事务管理器宕机,资源管理器就会一直阻塞,导致数据库无法使用。
- **同步阻塞:**在准备就绪之后,资源管理器中的资源一直处于阻塞,直到提交完成,释放资源。
- **数据不一致:**两阶段提交协议虽然为分布式数据强一致性所设计,但仍然存在数据不一致性的可能,比如在第二阶段中,假设协调者发出了事务 commit 的通知,但是因为网络问题该通知仅被一部分参与者所收到并执行了 commit 操作,其余的参与者则因为没有收到通知一直处于阻塞状态,这时候就产生了数据的不一致性
3.2 3PC(三阶段提交协议)
3PC 对 2PC 的准备阶段进行了进一步细分
- 准备阶段(CanCommit)
协调者向参与者发送 commit 请求,参与者如果可以提交就返回 Yes 响应,否则返回 No 响应。 - 预提交阶段(PreCommit)
协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务,参与者执行完操作之后返回 ACK 响应,同时开始等待最终指令。 - 执行事务提交阶段(DoCommit)
协调者根据参与者在准备阶段的响应判断是否执行事务还是中断事务:- 如果所有参与者都返回正确的 ACK 响应,则提交事务
- 如果参与者有一个或多个参与者收到错误的 ACK 响应或者超时,则中断事务
- 如果参与者无法及时接收到来自协调者的提交或者中断事务请求时,在等待超时之后,会继续进行事务提交
可以看出,三阶段提交解决的只是两阶段提交中单体故障和同步阻塞的问题,因为加入了超时机制,这里的超时的机制作用于 预提交阶段 和 提交阶段。如果等待 预提交请求 超时,参与者直接回到准备阶段之前。如果等到提交请求超时,那参与者就会提交事务了。
无论是 2PC 还是 3PC 都不能保证分布式系统中的数据 100%一致。
3.3 TCC(补偿事务)
- Try(尝试阶段): 尝试执行。完成业务检查,并预留好必需的业务资源
- Confirm(确认阶段): 确认执行。当所有事务参与者的 Try 阶段执行成功就会执行 Confirm ,Confirm 阶段会处理 Try 阶段预留的业务资源。否则,就会执行 Cancel
- Cancel(取消阶段): 取消执行,释放 Try 阶段预留的业务资源。
转账场景:
- Try:检查余额是否充足,预留的资源就是转账资金
- Confirm:Try 执行成功,这个阶段就会执行真正的扣钱操作
- Cancel:释放 Try 阶段预留的资金
正常情况是需要程序员自行实现 3 个阶段的逻辑
假如 Cancel 或者 Confirm 阶段出异常
TCC 会记录事务日志并持久化事务日志到某种存储介质上比如本地文件、关系型数据库、Zookeeper,事务日志包含了事务的执行状态,通过事务执行状态可以判断出事务是提交成功了还是提交失败了,以及具体失败在哪一步。如果发现是 Confirm 或者 Cancel 阶段失败的话,会进行重试,继续尝试执行 Confirm 或者 Cancel 阶段的逻辑。重试的次数通常为 6 次,如果超过重试的次数还未成功执行的话,就需要人工介入处理了。
TCC 不同于 2PC/3PC,TCC 具有代码侵入性,将数据库层的压力转移到了应用层
3.4 本地消息表
假设场景为用户支付完成后要调用会计服务生成会计数据
- 在支付库中引入一张消息表来记录支付消息,即用户支付成功后同时往这张消息表插入一条支付成功的消息,状态为“发送中”。注意支付逻辑和插入消息表的代码要包裹在一个事务里面,这里保证了本地事务的强一致性。即支付逻辑和插入消息表的消息组成了一个强一致性的事务,要么同时成功,要么同时失败。
- 完成第一步的逻辑后,此时再向 mq 的 PAY_QUEUE 队列中投递一条支付消息,这条支付消息的内容跟保存在支付库消息表的消息内容一致。
- mq 接收到消息后,此时会计服务也监听到这条消息了,此时会计服务处理消费逻辑即开始生成会计凭证。
- 会计凭证生成后,再反向向 mq 投递一条消费成功的消息到 ACC_QUEUE 队列
- 同时支付服务又来监听这个会计服务消费成功的消息,当支付服务监听到这个消费成功的消息后,此时再将本地消息表的消息状态改为“已发送”。
- 因为可能存在消息丢失的情况,支付方需要开启定时任务来扫描消息表里面状态为发送中的消息再进行发送,并且设置最大重试次数,超过这个次数就交由人工处理
这样可能会存在重复消费的情况,所以需要做好幂等处理
3.5 MQ 消息
1、Producer 向 broker 发送半消息
2、Producer 端收到响应,消息发送成功,此时消息是半消息,标记为 “不可投递” 状态,Consumer 消费不了。
3、Producer 端执行本地事务。
4、正常情况本地事务执行完成,Producer 向 Broker 发送 Commit/Rollback,如果是 Commit,Broker 端将半消息标记为正常消息,Consumer 可以消费,如果是 Rollback,Broker 丢弃此消息。
5、异常情况,Broker 端迟迟等不到二次确认。在一定时间后,会查询所有的半消息,然后到 Producer 端查询半消息的执行情况。
6、Producer 端查询本地事务的状态
7、根据事务的状态提交 commit/rollback 到 broker 端。(5,6,7 是消息回查)
8、消费者段消费到消息之后,执行本地事务,执行本地事务。
3.6 saga
Saga 属于长事务解决方案,其核心思想是将长事务拆分为多个本地短事务(本地短事务序列)。
- 长事务 —> T1,T2 ~ Tn 个本地短事务
- 每个短事务都有一个补偿动作 —> C1,C2 ~ Cn
策略
- 反向恢复:
- 如果 Ti 短事务提交失败,则补偿所有已完成的事务(一直执行 Ci 对 Ti 进行补偿)。
- 执行顺序:T1,T2,…,Ti(失败),Ci(补偿),…,C2,C1。
- 正向恢复:
- 如果 Ti 短事务提交失败,则一直对 Ti 进行重试,直至成功为止。
- 执行顺序:T1,T2,…,Ti(失败),Ti(重试)…,Ti+1,…,
和 TCC 类似,Saga 正向操作与补偿操作都需要业务开发者自己实现,因此也属于侵入业务代码
的一种分布式解决方案。和 TCC 很大的一点不同是 Saga 没有“Try” 动作,它的本地事务 Ti 直接被提交。因此,性能非常高!
理论上来说,补偿操作一定能够执行成功。不过,当网络出现问题或者服务器宕机的话,补偿操作也会执行失败。这种情况下,往往需要我们进行人工干预。并且,为了能够提高容错性(比如 Saga 系统本身也可能会崩溃),保证所有的短事务都得以提交或补偿,我们还需要将这些操作通过日志记录下来(Saga log,类似于数据库的日志机制)。这样,Saga 系统恢复之后,我们就知道短事务执行到哪里了或者补偿操作执行到哪里了。
但是,由于 saga 没有 tcc 的 Try 操作,因此不能保证隔离性。
4. Seata
采用 db 方式存储信息
首先需要创建数据库,其中有 4 张表
- lock_table:
用途:存储全局事务中资源的锁信息。
描述:当一个分支事务需要对某些资源(如数据库中的行)进行操作时,它会在 lock_table 中记录这些资源的锁信息,以防止其他事务同时对这些资源进行操作,从而避免数据不一致。 - global_table:
用途:存储全局事务的信息。
描述:每个全局事务在开始时都会在 global_table 中记录一条记录,包括事务的状态、开始时间、超时时间等。当全局事务完成(提交或回滚)时,这条记录会被更新或删除。 - distributed_lock:
用途:存储分布式锁的信息。
描述:在某些场景下,Seata 需要使用分布式锁来协调多个节点之间的操作。distributed_lock 表用于记录这些分布式锁的信息,确保在分布式环境中对资源的访问是有序的。 - branch_table:
用途:存储分支事务的信息。
描述:每个全局事务可能包含多个分支事务。branch_table 中记录了这些分支事务的信息,包括分支事务的 ID、所属的全局事务 ID、状态、资源信息等。当分支事务完成(提交或回滚)时,这条记录会被更新或删除。
Seata 包含 3 种角色:
Transaction Coordinator (TC) :
事务协调器,维护全局事务的运行状态,负责协调并驱动全局事务的提交或回滚Transaction Manager(TM) :
控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议
- Resource Manager (RM) :
控制分支事务,负责分支注册、状态汇报,并接收事务协调器的指令,驱动分支(本地)事务的提交和回滚
4.1 事务模式
Seata 一共提供了 4 中事务模式
4.1.1 AT 模式
AT 模式,是 seata 的默认/独有模式,也是实际项目中比较常用的一种模式,他采用的也是两阶段提交,不过弥补了 XA 模式中资源锁定周期过长的缺点,相对于 XA 来说,性能更好一些,但缺点就是数据不是强一致,因为它的数据会真实的提交到数据库的,而如果后面做分支事务有问题的话,回滚靠的是日志来实现最终一致。
阶段一 RM 的工作:
- 先会注册一个分支事务到事务协调者 TC 中
- 记录一个 SQL 更新前的快照和一个更新后的快照到 undo_log 日志表中
- 执行 SQL 并提交数据库事务并释放锁资源
- 报告事务状态
阶段二 RM 的工作:
- 如果此时所有微服务都执行完,并且没有出现异常情况,事务协调者 TC 通知 RM 删除 undo-log 记录。
- 如果此时中途有微服务出现异常情况,则 TC 会通知 RM 根据 undo-log 记录的对应快照恢复数据到更新前
使用前需在当前数据库中建立一张 undo_log 表
1
2
3
4
5
6
7
8
9
10
11
12
CREATE TABLE IF NOT EXISTS `undo_log`
(
`branch_id` BIGINT NOT NULL COMMENT 'branch transaction id',
`xid` VARCHAR(128) NOT NULL COMMENT 'global transaction id',
`context` VARCHAR(128) NOT NULL COMMENT 'undo_log context,such as serialization',
`rollback_info` LONGBLOB NOT NULL COMMENT 'rollback info',
`log_status` INT(11) NOT NULL COMMENT '0:normal status,1:defense status',
`log_created` DATETIME(6) NOT NULL COMMENT 'create datetime',
`log_modified` DATETIME(6) NOT NULL COMMENT 'modify datetime',
UNIQUE KEY `ux_undo_log` (`xid`, `branch_id`)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8mb4 COMMENT ='AT transaction mode undo table';
ALTER TABLE `undo_log` ADD INDEX `ix_log_created` (`log_created`);脏写问题

通过写隔离解决
- 一阶段本地事务提交前,需要确保先拿到 全局锁 。
- 拿不到 全局锁 ,不能提交本地事务。
- 拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。

以一个示例来说明:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
总体流程:
以一个示例来说明整个 AT 分支的工作过程。
业务表:product
AT 分支事务的业务逻辑:
1
update product set name = 'GTS' where name = 'TXC';- 一阶段
- 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
- 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
1
select id, name, since from product where name = 'TXC';得到前镜像
3. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。 4. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
1
select id, name, since from product where id = 1;得到后镜像:
5. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
{
"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"
}- 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
- 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
- 将本地事务提交的结果上报给 TC。
- 二阶段
- 回滚:
- 收到 TC 的分支回滚请求,开启一个本地事务,执行如下操作。
- 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
- 数据校验:拿 UNDO LOG 中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
- 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句:
1
update product set name = 'TXC' where id = 1;提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。
- 提交:
- 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
- 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。
4.1.2 XA 模式
XA 规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。Seata XA 模式是利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种事务模式。
4.1.3 TCC 模式

TCC 模式的每个阶段是做什么的?
- Try:资源检查和预留
- Confirm:业务执行和提交
- Cancel:预留资源的释放
TCC 的优点是什么?
- 一阶段完成直接提交事务,释放数据库资源,性能好
- 相比 AT 模型,无需生成快照,无需使用全局锁,性能最强
- 不依赖数据库事务,而是依赖补偿操作,可以用于非事务型数据库
TCC 的缺点是什么?
- 有代码侵入,需要人为编写 try、Confirm 和 Cancel 接口,太麻烦
- 软状态,事务是最终一致
- 需要考虑 Confirm 和 Cancel 的失败情况,做好幂等处理
事务悬挂和空回滚
空回滚
当某分支事务的 try 阶段阻塞时,可能导致全局事务超时而触发二阶段的 cancel 操作。在未执行 try 操作时先执行了 cancel 操作,这时 cancel 不能做回滚,就是空回滚。
执行 cancel 操作时,应当判断 try 是否已经执行,如果尚未执行,则应该空回滚。事务悬挂
对于已经空回滚的业务,之前被阻塞的try操作恢复,继续执行try,就永远不可能confirm或cancel ,事务一直处于中间状态,这就是业务悬挂。
执行try操作时,应当判断cancel是否已经执行过了,如果已经执行,应当阻止空回滚后的try操作,避免悬挂
示例:
1
2
3
4
5
6
7
CREATE TABLE `account_freeze_tbl` (
`xid` varchar(128) NOT NULL,
`user_id` varchar(255) DEFAULT NULL COMMENT '用户id',
`freeze_money` int(11) unsigned DEFAULT '0' COMMENT '冻结金额',
`state` int(1) DEFAULT NULL COMMENT '事务状态,0:try,1:confirm,2:cancel',
PRIMARY KEY (`xid`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 ROW_FORMAT=COMPACT;- Try业务:
- 记录冻结金额和事务状态到account_freeze表
- 扣减account表可用金额
- Confirm业务
- 根据xid删除account_freeze表的冻结记录
- Cancel业务
- 修改account_freeze表,冻结金额为0,state为2
- 修改account表,恢复可用金额
- 如何判断是否空回滚?
- cancel业务中,根据xid查询account_freeze,如果为null则说明try还没做,需要空回滚
- 如何避免业务悬挂?
- try业务中,根据xid查询account_freeze ,如果已经存在则证明Cancel已经执行,拒绝执行try业务
4.1.4 SAGA模式
在 Saga 模式下,分布式事务内有多个参与者,每一个参与者都是一个冲正补偿服务,需要用户根据业务场景实现其正向操作和逆向回滚操作。
分布式事务执行过程中,依次执行各参与者的正向操作,如果所有正向操作均执行成功,那么分布式事务提交。如果任何一个正向操作执行失败,那么分布式事务会去退回去执行前面各参与者的逆向回滚操作,回滚已提交的参与者,使分布式事务回到初始状态。
Saga也分为两个阶段:
- 一阶段:直接提交本地事务
- 二阶段:成功则什么都不做;失败则通过编写补偿业务来回滚
优点:
- 事务参与者可以基于事件驱动实现异步调用,吞吐高
- 一阶段直接提交事务,无锁,性能好
- 不用编写TCC中的三个阶段,实现简单
缺点:
- 软状态持续时间不确定,时效性差
- 没有锁,没有事务隔离,会有脏写
4.2 总结



