分布式事务解决方案

一.Mysql四大特性

MySQL 事务具有四个特性:原子性、一致性、隔离性、持久性,这四个特性简称 ACID 特性

一、原子性(Atomicity ):一个事务是一个不可再分割的整体,要么全部成功,要么全部失败

事务在数据库中就是一个基本的工作单位,事务中包含的逻辑操作(SQL 语句),只有两种情况:成功和失败。事务的原子性其实指的就是这个逻辑操作过程具有原子性,不会出现有的逻辑操作成功,有的逻辑操作失败这种情况

二、一致性(Consistency ):一个事务可以让数据从一种一致状态切换到另一种一致性状态

举例说明:张三给李四转账 100 元,那么张三的余额应减少 100 元,李四的余额应增加 100 元,张三的余额减少和李四的余额增加这是两个逻辑操作具有一致性

三、隔离性(Isolution ):一个事务不受其他事务的影响,并且多个事务彼此隔离

一个事务内部的操作及使用的数据,对并发的其他事务是隔离的,并发执行的各个事务之间不会互相干扰

四、持久性(Durability ):一个事务一旦被提交,在数据库中的改变就是永久的,提交后就不能再回滚

一个事务被提交后,在数据库中的改变就是永久的,即使系统崩溃重新启动数据库数据也不会发生改变

二.事务隔离级别

MySQL 中事务的隔离级别一共分为四种,分别如下:

读未提交(READ UNCOMMITTED)
读已提交(READ COMMITTED)
可重复读(REPEATABLE READ) 默认
序列化(SERIALIZABLE)

四种不同的隔离级别含义分别如下:

读未提交(READ UNCOMMITTED)
READ UNCOMMITTED 提供了事务之间最小限度的隔离。除了容易产生虚幻的读操作和不能重复的读操作外,处于这个隔离级的事务可以读到其他事务还没有提交的数据,如果这个事务使用其他事务不提交的变化作为计算的基础,然后那些未提交的变化被它们的父事务撤销,这就导致了大量的数据变化。

读已提交(READ COMMITTED)
处于 READ COMMITTED 级别的事务可以看到其他事务对数据的修改。也就是说,在事务处理期间,如果其他事务修改了相应的表,那么同一个事务的多个 SELECT 语句可能返回不同的结果。在一个事务内,能看到别的事务提交的数据

可重复读(REPEATABLE READ) 默认
在可重复读在这一隔离级别上,事务不会被看成是一个序列。不过,当前正在执行事务的变化仍然不能被外部看到,也就是说,如果用户在另外一个事务中执行同条 SELECT 语句数次,结果总是相同的。(因为正在执行的事务所产生的数据变化不能被外部看到)。

别的事务提交的数据,也看不到

序列化(SERIALIZABLE)
如果隔离级别为序列化,则用户之间通过一个接一个顺序地执行当前的事务,这种隔离级别提供了事务之间最大限度的隔离。

脏读:读到了其他事务还没有提交的数据。

不可重复读:对某数据进行读取,发现两次读取的结果不同,也就是说没有读到相同的内容。这是因为有其他事务对这个数据同时进行了修改或删除,主要说的是update

幻读:事务 A 根据条件查询得到了 N 条数据,但此时事务 B 更改或者增加了 M 条符合事务 A 查询条件的数据,这样当事务 A 再次进行查询的时候发现会有 N+M 条数据,产生了幻读,主要说的是insert和delete

四种隔离级别各自解决的问题
在这里插入图片描述

三.事务传播行为

在这里插入图片描述

四.分布式事务解决方案

4.1 两阶段提交/XA

两阶段提交(Two-phase Commit,2PC),通过引入协调者(Coordinator)来协调参与者的行为,并最终决定这些参与者是否要真正执行事务。

如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。

XA一共分为两阶段:

第一阶段(prepare):即所有的参与者RM准备执行事务并锁住需要的资源。参与者ready时,向TM报告已准备就绪。
第二阶段 (commit/rollback):当事务管理者™确认所有参与者(RM)都ready后,向所有参与者发送commit命令。
目前主流的数据库基本都支持XA事务,包括mysql、oracle、sqlserver、postgre

XA 事务由一个或多个资源管理器(RM)、一个事务管理器(TM)和一个应用程序(ApplicationProgram)组成

需要注意的是,在准备阶段,参与者执行了事务,但是还未提交。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。

存在的问题:
同步阻塞 所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,无法进行其它操作。
单点问题 协调者在 2PC 中起到非常大的作用,发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
数据不一致 在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。
太过保守 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。

特点:

简单易理解,开发较容易

对资源进行了长时间的锁定,并发度低

4.2 3PC

3PC和2PC 很像,不过在3PC的基础上它加入一个预提交阶段,并引入了超时机制。
3PC就是三阶段提交,分别为CanCommit,PreCommit,DoCommit
CanCommit阶段
Coordinator协调者会向Participant,参与者发送CanCommit消息,询问它是否可以执行操作,参与者收到消息 后如果他能够执行,那么就会返回给协调者能够执行的命令,我们用Yes,如果不能,那么就返回不能执行的命令,我们用No,那么能不能 的评判标准是什么呢,这要看实现者怎么去实现这个问题,可能会获取数据库锁资源,可能是其他。

