SpringCloud-分布式事务Seate-AT

Seata 是一款开源的分布式事务解决方案,致力于在微服务架构下提供高性能和简单易用的分布 式事务服务。在 Seata 开源之前,Seata 对应的内部版本在阿里经济体内部一直扮演着分布式一 致性中间件的角色,帮助经济体平稳的度过历年的双11,对各BU业务进行了有力的支撑。经过多 年沉淀与积累,商业化产品先后在阿里云、金融云进行售卖。2019.1 为了打造更加完善的技术生 态和普惠技术成果,Seata 正式宣布对外开源,未来 Seata 将以社区共建的形式帮助其技术更加 可靠与完备。

Seata 将 为用户提供了 AT、TCC、SAGA 和 XA 事务模式,为用户打造一站式的分布式解决方案。

1、基本流程

Seata AT模式实际上是2PC协议的一种演变方式,也是通过两个阶段的提交或者回滚来保证多节点事务的一致性。

  • 第一个阶段, 应用系统会把一个业务数据的事务操作和回滚日志记录在同一个本地事务中提交, 在提交之前,会向TC(seata server)注册事务分支,并申请针对本次事务操作的表的全局锁。

    接着提交本地事务,本地事务会提交业务数据的事务操作以及UNDO_LOG,放在一个事务中提交。

  • 第二个阶段,这一个阶段会根据参与到同一个XID下所有事务分支在第一个阶段的执行结果来决定事务的提交或者回滚,这个回滚或者提交是TC来决定的,它会告诉当前XID下的所有事务分支,提交或者回滚。

    • 如果是提交, 则把提交请求放入到一个异步任务队列,并且马上返回提交成功给到TC,这样可以避免阻塞问题。而这个异步任务,只需要删除UNDO_LOG就行,因为原本的业务事务已经提交了。
    • 如果是回滚,则开启一个本地事务,执行以下操作
      • 通过XID和Branch ID查找到响应的UNDO_LOG记录
      • 数据校验,拿到UNDO_LOG中after image(修改之后的数据)和当前数据进行比较, 如果有不同,说明数据被当前全局事务之外的动作做了修改,这种情况需要根据配置策略来做处理。
      • 根据UNDO_LOG中的before image和业务SQL的相关信息生成并执行回滚语句
      • 提交本地事务,并把本地事务的执行结果上报给TC

2、基本使用

seata提供了各种环境中使用的样例,本文样例基于spring-cloud-alibaba

2.1 seata server部署

下载seata-server

seata-server

修改配置文件

1
2
3
4
5
6
7
8
9
10
registry {
# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
type = "nacos"

nacos {
serverAddr = "localhost"
namespace = "piblic"
cluster = "default"
}
}

在实际中遇到问题,注册到nacos上时,默认获取到的是虚拟ip,不能跨网段访问,最终使用file方式配置。

1
2


2.2 seata client端配置

遇到的各种问题汇总:

1、【服务ip不稳定问题】使用dubbo作为微服务框架,服务注册时获取到的是ip地址是127.0.0.1,导致服务间不能互相调用

2、【seata-server注册到nacos,ip不稳定】发布的总是虚拟网段的ip地址,导致外域的服务不能访问seata-server

3、没有配置dubbo.scan属性,导致服务不能被dubbo发布

4、服务超时事件配置太短(或者没有配置),导致在一个调用链总是超时失败

5、数据库配置不正确,导致工程启动总是报一个错误:关键字importRegister

2.3 AT运行原理

AT模式属于强一致事务模型,工作时离不开几个表:

3.1 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,
PRIMARY KEY (id),
UNIQUE KEY ux_undo_log (xid, branch_id)
) ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8;

该表每个业务库需要有一个,用于记录全局事务在提交之前,当前库的事务分支的处理前后的现场

3.2 三表

global_table 存储全局事务

branch_table 存储事务分支

