事务特性

我们知道事务有4个非常重要的特性,即我们常说的(ACID)。

Atomicity(原子性):

是说事务是一个不可分割的整体,所有操作要么全做,要么全不做;只要事务中有一个操作出错,回滚到事务开始前的状态的话,那么之前已经执行的所有操作都是无效的,都应该回滚到开始前的状态。

Consistency(一致性):

是说事务执行前后,数据从一个状态到另一个状态必须是一致的,比如A向B转账( A、B的总金额就是一个一致性状态),不可能出现A扣了钱,B却没收到的情况发生。

Isolation(隔离性):

多个并发事务之间相互隔离,不能互相干扰。关于事务的隔离性,可能不是特别好理解,这里的并发事务是指两个事务操作了同一份数据的情况;而对于并发事务操作同一份数据的隔离性问题,则是要求不能出现脏读、幻读的情况,即事务A不能读取事务B还没有提交的数据,或者在事务A读取数据进行更新操作时,不允许事务B率先更新掉这条数据。而为了解决这个问题,常用的手段就是加锁了,对于数据库来说就是通过数据库的相关锁机制来保证。

Durablity(持久性):

事务完成后,对数据库的更改是永久保存的,不能回滚。

Seata

Seata介绍

Seata 是阿里开源的分布式事务框架,Seata分TC、TM和RM三个角色,TC(Server端)为单独服务端部署,TM和RM(Client端)由业务系统集成。因此,客户端承担了TM和TM的角色。为用户提供了基于两阶段提交的 AT、TCC、SAGA 和 XA 事务模式,解决微服务场景下面临的分布式事务问题,

3个核心组件

  1. TC (Transaction Coordinator) - 事务协调者

维护全局和分支事务的状态,指示全局提交或者回滚。

  1. TM (Transaction Manager) - 事务管理器

定义全局事务的范围:开始全局事务、提交或回滚全局事务。

  1. RM (Resource Manager) - 资源管理器

管理执行分支事务的那些资源,向TC注册分支事务、上报分支事务状态、控制分支事务的提交或者回滚。

TM是一个分布式事务的发起者和终结者(管理者),TC负责维护分布式事务的运行状态,而RM则负责本地事务的运行。三个组件相互协作,其中TC以Server形式独立部署,TM和RM集成在应用中启动,其整体交互如下:

设计原理

2021-01-08 Seata - 图1

  1. TM向TC发起请求,说明自己要开启一个全局事务,TC 会生成一个全局事务ID(XID),并返回给 TM
  2. TM得到 XID 后,开始调用各个服务(RM)
  3. RM会收到 XID,知道自己的事务属于这个全局事务。RM继续执行自己的业务逻辑,操作数据库。
  4. RM注册事务到 TC,作为这个 XID 下面的一个分支事务,并且把自己的事务执行结果也告诉 TC。
  5. 在各个微服务都执行完成后,TM如果发现各个微服务的本地事务都执行成功了,就请求 TC 对这个 XID 提交,否则回滚。
  6. TC 收到请求后,向 XID 下的所有分支事务发起相应请求。
  7. 各个微服务收到 TC 的请求后,执行相应指令,并把执行结果上报 TC。

模式

Seata提供了 ATTCCSAGAXA 等事务模式,其中AT、XA模式是无业务入侵模式,SAGA和TCC为业务入侵模式。

AT模式(无业务入侵)

AT模式是seata的默认工作模式。

前提
  • 需要基于支持本地ACID事务的关系型数据库,
  • java应用,通过jdbc访问数据库

两阶段提交协议

一阶段

2021-01-08 Seata - 图2

  1. 拦截“业务 SQL
  1. 解析 SQL:Seata的数据源代理拦截SQL,进行解析,得到 SQL 的类型(UPDATE),表(product),条件(where name = ‘TXC’)等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。在业务数据被更新前,将其保存成“before image”。
  3. 执行业务 SQL:更新这条记录的 name 为 ‘GTS’。
  4. 查询后镜像:根据前镜像的结果,通过主键定位数据,将其保存成“after image”。
  5. 生成回滚日志:把前后镜像数据组成一条回滚日志记录。
  6. 注册分支事务:提交前,向 TC 注册分支(注册分支事务,往tc的分支表和锁表插入数据),申请全局锁 。
  7. 分支事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  8. 将本地事务提交的结果上报给 TC。

2021-01-08 Seata - 图3

本地事务提交前必须先向服务端注册分支,分支注册信息中包含由表名和行主键组成的全局锁,如果分支注册过程中发现全局锁正在被其他全局事务锁定则抛出全局锁冲突异常,客户端需要循环等待,直到其他全局事务释放锁之后该本地事务才能提交。Seata 以这样的机制保证全局事务间的写隔离。

二阶段-回滚