如果参与者能够执行任务,那么返回协调者Yes
参与者不能执行任务,返回No,结束事务
PreCommit阶段
PreCommit阶段如果协调者收到参与者返回的状态值都为Yes,那么就证明它们都有能力去执行这个操作,那么协调者就会向所有参与者 发送PreCommit消息,协调者收到PreCommit消息后,就会执行本地事务,执行成功后将本地事务日志保存到undo_log和redo_log中,然后给协调者返回Yes,如果参与者执行本地事务失败,那么就返回给协调者No,协调者只要收到一个No消息,就会给所有参与者发送 中断事务abort消息,参与者收到abort消息会对事务就行回滚,因为第二阶段参与者与协调者都引入了超时机制,所以如果参与者没有收到 协调者的PreCommit消息,或者协调者没有收到参与者返回的预执行结果状态,那么在超过等待时间后,事务就会中断,这就避免了事务的阻塞。

协调者向参与者发送PreCommit,参与者预执行成功,返回Yes给协调者
参与者预执行失败,返回No给协调者
参与者预执行失败,返回No给协调者,协调者向参与者发送中断操作消息,中断事务
DoCommit阶段
协调者收到所有参与者返回的状态都是Yes,这时协调者就会向所有的参与者都发送DoCommit,参与者收到DoCommit后,就会真正地提交事务, 当事务提交成功后,就会返回给协调者Yes状态,表明我已经完成事务的提交,协调者收到所有的参与者都返回Yes,那么就完成本次事务,如果有一个 参与者返回No状态,那么就代表整个事务都要进行回滚,此时协调者就会向所有参与者都发送abort事务中断消息,参与者收到abort消息后,就会 进行事务的回滚。

在DoCommit阶段如果参与者因为超时或者其他原因没有收到协调者发送的DoCommit消息,那么它也会去提交事务,因为其实在PreCommit阶段, 从某种意义上来说事务已经是成功了的,所以参与者会认提交事务成功的可能性很大,所以依然会提交,那我们也可以说,只要PreCommit阶段所有参与者 都返回了Yes状态,那么只要进入第三阶段,事务基本上都能执行成功的。

协调者向参与者发送DoCommit消息,参与者全部返回Yes
某个参与者返回No,协调者会发送abort中断事务消息给所有参与者,让它们进行事务回滚
如果因为超时或者网络原因等没收到协调者发送的DoCommit或者abort消息,参与者也会去提交事务

3pc的特点:
3PC是2PC的升级版,它引入了超时机制,解决了单点故障引起的事务阻塞问题,但是3PC依然不能解决事务一致性问题,因为在DoCommit阶段,如果由于网络或者超时等原因导致参与者接收不到协调者发送过来的abort中断事务消息,那么过了超时时间,参与者会提交事务,本来应该是进行事务回滚的, 现在好了,提交事务了,那就就出现了数据不一致问题。

4.3 SAGA

Saga是这一篇数据库论文saga提到的一个方案。其核心思想是将长事务拆分为多个本地短事务,由Saga事务协调器协调,如果正常结束那就正常完成,如果某个步骤失败,则根据相反顺序一次调用补偿操作。

SAGA事务的特点:

并发度高,不用像XA事务那样长期锁定资源

需要定义正常操作以及补偿操作,开发量比XA大

一致性较弱,对于转账,可能发生A用户已扣款,最后转账又失败的情况

论文里面的SAGA内容较多,包括两种恢复策略,包括分支事务并发执行,我们这里的讨论,仅包括最简单的SAGA

SAGA适用的场景较多,长事务适用,对中间结果不敏感的业务场景适用

4.4 TCC

TCC(Try Confirm Cancel)方案是一种应用层面侵入业务的两阶段提交。是目前最火的一种柔性事务方案,其核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作。

TCC分为两个阶段,分别如下:

第一阶段:Try(尝试),主要是对业务系统做检测及资源预留 (加锁,锁住资源)
第二阶段:本阶段根据第一阶段的结果,决定是执行confirm还是cancel
Confirm(确认):执行真正的业务(执行业务,释放锁)
Cancle(取消):是预留资源的取消(出问题,释放锁)

在这里插入图片描述

TCC特点如下:

并发度较高,无长期资源锁定。

开发量较大,需要提供Try/Confirm/Cancel接口。

一致性较好,不会发生SAGA已扣款最后又转账失败的情况

TCC适用于订单类业务,对中间状态有约束的业务

4.5 AT

Seata的AT模式对业务是没有入侵的,AT模式其实能满足80%的业务场景,所以AT模式也是用得最多的,Seata的AT模式是2PC的具体实现,但是它和传统的2PC又有着 区别,区别不在于实现的思想上,而是在于处理的方式不一样。

传统的2PC:传统的2PC第一阶段是预提交,它会在本地发起一个事务,但是没有提交,而是将事务日志保存到数据库undo_log和redo_log中,它是数据库级别的, 所以在事务回滚事务的时候,会去读取日志来进行恢复。

