分布式事务

事务基本概念

ACID特性

  1. 原子性:构成事务的所有操作要不全部执行成功,要么全部执行失败,不可能出现部分成功,部分失败的情况
  2. 一致性:在事务执行之前和执行之后,数据完整性约束始终保持一致的状态。(比较重要,在后面章节将重点介绍)
  3. 隔离型:并发执行的两个事务之间互不干扰。
  4. 持久性:事务对数据的更改操作将会被持久化道数据库中。

CAP理论

二将军问题

由于网络的不确定性,会导致一致性问题

基本概念

  1. 一致性:实际应用中往往会将⼀份数据复制多份进⾏存储。⼀致性是指⽤户对数据的更新操作(包括新增、修改和删除),要么在所有的数据副本都执⾏成功,要么在所有的数据副本都执⾏失败。也就是说,⼀致性要求对所有数据节点的数据副本的修改是原⼦操作。所有数据节点的数据副本的数据都是最新的,从任意数据节点读取的数据都是最新的状态。

  2. 可用性:客户端访问数据的时候,能够快速得到响应。需要注意的是,系统处于可⽤性状态时,每个存储节点的数据可能会不⼀致,并不要求应⽤程序向数据库写⼊数据时能够⽴刻读取到最新的数据。也就是说,处于可⽤性状态的系统,任何事务的操作都可以得到响应的结果,不会存在超时或者响应错误的情况。

  3. 分区容忍性:如果只是将存储系统部署并运⾏在⼀个节点上,当系统出现故障时,整个系统将不可⽤。如果将存储系统部署并运⾏在多个不同的节点上,并且这些节点处于不同的⽹络中,这就形成了⽹络分区。此时,不可避免地会出现⽹络问题,导致节点之间的通信出现失败的情况,但是,此时的系统仍能对外提供服务,这就是分区容忍性。

一些组合方式

  1. AP:放弃一致性,追求系统可用性和分区容忍性。实际工作中大部分分布式系统的设计方案。
  2. CP:放弃可用性,追求系统一致性和分区容忍性。跨行转账业务中有应用。
  3. CA:放弃分区容忍性,追求系统一致性和可用性。一般不采用,因为分区容忍性在现在的网络架构下不会被消除。

BASE理论

分布式系统最多只能同时满⾜CAP理论中的两个特性。在实际场景中,⼤部分分布式系统会采⽤AP⽅式,即舍弃⼀致性,保证可⽤性和分区容忍性。但是通常情况下还是要保证⼀致性,这种⼀致性与CAP中描述的⼀致性有所区别:CAP中的⼀致性要求的是强⼀致性,即任何时间读取任意节点的数据都必须⼀致,⽽这⾥的⼀致性指的是最终⼀致性,允许在⼀段时间内每个节点的数据不⼀致,但经过⼀段时间后,每个节点的数据达到⼀致。

基本概念

BASE理论是对CAP理论中AP的⼀个扩展,它通过牺牲强⼀致性来获得可⽤性。BASE理论中的Base是

  • 基本可⽤(Basically Available)
  • 软状态(Soft State)
  • 最终⼀致性(Eventually Consistent)

当系统出现故障时,Base理论允许部分数据不可⽤,但是会保证核⼼功能可⽤;允许数据在⼀段时间内不⼀致,这种状态称为“软状态”,但是经过⼀段时间,数据最终是⼀致的。符合Base理论的事务可以称为柔性事务。

一致性分类

参考另一篇文章

分布式事务分类

如下思维导图所示,分为柔性事务和刚性事务,刚性事务满足CP理论,柔性事务满足BASE理论。

按个人理解还可以通过数据库保证、业务保证两个层面进行分类。数据库保证表示数据库内核中实现了分布式事务的强一致性,或者提供一些接口供外部调用来保证一致性。业务保证表示通过业务层的控制来实现业务层面的一致性,无数据库本身的实现无关,本次介绍的Seata框架就是基于中间件框架去保证一致性。