回滚相对复杂一些,如果发起方一阶段抛异常会向服务端请求回滚该全局事务,服务端会根据 xid 查询出这个全局事务,加锁关闭事务使得后续不会再有分支注册上来,并同时更改其状态 Begin 为 Rollbacking,接着进行同步回滚以保证数据一致性。除了同步回滚这个点外,其他流程同提交时相似,如果同步回滚成功则释放全局锁并删除事务日志,如果失败则会进行异步重试。整个流程如下图所示:

2021-01-08 Seata - 图4

客户端接收到服务端的 branch rollback 请求,先根据 resourceId 拿到对应的数据源代理,然后根据 xid 和branchId 查询出 UndoLog 记录,反序列化其中的 rollback 字段拿到数据的前后快照,我们称该全局事务为A。

根据具体 SQL 类型生成对应的 UndoExecutor,校验一下数据 UndoLog 中的前后快照是否一致或者前置快照和当前数据(这里需要 SELECT 一次)是否一致,如果一致说明不需要做回滚操作,如果不一致则生成反向 SQL 进行补偿,在提交本地事务前会检测获取数据库本地锁是否成功,如果失败则说明存在其他全局事务(假设称之为 B)的一阶段正在修改相同的行,但是由于这些行的主键在服务端已经被当前正在执行二阶段回滚的全局事务 A 锁定,因此事务 B 的一阶段在本地提交前尝试获取全局锁一定是失败的,等到获取全局锁超时后全局事务 B 会释放本地锁,这样全局事务 A 就可以继续进行本地事务的提交,成功之后删除本地UndoLog 记录。整个流程如下图所示:

2021-01-08 Seata - 图5

2021-01-08 Seata - 图6

  • 收到 TC 的分支回滚请求,执行如下操作。
  • 通过 XID 和 Branch ID 查找到相应的 UNDO LOG 记录。
  • 数据校验:拿 UNDO LOG中的后镜与当前数据进行比较,如果有不同,说明数据被当前全局事务之外的动作做了修改。这种情况,需要根据配置策略来做处理,详细的说明在另外的文档中介绍。
  • 根据 UNDO LOG 中的前镜像和业务 SQL 的相关信息生成并执行回滚的语句
  • 提交本地事务。并把本地事务的执行结果(即分支事务回滚的结果)上报给 TC。

二阶段-提交

服务端:

  • 一阶段完成后,事务发起方TM向服务端TC发起全局事务提交请求
  • TC根据XID找到全局事务,加锁关闭全局事务,将事务状态从begin改为commiting
  • 分支类型均为AT类型,服务端进行异步提交,修改全局事务状态为异步提交(AsyncCommitting),加入异步提交管理器
  • 定时线程器去查询出待提交的全局事务去提交,发送分支提交请求。

2021-01-08 Seata - 图7

客户端:

  • 接收到服务端发送的 branch commit 请求,根据请求参数找到资源管理器RM
  • 将分支请求插入一个队列
  • 定时线程池去队列查找相应的请求,删除回滚日志。

2021-01-08 Seata - 图8

2021-01-08 Seata - 图9

  • 收到 TC 的分支提交请求,把请求放入一个异步任务的队列中,马上返回提交成功的结果给 TC。
  • 异步任务阶段的分支提交请求将异步和批量地删除相应 UNDO LOG 记录。

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

以一个示例来说明:

两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。

tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。 tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁

2021-01-08 Seata - 图10

tx1 二阶段全局提交,释放 全局锁 。tx2 拿到 全局锁 提交本地事务。

2021-01-08 Seata - 图11

如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。

此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。

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

读隔离

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

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

2021-01-08 Seata - 图12

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

优点
  • 使用简单,学习成本低,对业务无入侵,对于AT模式来说,只需一个注解就可以实现分布式事务。
  • 可通过HA-Cluster保证高可用。
  • 灵活,拓展性高,配置,服务发现和注册,全局锁,可由用户自己实现。

缺点
  • Seata的引入全局锁会额外增加死锁的风险。

TCC模式(业务入侵)

TCC 模式需要用户根据自己的业务场景实现 Try、Confirm 和 Cancel 三个操作;事务发起方在一阶段执行 Try 方式,在二阶段提交执行 Confirm 方法,二阶段回滚执行 Cancel 方法。

  • Try:资源的检测和预留;(一阶段)
  • Confirm:执行的业务操作提交;要求 Try 成功 Confirm 一定要能成功;(二阶段)
  • Cancel:预留资源释放;(阶段)

用户接入 TCC 模式,最重要的事情就是考虑如何将业务模型拆成 2 阶段,实现成 TCC 的 3 个方法,并且保证 Try 成功 Confirm 一定能成功。相对于 AT 模式,TCC 模式对业务代码有一定的侵入性,但是 TCC 模式无 AT 模式的全局行锁,TCC 性能会比 AT 模式高很多。

配置

······

部署

······