Seata AT模式在第第一阶段会对事务进行解析,提取出sql语句,并制作出原始快照和新快照,然后保存进undo_log表中,只不过这里的undo_log表是一张数据表, 参与分布式事务的参与者都需要这一张表,是Seata设计的,在进行事务提交和回滚的时候也是基于这张表。

4.6 本地消息表

订单系统新增一条消息表,将新增订单和新增消息放到一个事务里完成,然后通过轮询的方式去查询消息表,将消息推送到 MQ,库存系统去消费 MQ。

在这里插入图片描述

本地消息表的特点:

长事务仅需要分拆成多个任务,使用简单

生产者需要额外的创建消息表

每个本地消息表都需要进行轮询

消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作

4.7 MQ事务消息

有一些第三方的MQ是支持事务消息的,比如RocketMQ,他们支持事务消息的方式也是类似于采用的二阶段提交,但是市面上一些主流的MQ都是不支持事务消息的,比如 RabbitMQ 和 Kafka 都不支持。

RocketMQ提供了类似X/Open XA的分布事务功能,通过MQ的事务消息能达到分布式事务的最终一致。

总体而言RocketMQ事务消息分为两条主线

定时任务发送流程:发送half message(半消息),执行本地事务,发送事务执行结果

定时任务回查流程:MQ服务器回查本地事务,发送事务执行结果

半消息的实现,在RabbitMQ的基础上增加了功能

在这里插入图片

事务消息特点如下:

长事务仅需要分拆成多个任务,并提供一个反查接口,使用简单

消费者的逻辑如果无法通过重试成功,那么还需要更多的机制,来回滚操作

适用于可异步执行的业务,且后续操作无需回滚的业务

实现了最终一致性,不需要依赖本地数据库事务。

目前主流MQ中只有RocketMQ支持事务消息(需要买阿里的服务)。

分布式事务中的网络异常

在分布式事务的各个环节都有可能出现网络以及业务故障等问题,这些问题需要分布式事务的业务方做到防空回滚,幂等,防悬挂三个特性,下面以TCC事务说明这些异常情况:

空回滚:

在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。

出现原因是当一个分支事务所在服务宕机或网络异常,分支事务调用记录为失败,这个时候其实是没有执行Try阶段,当故障恢复后,分布式事务进行回滚则会调用二阶段的Cancel方法,从而形成空回滚。

幂等:

由于任何一个请求都可能出现网络异常,出现重复请求,所以所有的分布式事务分支,都需要保证幂等性

悬挂:

悬挂就是对于一个分布式事务,其二阶段 Cancel 接口比 Try 接口先执行。

出现原因是在 RPC 调用分支事务try时,先注册分支事务,再执行RPC调用,如果此时 RPC 调用的网络发生拥堵,RPC 超时以后,TM就会通知RM回滚该分布式事务,可能回滚完成后,RPC 请求才到达参与者真正执行。

五.Seata详解

5.1 Seata是什么

Seata 是一款开源的分布式事务解决方案,致力于提供高性能和简单易用的分布式事务服务。Seata 将为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

5.2 AT模式

5.2.1 1+3组件

Transaction Coordinator (TC): 事务协调器,维护全局事务的运行状态,负责协调并决定全局事务的提交或回滚。(Seata)
Transaction Manager(TM): 控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议。(全局入口)
Resource Manager (RM): 资源管理器,负责本地事务的注册,本地事务状态的汇报(投票),并且负责本地事务的提交和回滚。(分布式服务)
XID: 一个全局事务的唯一标识
其中,TM是一个分布式事务的发起者和终结者,TC负责维护分布式事务的运行状态,而RM则负责本地事务的运行

5.2.2 实现步骤

TM端使用@GlobalTransaction进行全局事务开启、提交、回滚。
TM开始RPC调用远程服务。
RM端seata-client通过扩展DataSourceProxy,实现自动生成undo_log与TC上报。
TM告知TC提交/回滚全局事务。
TC通知RM各自执行commit/rollback操作,同时清楚undo_log。

在这里插入图片描述
一阶段步骤
TM:bussiness-service.buy(long,long)方法执行时,由于该方法具有@GlobalTransactional标志,该TM会向TC发起全局事务,生成XID(全局锁)。
RM:OrderService.create(long,long):写表,undo_log记录回滚日志(Branch ID),通知TC操作结果。
RM:StorageService.changeNum(long,long):写表,undo_log记录回滚日志(Branch ID),通知TC操作结果。
RM写表的过程,Seata会拦截业务SQL,首先解析SQL语义,在业务数据被更新前,将其保存成before image,然后执行业务SQL,在业务数据更新之后,再将其保存成after image,最后生成行锁。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。

在这里插入图片描述
二阶段步骤
因为“业务 SQL”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。

正常:TM执行成功,通知TC全局提交,TC此时通知所有的RM提交成功,删除UNDO_LOG回滚日志。

在这里插入图片描述
异常
TM执行失败,通知TC全局回滚,TC此时通知所有的RM进行回滚,根据UNDO_LOG反向操作,使用before image还原业务数据,删除UNDO_LOG,但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写,出现脏写就需要转人工处理。

在这里插入图片描述