分布式事务解决方案-Seata

三大组件

image-20231203224216006

  • TM(Transaction Manager):事务管理器。与TC交互,开启、提交、回滚事务,负责AT模式事务的并发控制。
  • RM(Resource Manager):资源管理器。与TC交互,负责分支事务注册和分支事务状态上报。
  • TC(Transaction Coordinator):事务协调器。维护全局事务和分支事务的状态,推进事务两阶段处理。

TC单独作为一个服务部署、TM和聚合服务一起部署、RM和微服务一起部署,如下图。

image-20231203224820472

四大模式:
  • TCC:用户根据自己的业务实现try()、confirm()、cancel()三个接口,事务发起方在一阶段之行try()、在二阶段提交执行confirm()、在二阶段回滚执行cancel()方法。

image-20231203222946253

  • Saga:业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。

image-20231203223045837

  • XA:使用支持XA事务的数据库即可。

image-20231203223211867

  • AT:Seata自创的分布式事务解决方案,业务入侵少,对数据库本身的依赖小,应用最广泛的一种策略。

AT模式详解

假设现在有两个服务,积分服务和余额服务,余额服务负责管理用户的余额并调用积分服务,积分服务负责管理用户的积分,用户充值的时候,两个事务被声明为全局事务,AT模式的执行流程如下图所示。

image-20231203232303000

事务日志管理器

在AT模式中,Seata框架的数据代理源会拦截业务SQL语句,生成包含前镜像和后镜像的事务日志表(undo log),依赖的组件就是事务日志管理器。主要干下面几个事情:

  • 保存事务日志
  • 二阶段回滚调用undo()方法
  • 二阶段回滚后删除事务日志
  • 二阶段提交后批量删除事务日志
  • 根据创建时间删除事务日志

以MySQL为例,创建的undo log表结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;

其中rollback_info字段记录了修改字段的前像和后像:

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
{
"@class": "io.seata.rm.datasource.undo.BranchUndoLog",
"xid": "192.168.181.2:8091:4386660905323926065",
"branchId": 4386660905323926071,
"sqlUndoLogs": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.undo.SQLUndoLog",
"sqlType": "INSERT",
"tableName": "t_order",
"beforeImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords$EmptyTableRecords",
"tableName": "t_order",
"rows": ["java.util.ArrayList", []]
},
"afterImage": {
"@class": "io.seata.rm.datasource.sql.struct.TableRecords",
"tableName": "t_order",
"rows": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.sql.struct.Row",
"fields": ["java.util.ArrayList", [{
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "id",
"keyType": "PRIMARY_KEY",
"type": 4,
"value": 31
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "order_no",
"keyType": "NULL",
"type": 12,
"value": "63098e74e93b49bba77f1957e8fdab39"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "user_id",
"keyType": "NULL",
"type": 12,
"value": "1"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "commodity_code",
"keyType": "NULL",
"type": 12,
"value": "C201901140001"
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "count",
"keyType": "NULL",
"type": 4,
"value": 50
}, {
"@class": "io.seata.rm.datasource.sql.struct.Field",
"name": "amount",
"keyType": "NULL",
"type": 8,
"value": 100.0
}]]
}]]
}
}]]
}
  • insert操作,前镜像为空,后镜像为插入后数据
  • delete操作,前镜像为删除前操作,后镜像为空
  • update操作,前镜像为更新前操作,后镜像为更新后操作。

前镜像和后镜像结合表元数据,就能执行基于业务的回滚操作了(表元数据通过seata执行一条查询语句从数据库中获得,包括主表和索引元数据)

上面的undo log是seata自动进行操作的,用户只需要在调用的方法上加一个注解。seata中使用包装类将执行SQL语句的数据源进行代理,在SQL执行前后、事务commit、rollback执行前后进行了一些与seata分布式事务相关的操作如分支事务注册、分支状态汇报、全局锁查询、事务日志插入等。