lock_table

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
-- -------------------------------- The script used when storeMode is 'db' ---------------------------------- the table to store GlobalSession data
CREATE TABLE IF NOT EXISTS `global_table`(
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`status` TINYINT NOT NULL,
`application_id` VARCHAR(32),
`transaction_service_group` VARCHAR(32),
`transaction_name` VARCHAR(128),
`timeout` INT,
`begin_time` BIGINT,
`application_data` VARCHAR(2000),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`xid`),
KEY `idx_gmt_modified_status` (`gmt_modified`, `status`),
KEY `idx_transaction_id` (`transaction_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;

-- the table to store BranchSession data
CREATE TABLE IF NOT EXISTS `branch_table`(
`branch_id` BIGINT NOT NULL,
`xid` VARCHAR(128) NOT NULL,
`transaction_id` BIGINT,
`resource_group_id` VARCHAR(32),
`resource_id` VARCHAR(256),
`branch_type` VARCHAR(8),
`status` TINYINT,
`client_id` VARCHAR(64),
`application_data` VARCHAR(2000),
`gmt_create` DATETIME(6),
`gmt_modified` DATETIME(6),
PRIMARY KEY (`branch_id`),
KEY `idx_xid` (`xid`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;

-- the table to store lock data
CREATE TABLE IF NOT EXISTS `lock_table`(
`row_key` VARCHAR(128) NOT NULL,
`xid` VARCHAR(96),
`transaction_id` BIGINT,
`branch_id` BIGINT NOT NULL,
`resource_id` VARCHAR(256),
`table_name` VARCHAR(32),
`pk` VARCHAR(36),
`gmt_create` DATETIME,
`gmt_modified` DATETIME,
PRIMARY KEY (`row_key`),
KEY `idx_branch_id` (`branch_id`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8;

http://www.iocoder.cn/Dubbo/Seata/?self

3、事务隔离

那这种事务的隔离级别是什么样的呢?我们在学习数据库的事务特性时,必须会涉及到的就是事务的隔 离级别,不同的隔离级别,会产生一些并发性的问题,比如

脏读 – A事务读到B事物update后未提交的数据,B事务回滚后,A读到的数据是脏数据

不可重复读 – A事务在第一次读后,B事务修改了数据,A事务继续第二次读,两次数据不一致

幻读 –与脏读类似,脏读针对的是同一条记录的更新,幻读是范围数据多了或少了

我们知道mysql的数据库隔离级别有4种

  • 读未提交 RU – 会出现所有读一致性问题
  • 读已提交 RC – 解决了脏读
  • 可重复读 RR –解决了不可重复度
  • 序列化读
3.1 写隔离

所谓的写隔离,就是多个事务对同一个表的同一条数据做修改的时候,需要保证对于这个数据更新操作的隔离性,在传统事务模型中,我们一般是采用锁(LBCC)的方式来实现。

那么在分布式事务中,如果存在多个全局事务对于同一个数据进行修改,为了保证写操作的隔离,也需要通过一种方式来实现隔离性,自然也是用到锁的方法,具体来说。

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

举一个具体的例子,假设有两个全局事务tx1和tx2,分别对a表的m字段进行数据更新操作,m的初始 值是1000。

  1. tx1先开始执行,按照AT模式的流程,先开启本地事务,然后更新m=1000-100=900。在本地事务 更新之前,需要拿到这个记录的全局锁。
  2. 如果tx1拿到了全局锁,则提交本地事务并释放本地锁。

  1. 接着tx2后开始执行,同样先开启本地事务拿到本地锁,并执行m=900-100的更新操作。在本地事 务提交之前,先尝试去获取这个记录的全局锁。而此时tx1全局事务还没提交之前,全局锁的持有 者是tx1,所以tx2拿不到全局锁,需要等待

接着, tx1在第二阶段完成事务提交或者回滚,并释放全局锁。此时tx2就可以拿到全局锁来提交本地事务。

如果tx1的第二阶段是全局回滚,则tx1需要重新获取这个数据的本地锁, 然后进行反向补偿更新实现事务分支的回滚。此时,如果tx2仍然在等待这个数据的全局锁并且同时持有本地锁,那么tx1的分支事务回滚会失败,分支的回滚会一直重试直到tx2的全局锁等待超时,放弃全局锁并回滚本地事务并释放本地锁之后,tx1的 分支事务才能最终回滚成功。

由于在整个过程中, 全局锁在tx1结束之前一直被tx1持有,所以并不会发生脏写问题。

3.2 读隔离

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

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

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

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