AT模式的一阶段、二阶段提交和回滚均由Seata框架自动生成,用户只需编写业务SQL,便能轻松接入分布式事务,AT模式是一种对业务无任何侵入的分布式事务解决方案。

全局事务锁原理:
在这里插入图片描述

为什么Seata在第一阶段就直接提交了分支事务

Seata能够在第一阶段直接提交事务,是因为Seata框架为每一个RM维护了一张UNDO_LOG表(这张表需要客户端自行创建),其中保存了每一次本地事务的回滚数据。因此,二阶段的回滚并不依赖于本地数据库事务的回滚,而是RM直接读取这张UNDO_LOG表,并将数据库中的数据更新为UNDO_LOG中存储的历史数据。这也是在使用seata作为分布式事务解决方案的时候,需要在参与分布式事务的每一个服务中加入UNDO_LOG表。

这里举一个例子,事务A,事务B,对money字段减100元操作。

update account set money = money - 100 where id = 1;

事务A先开始,开启本地事务,拿到本地锁,更新操作 money = 1000 - 100 = 900,同时记录undolog,本地事务提交前,先拿到该记录的 全局锁 ,事务A拿到后,进行提交,提交后释放本地锁。此时事务B 开始,开启本地事务,拿到本地锁(事务A已经释放),更新操作 money = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,但此时,事务A可能还没释放全局锁,事务B需要重试等待 全局锁 。后续可以分为两种情况

1、一阶段事务A提交成功,释放本地锁,第二阶段异步提交,事实上并不会对数据进行提交(因为一阶段已经提交了),而是发起一个异步请求删除undolog中关于本事务的记录,同时释放全局锁。然后事务B拿到了这把全局锁,提交成功,money更新为800

2、一阶段事务A提交成功,但是二阶段失败了,需要回滚,此时事务A需要获取本地锁,但是本地锁被事务B占用,同时自己又占有全局锁,这就造成了死锁,Seata的方案是:锁超时。

如果事务B仍在等待该数据的 全局锁,同时持有本地锁,则 事务A 的分支回滚会失败。分支的回滚会一直重试,直到事务B的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,事务A的分支回滚最终成功。这样money又恢复成了1000

因为整个过程 全局锁 在 事务A 结束前一直是被 事务A 持有的,所以不会发生 脏写 的问题。

Seata脏读问题

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted),所以Seata默认配置会产生脏读。这里分布式事务的隔离级别说的是分布式事务和分布式事务之间的可见性,上升了一个层面不要把它想成单机事务的隔离性。例如两个分布式事务A和B,事务A增加了100元,对于事物B来说,事务A确确实实在一阶段数据库层面已经提交了,假如事物A在一阶段成功了,二阶段失败了,那么对于分布式事务B来说就是读取到了脏数据。

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。

出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

所以没有框架能完全解决分布式事务问题,只能根据自己业务相互平衡

5.2.3 写隔离

一阶段本地事务提交前,需要确保先拿到 全局锁 。
拿不到 全局锁 ,不能提交本地事务。
拿 全局锁 的尝试被限制在一定范围内,超出范围将放弃,并回滚本地事务,释放本地锁。
以一个示例来说明:

两个全局事务 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 持有的,所以不会发生 脏写 的问题。

5.2.4 读隔离

在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。

如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。

在这里插入图片描述
SELECT FOR UPDATE 语句的执行会申请 全局锁 ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。

for update扩展
如果遇到存在高并发并且对于数据的准确性很有要求的场景,需要使用for update。
比如涉及到金钱、库存等。一般这些操作都是很长一串并且是开启事务的。如果库存刚开始读的时候是1,而立马另一个进程进行了update将库存更新为0了,而事务还没有结束,会将错的数据一直执行下去,就会有问题。所以需要for upate 进行数据加锁防止高并发时候数据出错。

for update 仅适用于InnoDB,并且必须开启事务,在begin与commit之间才生效。
要测试for update的锁表情况,可以利用MySQL的Command Mode,开启二个视窗来做测试。

for update操作在未获取到数据的时候,mysql不进行锁 (no lock)
获取到数据的时候,进行对约束字段进行判断,存在有索引的字段则进行row lock 否则进行 table lock
当使用 ‘<>’,'like’等关键字时,进行for update操作时,mysql进行的是table lock

5.2.5 底层实现

AT 模式下,会使用 Seata 内部数据源代理 DataSourceProxy,全局锁的实现就是隐藏在这个代理中。我们分别在执行、提交的过程都做了什么。

执行过程
执行过程在 StatementProxy 类,在执行过程中,如果执行 SQL 是 select for update,则会使用 SelectForUpdateExecutor 类,如果执行方法中带有 @GlobalTransactional or @GlobalLock注解,则会检查是否有全局锁,如果当前存在全局锁,则会回滚本地事务,通过 while 循环不断地重新竞争获取本地锁和全局锁。