从上面可以看出,如果是一个批量插入或者删除的SQL,其产生的rollback_info是非常大的,所以seata可以配置Bzip2、Gzip、lz4等压缩算法进行压缩。

在本地事务提交之前,seata会把undo插入到数据库中,插入完成之后才会进行事务提交,因此只要事务提交了就一定有undo,如果数据库在undo插入之后,事务提交之前宕机了,残留的undo log也会通过过期时间进行清理。如果因为网络二将军问题,在收到二阶段回滚命令之后TC并没有收到ACK,那么由于在执行回滚之后会立即删除undo log,因此第二次执行回滚检查undo log不存在后会停止回滚。

AT的隔离级别

分布式事务的默认隔离级别都是读未提交,分布式事务的脏读和数据库的脏读在语义上不同,前者在业务中对业务是没有影响的,后者是数据库默认需要避免的。如果在实际生产环境中确实需要使用读已提交的分布式事务隔离级别,例如现在有一个广告业务,扫描订单表进行推送,当一个订单表的全局事务在本地提交之后,广告业务扫到了,但订单表的TC决定回滚,此时发生分布式事务的脏读,此时需要加入全局锁来提升隔离级别。

注意seata目前支持的SQL类型就是IUD+select…for update四种,其中select…for update默认需要申请全局锁,工作在读已提交隔离级别下面。

行锁的格式为“表名:主键1:主键2”,如果表不支持主键怎么办?Seata不支持不带主键。

AT的两阶段提交
  1. 一阶段

image-20231204224341291

以update students set name=’zhangsan’ where name =’lisi’;为例,流程如下:

  • 解析SQL语句,得到SQL类型为update、表名为student,where 条件为name=’lisi’;
  • 开启一个数据库本地事务;
  • 执行select id from name where name=’list’;查找前镜像;
  • 执行原始SQL语句;
  • 执行select id,* from name where id=xxx,查找后镜像;
  • 生成事务日志和事务锁数据
  • 注册分支事务
    • 如果全局锁冲突,回滚本地事务,在休眠一段时间后重新执行
    • 无冲突则分支事务注册成功
  • 提交本地事务
  • 向事务协调器汇报分支状态
  1. 二阶段

image-20231204232143355

这里为了性能考虑,如果提交成功不会立刻删除中间数据,而是立刻返回成功,中间数据在异步线程中进行删除。

如果提交失败,需要进行回滚操作,这里需要先对比后镜像是不是和现在数据库里面的数据一致,如果一直执行回滚,如果不一致说明发生了脏写,这个时候需要人工干预了。

AT模式的全局锁

如果没有全局锁会发生什么?

image-20231204235132627

在seata分支事务中,锁处理的流程如下:

  • 开启数据库本地事务,获取数据库锁,这个时候可以修改数据库数据,但不能修改事务信息;
  • 通过事务协调器,获取全局锁,意味着可以修改数据并持久化。
  • 提交本地事务,释放数据库锁。
  • 在全局事务提交或者回滚之后是否全局锁。

下面直接看一个例子来理解:

事务1和事务2都要对表的m字段进行变更

image-20231204234000587

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

image-20231204234150615

如果tx1二阶段回滚,此时tx1修改数据库数据需要本地锁,而tx2需要全局锁提交事务,发生死锁,seata规定分支事务回滚会一直重试,直到tx2获取全局锁超时,发生回滚释放本地锁。特殊情况是当用户使用select…for update的时候,也需要申请全局锁,当查询的数据正在被更新则被阻塞。

image-20231204234541621

那么这个全局锁到底存放在哪,通过什么管理的?seata有三种方式:

  • 基于数据库
  • 基于文件实现
  • 基于Redis实现

基于数据库的实现

seata搞了一个强制放锁的逻辑,超过一定时间之后,允许别的事务抢锁,因此在释放锁的时候如果发现锁的持有者不是自己则不能释放。