文章目录
背景介绍
分布式事务介绍
举一个例子,一个人从账户A向账户B转账100块钱,那么就有如下情况
微服务 A :table_a.money = table_a.money - 100
微服务 B :table_b.money = table_b.money + 100
这时候这两个微服务必须同时成功或者同时失败,如果一个成功一个失败的话就会出现问题。
两阶段提交(2PC)
两阶段提交方案是一个典型的解决分布式事务问题的方案。其核心思想在于引入一个 TC(协调者),负责协调各参与者。如下图所示,如果两个微服务在各自本地都执行成功,那么两个微服务将本地成功的信息通知给 TC。
TC 得到所有分支事务的成功消息后,作出全局提交的通知,如下图所示。如果任何一个分支事务执行失败,那么 TC 作出全局回滚的决策,通知所有分支事务完成本地数据库的回滚操作。
项目设计
采用一阶段提交方案,并修改中间件中全局事务的状态,配合在各本地数据库添加 mvcc 表,以实现 Reapeatable Read 隔离级别。
整体框架图
项目中的角色分为以下几种:
- Transaction Coordinate (TC) :事务协调器,根据所有本地事物的结果做出决策,发起全局提交或全局回滚的决议,同时维护全局所有事务的状态(运行中/已提交/已回滚),生成快照提供给各分支事务做可见性判断
- Resource Manager(RM):负责各微服务本地分支事物的相关处理
- Business:模拟用户使用业务
- service a:负责服务 A 的本地事物
- service b:负责服务 B 的本地事物
以项目的开启为例,其架构图如下所示
底下我们来仔细讲解一下各个步骤(以 RR 隔离级别为例):
- 用户请求要求开启业务,此时 service a 向 TC 发起一个全局事务开启请求;
- TC 注册一个全局事务号(事物组号)xid 发送给 service a;此时 service a 通过 rpc 调用 service b 的逻辑,并将全局事务号 xid 传给 service b;
- service a 向 TC 注册自己的分支事务;
- TC 向 service a 发送其分支事物号 branch_id;
- TC 向 service a 发送当前的全局事务快照,用于作可见性判断;
- service a 执行本地分支事物并提交,告诉 TC 本地分支事务的提交结果(成功/失败)
注意: service b 告诉 TC 这是当前全局事务的最后一个分支事物,其余流程和 service a 类似,这里不再详述。
此时,所有本地事务提交完成,并将提交结果告诉TC,如下图所示。
- service a 和 service b 完成本地的分支事物;
- service a 和 service b 将本地事务的执行结果告知 TC;
- TC 作出全局回滚/全局提交的决议,更新对应全局事务 xid 在 TC 中的状态。
TC 设计
TC 作为中间件,主要包含以下几个模块。
注册模块
用于注册全局事务和分支事务。
以上图为例,当收到 service a 的开启全局事务消息后,则开启一个全局事务,并创建一个全局事务号 xid 提供给各微服务。
当微服务来注册分支事务时,TC 将生成分支事务号 branch_id 提供给各微服务。
xid 设计
这里的 xid 是一个 64 位的无符号整数,并且保留 3 个 xid 值作为特殊标记。
- 0:表示无效的 xid
- 1:保留位
- 2:表示冻结的 xid
xid 可以相互比较大小。例如对于 xid=100 的事务,大于 100 的 xid 属于“未来”,是不可见的;小于 100 的 xid 是发生在过去的,是可见的。
因为 xid 在逻辑上是无限的,而实际系统中的 xid 空间不足,因此将 xid 在空间上视为一个环。
对于任意一个 xid ,其过去和未来如上图中的环。从 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ElEciHxX-1613566144387)(https://cdn.nlark.com/yuque/__latex/e55728da1b5ad806d435053e09311247.svg)] 的自然溢出值到 xid ,我们认为发生在过去,是可见的;剩余的则是不可见的。
协调器模块
该模块用于决策全局事务是全局提交还是全局回滚。
当收到所有分支事物的提交结果后,作出全局提交/全局回滚的决策。只要出现 1 个及以上的分支事物提交失败,那么则作出全局回滚的决策,要求各分支事务回滚;否则则发出全局提交的决策。
事务信息模块
该模块用于维护全局事务的信息。
xid 生成
对于每个全局事务,都要求有唯一
的 xid 与其相对应,但是这些请求获得 xid 的服务是并发的。因此,我们需要保证 xid 是原子自增的,不会分配给两个事务相同的 xid。这里可以通过互斥锁来保证并发控制。
全局事务状态维护
维护所有全局事务的活跃性。每次当有全局事务提交/回滚后更新状态。对于所有全局事务,需要维护每一个全局事务的状态,这里可以将其状态放入 leveldb 中存储。其键为 xid,值为其状态,具体如下表所示:
属性值 | 表示状态 | 含义 |
---|---|---|
0 | running | 该全局事务正在运行中 |
1 | commited | 该全局事务已全局提交 |
2 | aborted | 该全局事务已全局回滚 |
当一个全局事务提交/回滚后,TC 将对应的 xid 在 leveldb 中的状态做修改。
而对于运行中的全局事务,我们采用一个链表来维护其 xid。当一个全局事务提交/回滚之后,需要从链表中删除该 xid 。例如当前活跃的事务 xid 为 [22,25,26,29,31],这时候 26 提交了,需要将其从链表中删除并在 leveldb 中存储其状态。链表的具体实现有以下两种思路:
- 需要保证链表是有序的,这样在插入和删除的时候都使用二分查找来降低复杂度,为了实现并发控制,在插入和删除前需要对链表加互斥锁。
- 不要求链表有序,采取无锁队列的方式插入(尾端 CAS),只是同时使用一个哈希表来维护 <xid , 链表节点地址> ,这样在删除的时候可以快速定位到节点。
全局事务快照维护
全局事务快照用于发送给各微服务,在其本地的数据库的 mvcc 表中完成可见性判断。在任意时刻,TC 都可以生成全局事务快照(snapshot)。
生成的全局快照包含以下三部分内容:
- xmin: 最早仍然活跃的事务的 xid。所有比它更早的全局事务(xid < xmin),要么已经提交并可见,要么已经回滚。
- xmax: 第一个尚未分配的 xid。所有 xid ≥ xmax 的事务在获取快照时尚未启动,因此其结果对当前事务不可见。
- xip_list: 获取快照时活跃事务的 xid 列表,仅包括 xmin 与 xmax 之间的 xid。
对于Read Commited
隔离级别,各微服务每条语句执行前都需要向 TC 索要当前全局事务快照;
对于Reapeatable Read
隔离级别,TC 需要在全局事务开始时生成全局事务快照,然后通过 map 来维护每一个 RR 隔离级别的事务快照,其形式为 <xid, snapshot>。在微服务注册分支事务 id 时一块将事务快照发过去,当此全局事务结束后从 map 中删除该键值对。同样的,为了持久化存储,需要将该 map 的内容维护在 db 中。
网络通信模块
通过 rpc 负责和客户端通信。
服务端监听端口,处理连接进来的对话,并新开协程去处理对应操作。
同时提供相关接口方便 RM 与 TC 交互,例如索要全局事务状态和行锁等。
锁模块
为了防止写-写冲突,需要使用 RowExclusiveLock(行锁)。我们可以通过一张表来维护当前哪些全局事务拥有行锁,该表有以下字段:
- xid:标记占有该行锁的全局事务号
- branch_id:标记占有该行锁的分支事务号
- db_name:该行锁所在的数据库名
- table_name:该行锁所在的表名
- row_id:该行锁的所在行的 t_id (下文中会介绍 t_id)
微服务在做增删改操作的时候需要向 TC 先获取行锁,在事务完成全局提交/全局回滚的时候再释放锁。
存储模块
用来将我们的数据持久化,为了防止 TC 重启或宕机导致信息丢失的情况。
将会话信息、全局事务状态、锁的相关信息写入 db 持久化存储,在恢复时按照条件遍历 db 中的所有数据,进行 Session 恢复。
配置模块
配置一些常用的参数比如: 监听端口设置,session 数量最大允许为多少等等。
RM 设计
RM 作为分布式事务的客户端组件,负责处理与 TC 的交互,同时用户 sql 需要经过 RM 改写来实现对应的隔离级别。
注册模块
负责接收业务请求,向 TC 请求开启全局事务和注册分支事务。
事务处理模块
事务处理模块主要功能是改写用户 sql,并将其插入对应的 mvcc 表中以实现对应隔离级别。
mvcc 表设计
对于用户所有新建的表,都在表结构中添加部分字段,以实现分布式事务的 mvcc 控制。对于这种修改后的表,暂称其位 mvcc 表,其表结构如下图所示,灰色部分为用户本身定义的表结构。
每个字段的含义如下图所示
字段名 | 类型 | 含义 |
---|---|---|
t_id | INTEGER | 自增主键,用于标记元组编号 |
t_xmin | BIGINT | 创建该元组的全局事务 id (xid) |
t_xmax | BIGINT | 删除该元组的 xid |
t_ctid | INTEGER | 指向下一个版本元组的指针,值为下一个元组t_id,默认值为0 |
t_infomask | TINYINT | 用作字符掩码 |
userdata | \ | 用户本身定义的表结构字段 |
对于 t_infomask 中的掩码,其前四位分别用作以下标记:
- xminCommited:标记 xmin 所代表的全局事务已提交
- xminAborted:标记 xmin 所代表的全局事务已回滚
- xmaxCommited:标记 xmax 所代表的全局事务已提交
- xmaxAborted:标记 xmax 所代表的全局事务已回滚
用户 sql 改写
对于用户的 SQL ,需要将其改写后执行,而其改写的流程如下图所示。
对用户的 SQL 进行改写,按照规则添加上述字段:
- insert 类型:t_xmin 设置为当前的 xid 值
- update 类型:并不直接修改原元组,而是插入一条新的元组,其 xmin 值为xid,并将旧元组的 t_xmax 设置为当前的 xid,再将旧元组 t_ctid 的值设置为当前元组的 t_id
- delete 类型:并不在表中删除,而是找到对应元组,将 xmax 值设置为 xid。
这里为了防止写-写冲突
,需要先去向 TC 获取当前行的 RowExclusiveLock,如果行锁已被其他事务占有则阻塞,直到释放。
假定增加元组的全局事务 xid 为 100,更新元组的 xid 为 200,删除元组的 xid 为300,则增删改的例子如下图所示。
可见性规则设定
对于分支事务,可以通过 TC 发来的全局事务快照进行可见性判断。但是由于快照中缺少标记该事务是 Commited 还是 Aborted,只能判断其是否已提交。因此可以利用元组中的 t_infomask 字段来进行可见性判断。对于 xid 已经提交但仍未标记 commited/aborted 的元组(更新后第一次被 select 读到),则向 TC 询问该 xid 的状态并将元组中的对应字段置位。这里的修改不需要获取行锁,即使更新失败也不要紧,下一次 select 到的时候再去更新即可。
举个例子,如果全局提交, current_xid == xmin ,那么就把 t_infomask 字段中的第 1 位 (xminCommited) 置为 1,如果是全局回滚,就将第 2 位 (xminAborted) 置为 1。
我们假定当前的全局事务快照的值如下所示:
- 全局最早仍活跃事务 id:xmin
- 第一个尚未分配全局事务 id :xmax
- 活跃事务列表:xip_list
- 当前全局事务 id :current_xid
那么规则可以定义为以下几条(先写这么多,未完待续):
// 对于仅存在 t_xmin 的元组
if t_xmax == 0
if t_xmin < xmin or t_xmin == current_xid
return true
else
return false
// 对于存在 t_xmax 的元组
if t_xmin < xmin
if t_xmax == current_xid
return false
else if t_xmax >= xmin and t_xmax < xmax
if t_xmax in xip_list
return true
else if t_xmax > xmax
return true
else
return false
else
if t_xmin < xmax and t_xmin not in xip_list
if t_xmin status == ABORTED
return false
else if t_xmax >= xmin and t_xmax < xmax
if t_xmax in xip_list
return true
else
return false
网络通信模块
通过 rpc 负责和 TC 通信,并完成编解码等相关工作。同时提供相关接口方便 RM 与 TC 交互,例如索要全局事务状态和行锁等。
存储模块
为了防止微服务突然宕机或重启丢失数据的情况,RM 同样需要将相关信息存储到 db 持久化。
- 会话信息:与 TC 的连接信息
- 全局事务 xid:存储正在执行的全局事务的 xid,以及其在本地分支事务的提交情况,如果 TC 来询问则可以直接将该分支事务的提交结果告知 TC
配置模块
配置常用参数:数据库地址、端口等信息
流程介绍
这一章节将更加具体地介绍每一部分的详细逻辑。
事务注册流程
当用户发起一个请求调用微服务时,如果微服务标记这是一个全局事务时,RM 将向 TC 发起全局事务注册申请,其具体流程如下,TC 分配一个xid 给用户,并完成自身的持久化存储,并将 xid 返回给 RM 。
对于上图中的第 10 步,RM 得到 xid 后,需要将其存储在自身的数据库中,以便之后做可见性判断。
事务执行流程
在注册完 xid 和 branch id 后,各微服务便可以开始开启自己的分支事务。
查询类型 SQL
下图是以 RC 隔离级别的 select
语句为例的执行流程。
其中第 10 步的筛选结果集,因为每个元组的 t_infomask 字段都能标记该元组的 xmin 和 xmax 的状态,如果 t_infomask 字段已被更新,那么就可以直接读取;否则还需要再次和 TC 交互,询问某个 xid 的状态。第 13 步在结果提交后,可以异步地去更新数据库中元组的 t_infomask 字段。
对于 RR 隔离级别,则是在开启全局事务的时候获取事务快照,在查询过程中不再要求获取快照。
修改类型 SQL
下图是以 RC 隔离级别的 update
语句为例,介绍执行流程。与 select 语句不同,update 语句需要先根据谓词查询指定的元组,再向 TC 获取全局事务快照进行可见性判断,同时取得行锁。第 14 步按照 mvcc 规则完成 update 操作,也就是新增一条元组,再修改旧元组的 xmax。
insert 类型和 delete 类型的操作也大体类似,这里不再详述。
事务提交流程
当一个分支事务在本地提交后,告诉 TC 本地事务的提交结果。当一个全局事务内所有分支事务都提交后, TC 作出全局提交/全局回滚的决策,释放该 xid 所占有的行锁。然后修改该 xid 在 leveldb 中的状态,最后更新全局事务快照。
第 3 步,在本地事务提交成功后,需要将该 xid 对应的状态在本地数据库中置为 commit,否则置为 rollback。如果 TC 未能收到本地分支已提交/回滚的通知,则需要查询数据库所对应分支的提交状况。
异常处理
TC 宕机
对于中间件宕机的情况,由于没有高可用,只能等待 TC 重启。因为 TC 的存储模块将所有信息持久化,只需要按照顺序恢复即可。
重启以后,对于所有正在运行的全局事务,需要向各 RM 询问该事务在本地事务的状况,重新收集提交信息。
RM(微服务) 宕机
由于在 RM 上正在运行的全局事务都做了持久化,可以直接读取到正在运行的全局事务 xid。此时去查找表中是否有该 xid 信息。如果有,说明分支事务在本地提交成功,通知 TC 本地执行成功;否则通知失败。至于分支事务自身的 ACID,通过本机数据库就可以实现。
注:如果某个全局事务在该分支事务上是个只读事务,那么本地表中不会由任何该 xid 的修改信息,会被误判为本地执行失败。但是这不影响正确性,暂时不考虑。