public T doExecute(Object... args) throws Throwable {
    Connection conn = statementProxy.getConnection();
    // ... ...
    try {
        // ... ...
        while (true) {
            try {
                // ... ...
                if (RootContext.inGlobalTransaction() || RootContext.requireGlobalLock()) {
                    // Do the same thing under either @GlobalTransactional or @GlobalLock, 
                    // that only check the global lock  here.
                    statementProxy.getConnectionProxy().checkLock(lockKeys);
                } else {
                    throw new RuntimeException("Unknown situation!");
                }
                break;
            } catch (LockConflictException lce) {
                if (sp != null) {
                    conn.rollback(sp);
                } else {
                    conn.rollback();
                }
                // trigger retry
                lockRetryController.sleep(lce);
            }
        }
    } finally {
        // ...
    }

提交过程
提交过程在 ConnectionProxy#doCommit方法中。

1)如果执行方法中带有@GlobalTransactional注解,则会在注册分支时候获取全局锁:

请求 TC 注册分支

private void register() throws TransactionException {
    if (!context.hasUndoLog() || !context.hasLockKey()) {
        return;
    }
    Long branchId = DefaultResourceManager.get().branchRegister(BranchType.AT, getDataSourceProxy().getResourceId(),
                                                                null, context.getXid(), null, context.buildLockKeys());
    context.setBranchId(branchId);
}

TC 注册分支的时候,获取全局锁

protected void branchSessionLock(GlobalSession globalSession, BranchSession branchSession) throws TransactionException {
    if (!branchSession.lock()) {
        throw new BranchTransactionException(LockKeyConflict, String
                                             .format("Global lock acquire failed xid = %s branchId = %s", globalSession.getXid(),
                                                     branchSession.getBranchId()));
    }
}

2)如果执行方法中带有@GlobalLock注解,在提交前会查询全局锁是否存在,如果存在则抛异常:

private void processLocalCommitWithGlobalLocks() throws SQLException {
    checkLock(context.buildLockKeys());
    try {
        targetConnection.commit();
    } catch (Throwable ex) {
        throw new SQLException(ex);
    }
    context.reset();
}

GlobalLock 注解说明
从执行过程和提交过程可以看出,既然开启全局事务 @GlobalTransactional注解可以在事务提交前,查询全局锁是否存在,那为什么 Seata 还要设计多处一个 @GlobalLock注解呢?

因为并不是所有的数据库操作都需要开启全局事务,而开启全局事务是一个比较重的操作,需要向 TC 发起开启全局事务等 RPC 过程,而@GlobalLock注解只会在执行过程中查询全局锁是否存在,不会去开启全局事务,因此在不需要全局事务,而又需要检查全局锁避免脏读脏写时,使用@GlobalLock注解是一个更加轻量的操作。

如何防止脏写
先来看一下使用 Seata AT 模式是怎么产生脏写的:

在这里插入图片描述
业务一开启全局事务,其中包含分支事务A(修改 A)和分支事务 B(修改 B),业务二修改 A,其中业务一执行分支事务 A 先获取本地锁,业务二则等待业务一执行完分支事务 A 之后,获得本地锁修改 A 并入库,业务一在执行分支事务时发生异常了,由于分支事务 A 的数据被业务二修改,导致业务一的全局事务无法回滚。

如何防止脏写?

1、业务二执行时加 @GlobalTransactional注解:

在这里插入图片描述
业务二在执行全局事务过程中,分支事务 A 提交前注册分支事务获取全局锁时,发现业务业务一全局锁还没执行完,因此业务二提交不了,抛异常回滚,所以不会发生脏写。

2、业务二执行时加 @GlobalLock注解:

在这里插入图片描述
与 @GlobalTransactional注解效果类似,只不过不需要开启全局事务,只在本地事务提交前,检查全局锁是否存在。

2、业务二执行时加 @GlobalLock 注解 + select for update语句:
在这里插入图片描述
果加了select for update语句,则会在 update 前检查全局锁是否存在,只有当全局锁释放之后,业务二才能开始执行 updateA 操作。

如果单单是 transactional,那么就有可能会出现脏写,根本原因是没有 Globallock 注解时,不会检查全局锁,这可能会导致另外一个全局事务回滚时,发现某个分支事务被脏写了。所以加 select for update 也有个好处,就是可以重试。

如何防止脏读
Seata AT 模式的脏读是指在全局事务未提交前,被其它业务读到已提交的分支事务的数据,本质上是Seata默认的全局事务是读未提交。

那么怎么避免脏读现象呢?

业务二查询 A 时加 @GlobalLock 注解 + select for update语句:

在这里插入图片描述

业务二查询 A 时加 @GlobalLock 注解 + select for update语句:
加select for update语句会在执行 SQL 前检查全局锁是否存在,只有当全局锁完成之后,才能继续执行 SQL,这样就防止了脏读。

5.2.6 代码实现

下单总接口

@Component
@AllArgsConstructor
public class PlaceOrderExecute {

    private final IntegralClient integralClient;
    private final StockClient stockClient;
    private final OrderClient orderClient;

    @GlobalTransactional
    public Response execute(PlaceOrderDTO placeOrderDTO) {
        //保存订单
        Order order = new Order()
                .setUserId(placeOrderDTO.getUserId())
                .setCommodityId(placeOrderDTO.getCommodityId())
                .setCount(placeOrderDTO.getCount())
                .setMoney(placeOrderDTO.getMoney());
        orderClient.saveOrder(order);
        //扣减积分
        Integral integral = new Integral().setUserId(placeOrderDTO.getUserId()).setIntegral(10);
        integralClient.increaseIntegral(integral);
        //扣减库存
        Stock stock = new Stock().setCommodityId(placeOrderDTO.getCommodityId()).setStockNum(placeOrderDTO.getCount());
        stockClient.decreaseStock(stock);
        return new Response(200,"placeOrder success",null);
    }
}

订单服务调用账户服务扣减余额

@Component
@AllArgsConstructor
public class OrderExecute {

    final OrderDao orderDao;
    final AccountClient accountClient;

    public Response execute(Order order) {
        //保存订单信息
        orderDao.saveOrder(order);
        //扣减账户余额
        accountClient.decreaseBalance(order);
        return new Response(200,"placeOrder success",null);
    }
}

上面就完成了,我们发现,只在下单接口加了一个@GlobalTransactional
注解便可,所以AT模式对代码是零入侵的。

总结:
加上@GlobalTransactional注解 即是向TC(事务协调者)发起了一次全局事务,TM 向 TC 申请开启一个全局事务,全局事务创建成功并生成一个全局唯一的 XID。
发起者就是TM(事务发起者) 各个分支事务则是RM(事务参与者)
RM 向 TC 注册分支事务,接着执行这个分支事务并提交(重点:RM在第一阶段就已经执行了本地事务的提交/回滚),最后将执行结果汇报给TC。
TM 根据 TC 中所有的分支事务的执行情况,发起全局提交或回滚决议。
TC 调度 XID 下管辖的全部分支事务完成提交或回滚请求。

每个分支事务都要执行的流程:
第一阶段:
先获取本地锁,执行本地事务,生成前置镜像,执行sql,生成后置镜像,根基前置和后置镜像生成undolog,然后获取全局锁
1.获取到了全局锁 进行事务提交 然后释放本地锁
2.获取不到全局锁 等待其他线程全局锁释放
2.1 获取到了全局锁 然后提交事务 释放本地锁
2.2 获取全局锁超时 回滚事务 释放本地锁
告知TC提交结果
第二阶段:
TC通知各分支执行结果,
成功则删除undolog日志 删除各个分支事务的全局锁 删除全局事务id
失败则按照undolog进行反向补偿(如果有其他线程修改了该数据,就是脏写,则报错,需要人工处理,因为全局锁并不限制本地事务线程修改)

5.3 TCC模式

我们用一个下单过程来说明TCC
的工作机制,正常的下单会包含创建订单,扣减库存,扣减账户余额,增加积分等功能,所以会调用这些子系统, 有的系统使用http进行远程调用,有的使用rpc进行调用,我们这个例子中有五个微服务,分别为业务微服务(BusinessMicroservice), 库存微服务(StockMicroservice),订单微服务(OrderMicroservice),积分微服务(IntegralMicroservice), 账户微服务(AccountMicroservice),在业务微服务中通过rpc统一调用其他微服务。
在这里插入图片描述
使用TCC
就需要设计三部分的业务代码,分别是Try
阶段,Confirm
阶段,Concel
阶段。

5.3.1 Try

在这里插入图片描述
预扣减库存
这一步并没有真正地扣减库存,所以叫做预扣减库存,首先先检查库存是否被扣减,比如我下单量为2,此时库存为1,那么显然不能扣减,如果库存为 10,那么证明可以扣减,但是此时并不会真正地扣减库存,我们需要对其进行一些设计,这需要根据自己的业务场景去设计,比如可以新建一个冻结库存 的表,因为下单量为2,库存为10,所以此时执行10-2,库存为8,我们就将库存更新为8,然后在库存冻结表中插入一条扣减记录,记录是某个用户的下单数量, ,那么扣减库存这一步就完成了。
在这里插入图片描述
预创建订单
预创建订单也不是真正地创建订单,我们可以将订单的状态改为创建中,这个状态值只是用来表示订单的状态,这个状态并非真实订单的状态,而是为了使用 分布式事务而使用的状态,并不是商品生命周期中的属性。

在这里插入图片描述
预增加积分

在这里插入图片描述
预扣减余额
扣减余额我们在账户表中添加一个冻结字段,例如,如果账户余额为1000,本次需要扣减200,那么此时余额就变为800(1000-200), 冻结金额为200。
在这里插入图片描述
到这里,Try阶段我们就说完了,我们发现,在Try阶段我们做的事就是在预留资源。

5.3.2 Confirm

如果Try阶段所有的业务都成功地执行完毕,没有出现错误,那么在Confirm阶段就会执行所有分支事务,这个阶段唯一做的就是提交事务(完成我们自己定义的逻辑), 不会再去校验数据,比如库存是否充足等,因为第一阶段已经检验过并且通过了。
在这里插入图片描述
扣减库存

在这里插入图片描述
设置订单状态为已创建
Try阶段将订单状态设置为创建中,到了这里就需要将订单状态设置为已创建,代表订单事务已经完成。

在这里插入图片描述

增加积分

这里的增加积分在Try阶段其实已经做了,只是预留了一个冻结积分,所以这里就需要更新冻结积分,将其更新为0,代表增加积分这个事务已经完成。
在这里插入图片描述

扣减余额

这里的扣减余额在Try阶段已经做了,只是预留了一个冻结金额,所以这里就需要更新冻结金额,将其更新为0,代表扣减余额事务已经完成。

在这里插入图片描述
到这里TCC
的Confirm
就完成了,Confirm
阶段唯一做的事情就是执行任务,不做任何的数据校验。

5.3.3 Cancel

上面的Confirm
阶段是Try
阶段所有的操作都正常,没有出错,如果有一种的一个操作出现异常或资源出错,那么就会进入Cancel
阶段,Cancel
阶段会对Try
阶段的所有操作进行回滚,也就是将数据恢复到刚开始的时候。
在这里插入图片描述
恢复库存

恢复库存就是查询库存冻结表中的冻结库存,然后加上库存表中库存(库存表库存 = 库存表库存 + 冻结库存), 8 + 2 = 10 , 然后删除冻结库存记录, 代表事务回滚成功。

在这里插入图片描述
设置订单状态为取消

Try
阶段订单状态为创建中,那么因为在Try
阶段某个分支事务出错,所以需要将订单状态置为已取消(这个状态并不是订单生命周期中的状态), 而是为事务设计的状态,代表事务回滚成功。
在这里插入图片描述

恢复积分
用冻结积分加上积分余额(积分余额 = 积分余额 + 冻结积分),然后更新到积分余额上,随后将冻结积分更新为0,代表事务回滚成功。
在这里插入图片描述
恢复余额
用冻结余额加上余额(余额 = 余额+ 冻结余额),更新到余额上面,将冻结余额更新为0,代表事务回滚成功。
在这里插入图片描述
Cancel
阶段就完成了,Cancel
阶段主要是对各个事务进行恢复,他是基于Try
阶段的数据进行恢复。

总结
完成了TCC
的分析,我们可以看出TCC
事务之间并没有阻塞,但是事务的成败很大一部分是掌握在开发人员的手上,因为它不像2PC
模式的 框架完全是由框架来帮我们完成事务的提交和回滚,在TCC
模式中,事务的提交回滚都是要由我们去编写业务代码来实现,TCC
帮我们做的是对任务 的调度,感知分支事务是否正常,再根据结果进行提交或者回滚,所以,我们编写代码的好坏直接影响到TCC
事务是否成功。

从上面的例子中,我们其实不难看出,TCC
事务并不能完全保证事务的一致性,在Try
阶段,如果所有分支都正常,那么其实在Confirm
阶段 基本上都能成功,如果Try
阶段失败,那么在Cancel
阶段其实也基本能成功,如果在Confirm
阶段和Cancel
没成功,那么TCC
框架就会重试, 或者需要人工进行处理,所以数据的一致性并不能完全得到保障。

5.3.4 代码实现

Seata的TCC是要使用接口,我们需要在总事务接口上面使用@LocalTCC
注解,在方法上使用@TwoPhaseBusinessAction
注解,注解参数name
是事务名称,唯一的,commitMethod
是事务提交方法,rollbackMethod
是回滚方法,两个方法都需要我们编写。

@LocalTCC
public interface IBusinessService {

    @TwoPhaseBusinessAction(name = "placeOrder",
                            commitMethod = "commitOrder" ,
                            rollbackMethod = "rollbackOrder")
    Response placeOrder(@BusinessActionContextParameter(paramName = "params")Map<String,String> params);

    void commitOrder(BusinessActionContext context);

    void rollbackOrder(BusinessActionContext context);
}

实现类,里面就是编写Try阶段预留资源,Confirm阶段提交事务,Cancel阶段回滚事务的逻辑。

@Component
@AllArgsConstructor
public class BusinessServiceImpl implements IBusinessService {
    private final IntegralClient integralClient;
    private final StockClient stockClient;
    private final OrderClient orderClient;
    private final AccountClient accountClient;
    /**
     * Try
     * @param
     * @return
     */
    @Override
    @Transactional(rollbackFor = Exception.class)
    public Response placeOrder(Map<String,String> params){
        PlaceOrderDTO placeOrderDTO = JSON.parseObject(JSON.toJSONString(params), PlaceOrderDTO.class);
        FreezeStock freezeStock = stockClient.queryByUserId(placeOrderDTO.getUserId());
        if (null != freezeStock){
            throw new RuntimeException("abort the transaction");
        }
        //insert freeze stock record
        stockClient.saveFreezeStock(buildFreezeStock(placeOrderDTO));
        //update stock
        stockClient.decreaseStock(buildStockDTO(placeOrderDTO));
        //update integral
        integralClient.increaseIntegral(buildIntegralDTO(placeOrderDTO));
        //update account
        accountClient.decreaseBalance(buildAccountDTO(placeOrderDTO));
        return new Response(200,"place order success",null);
    }

    /**
     * Confirm
     * @param context
     * @return
     */
    @Override
    public void commitOrder(BusinessActionContext context) {
        Map<String,String> params = (Map<String, String>) context.getActionContext("params");
        Integer userId = Integer.valueOf(params.get("userId"));
        //update order status to 2
        String orderId =params.get("orderId");
        int confirmStatus = 2;
        orderClient.updateOrderStatus(buildUpdateOrderDTO(orderId,confirmStatus));
        //delete the freeze table
        stockClient.deleteFreezeRecordByUserId(userId);
        //update the freeze integral to 0
        integralClient.updateFreezeIntegralToZeroByUserId(userId);
        //update the account balance to 0
        accountClient.updateAccountBalanceToZeroById(userId);
    }

    /**
     * Cancel
     * @param context
     * @return
     */
    @Override
    public void rollbackOrder(BusinessActionContext context) {
        Map<String,String> params = (Map<String, String>) context.getActionContext("params");
        Integer userId = Integer.valueOf(params.get("userId").toString());
        //update the order status to 3 or delete the order
        String orderId = params.get("orderId").toString();
        Integer commodityId = Integer.valueOf(params.get("commodityId").toString());
        Integer count = Integer.valueOf(params.get("count").toString());
        Integer integral = Integer.valueOf(params.get("integral").toString());
        BigDecimal money = BigDecimal.valueOf(Long.parseLong(params.get("money").toString()));
        int confirmStatus = 3;
        orderClient.updateOrderStatus(buildUpdateOrderDTO(orderId,confirmStatus));
        //recovery stock and delete freeze record
        stockClient.updateStockNumByUserId(buildUpdateStockDTO(commodityId,count));
        stockClient.deleteFreezeRecordByUserId(userId);
        //recovery the integral balance
        integralClient.updateIntegralBalance(buildUpdateIntegralDTO(integral,userId));
        //recovery the account
        accountClient.updateAccountBalance(buildUpdateAccountDTO(userId,money));
    }

    public AccountDTO buildAccountDTO(PlaceOrderDTO placeOrderDTO){
        return new AccountDTO()
                .setUserId(placeOrderDTO.getUserId())
                .setMoney(placeOrderDTO.getMoney());
    }

    public IntegralDTO buildIntegralDTO(PlaceOrderDTO placeOrderDTO){
        return new IntegralDTO()
                .setIntegral(placeOrderDTO.getIntegral())
                .setUserId(placeOrderDTO.getUserId());
    }

    public StockDTO buildStockDTO(PlaceOrderDTO placeOrderDTO){
        return new StockDTO()
                .setCommodityId(placeOrderDTO.getCommodityId())
                .setCount(placeOrderDTO.getCount());
    }

    public FreezeStock buildFreezeStock(PlaceOrderDTO placeOrderDTO){
        return new FreezeStock()
                .setUserId(placeOrderDTO.getUserId())
                .setFreezeStock(placeOrderDTO.getCount())
                .setCommodityId(placeOrderDTO.getCommodityId());
    }

    public Order buildOrder(PlaceOrderDTO placeOrderDTO , Integer status){
        return new Order()
                .setUserId(placeOrderDTO.getUserId())
                .setCommodityId(placeOrderDTO.getCommodityId())
                .setCount(placeOrderDTO.getCount())
                .setMoney(placeOrderDTO.getMoney())
                .setOrderId(placeOrderDTO.getOrderId())
                .setStatus(status);
    }

    public UpdateOrderDTO buildUpdateOrderDTO(String orderId , Integer status){
        return new UpdateOrderDTO()
                .setStatus(status)
                .setOrderId(orderId);
    }

    public UpdateStockDTO buildUpdateStockDTO(Integer commodityId , Integer count){
        return new UpdateStockDTO()
                .setCommodityId(commodityId)
                .setCount(count);
    }

    public IntegralDTO buildUpdateIntegralDTO(Integer integral , Integer userId){
        return new IntegralDTO()
                .setIntegral(integral)
                .setUserId(userId);
    }

    public AccountDTO buildUpdateAccountDTO(Integer userId , BigDecimal money){
        return new AccountDTO()
                .setUserId(userId)
                .setMoney(money);
    }
}

然后调用接口,我们发现TCC模式也需要使用@GlobalTransactional
注解。

@RestController
@AllArgsConstructor
public class PlaceOrderApi {

    private final IBusinessService businessService;
    private final OrderClient orderClient;

    @GlobalTransactional
    @PostMapping("placeOrder")
    public Response placeOrder(@RequestBody PlaceOrderDTO placeOrderDTO) throws InvocationTargetException, IllegalAccessException, NoSuchMethodException {
        int tryStatus = 1;
        Order order = buildOrder(placeOrderDTO, tryStatus);
        orderClient.saveOrder(order);
        Map<String, String> map = BeanUtils.describe(placeOrderDTO.setOrderId(order.getOrderId()));
        return businessService.placeOrder(map);
    }

    public Order buildOrder(PlaceOrderDTO placeOrderDTO , Integer status){
        return new Order()
                .setUserId(placeOrderDTO.getUserId())
                .setCommodityId(placeOrderDTO.getCommodityId())
                .setCount(placeOrderDTO.getCount())
                .setMoney(placeOrderDTO.getMoney())
                .setOrderId(IdWorker.fastId())
                .setStatus(status);
    }
}

参考文献
TCC模式详解:
实战,阿里神器 Seata 实现 TCC模式 解决分布式事务,真香
读完这一篇,我不信你还不懂分布式事务TCC

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值