分布式事务-Seata

831 阅读12分钟

Seata 简介

2019 年 1 月,阿里巴巴中间件团队发起了开源项目 Fescar(Fast & Easy Commit And Rollback),其愿景是让分布式事务的使用像本地事务的使用一样,简单和高效,并逐步解决开发者们遇到的分布式事务方面的所有难题。Fescar 开源后,蚂蚁金服加入 Fescar 社区参与共建,并在 Fescar 0.4.0 版本中贡献了 TCC 模式。为了打造更中立、更开放、生态更加丰富的分布式事务开源社区,经过社区核心成员的投票,决定对 Fescar 进行品牌升级,于 2019 年 5 月 开始更名为 Seata,意为:Simple Extensible Autonomous Transaction Architecture,是一套一站式分布式事务解决方案,为用户提供了 AT、TCC、SAGA 和 XA 事务模式。Seata 融合了阿里巴巴和蚂蚁金服在分布式事务技术上的积累,并沉淀了新零售、云计算和新金融等场景下丰富的实践经验,但要实现适用于所有的分布式事务场景的愿景,仍有很长的路要走。

更多介绍可参考:

Seata 项目:github.com/seata/seata

Seata 官方示例代码:github.com/seata/seata…

Seata 官方中文文档:seata.io/zh-cn/docs/…

Seata 演进历史

TXC:Taobao Transaction Constructor,阿里巴巴中间件团队自 2014 年起启动该项目,以满足应用程序架构从单一服务变为微服务所导致的分布式事务问题 GTS:Global Transaction Service,2016 年 TXC 作为阿里中间件的产品,更名为 GTS 发布 FESCAR:2019 年开始基于 TXC/GTS 开源 FESCAR SEATA:2019 年 5 月 FESCAR 更名为 SEATA Seata 设计理念 Seata 的设计目标是对业务无侵入,因此从业务无侵入的 2PC 方案着手,在传统 2PC 的基础上演进。它把一个分布式事务理解成一个包含了若干分支事务的全局事务。全局事务的职责是协调其下管辖的分支事务达成一致,要么一起成功提交,要么一起失败回滚。此外,通常分支事务本身就是一个关系型数据库的本地事务。

Seata 的三大组件

  • TC:Transaction Coordinator 事务协调器,维护全局和分支事务的状态,负责协调并驱动全局事务的提交或回滚
  • TM:Transaction Manager 事务管理器,控制全局事务的边界,负责开启一个全局事务,并最终发起全局提交或全局回滚的决议
  • RM:Resource Manager 资源管理器,管理分支事务处理的资源,向 TC 注册分支事务,上报分支事务的状态,接受 TC的命令来提交或者回滚分支事务

Seata 的执行流程

  1. A 服务的 TM 向 TC 申请开启一个全局事务,TC 就会创建一个全局事务并返回一个唯一的 XID
  2. A 服务的 RM 向 TC 注册分支事务,并将其纳入 XID 对应全局事务的管辖
  3. A 服务执行分支事务,向数据库执行操作
  4. A 服务开始远程调用 B 服务,此时 XID 会在微服务的调用链上传播
  5. B 服务的 RM 向 TC 注册分支事务,并将其纳入 XID 对应的全局事务的管辖
  6. B 服务执行分支事务,向数据库执行操作
  7. 全局事务调用链处理完毕,TM 根据有无异常向 TC 发起全局事务的提交或者回滚
  8. TC 协调其管辖之下的所有分支事务,决定是否回滚

seata 实现的 2PC 与传统 2PC 的区别

架构层次方面:传统 2PC 方案的 RM 实际上是在数据库层,RM 本质上就是数据库自身,通过 XA 协议实现,而 Seata 的RM 是以 Jar 包的形式作为中间件层部署在应用程序这一侧的 两阶段提交方面:传统 2PC 无论第二阶段的决议是 Commit 还是 Rollback,事务性资源的锁都要保持到 Phase2完成才释放。而 Seata 的做法是在 Phase1 就将本地事务提交,这样就可以省去 Phase2 持锁的时间,整体提高了效率

AT 模式

前提

  • 基于支持本地 ACID 事务的关系型数据库。
  • Java 应用,通过 JDBC 访问数据库。

整体机制

两阶段提交协议的演变:

  • 一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源。

  • 二阶段:

    • 提交异步化,非常快速地完成。
    • 回滚通过一阶段的回滚日志进行反向补偿。

写隔离

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

以一个示例来说明:

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

读隔离

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

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

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

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

工作机制

以一个示例来说明整个 AT 分支的工作过程。

业务表:product

FieldTypeKey
idbigint(20)PRI
namevarchar(100)
sincevarchar(100)

AT 分支事务的业务逻辑:

update product set name = 'GTS' where name = 'TXC';

一阶段

过程:

  1. 解析 SQL:得到 SQL 的类型(UPDATE),表(product),条件(where name = 'TXC')等相关的信息。
  2. 查询前镜像:根据解析得到的条件信息,生成查询语句,定位数据。
select id, name, since from product where name = 'TXC';

得到前镜像:

idnamesince
1TXC2014
  1. 执行业务 SQL:更新这条记录的 name 为 'GTS'。
  2. 查询后镜像:根据前镜像的结果,通过 主键 定位数据。
select id, name, since from product where id = 1;

得到后镜像:

idnamesince
1GTS2014
  1. 插入回滚日志:把前后镜像数据以及业务 SQL 相关的信息组成一条回滚日志记录,插入到 UNDO_LOG 表中。
{
	"branchId": 641789253,
	"undoItems": [{
		"afterImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "GTS"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"beforeImage": {
			"rows": [{
				"fields": [{
					"name": "id",
					"type": 4,
					"value": 1
				}, {
					"name": "name",
					"type": 12,
					"value": "TXC"
				}, {
					"name": "since",
					"type": 12,
					"value": "2014"
				}]
			}],
			"tableName": "product"
		},
		"sqlType": "UPDATE"
	}],
	"xid": "xid:xxx"
}
  1. 提交前,向 TC 注册分支:申请 product 表中,主键值等于 1 的记录的 全局锁 。
  2. 本地事务提交:业务数据的更新和前面步骤中生成的 UNDO LOG 一并提交。
  3. 将本地事务提交的结果上报给 TC。

二阶段-回滚

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

二阶段-提交

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

环境搭建

环境准备

  • JDK: 8
  • maven:3.5.4
  • Windows:11
  • seata-server:1.4.2
  • nacos-server:2.0.3
  • Mysql : 5.7

下载配置安装

nacos下载地址:github.com/alibaba/nac…

nacos安装配置

nacos安装配置请参考官网

默认nacos不设置密码,后续在配置seata-server和代码中可不用设置nacos密码

Seata-server安装配置

seata-server下载地址:github.com/seata/seata…

seata-server默认是以文件方式存储,本样例采用nacos注册中心和db方式来存储

  1. 解压安装

image.png

  1. 修改配置文件

    进入seata/seata-server-1.4.2/conf目录,修改registry.conf的注册中心和配置中心采用nacos ``registry {`

  ``# file 、nacos 、eureka、redis、zk、consul、etcd3、sofa

  ``type = ``"nacos"

 

  ``nacos {

    ``application = ``"seata-server"

    ``serverAddr = ``"127.0.0.1:8848"

    ``group = ``"SEATA_GROUP"

    ``namespace = ``""

    ``cluster = ``"default"

    ``username = ``""

    ``password = ``""

  ``}

}

 

config {

  ``# file、nacos 、apollo、zk、consul、etcd3

  ``type = ``"nacos"

  ``nacos {

    ``serverAddr = ``"127.0.0.1:8848"

    ``namespace = ``""

    ``group = ``"SEATA_GROUP"

    ``username = ``""

    ``password = ``""

    ``dataId = ``"seataServer.properties"

  ``}

`}``

进入`seata/seata-server-1.4.2/conf`目录,修改`file.conf,采用db模式存储数据`  


注意:mode选择db,修改对应数据库的连接地址和用户名及密码信息

## transaction log store, only used in seata-server

store {

  ``## store mode: file、db、redis

  ``mode = ``"db"

  ``## rsa decryption ``public key

  ``publicKey = ``""

 

  ``## database store property

  ``db {

    ``## the implement of javax.sql.DataSource, such as DruidDataSource(druid)/BasicDataSource(dbcp)/HikariDataSource(hikari) etc.

    ``datasource = ``"druid"

    ``## mysql/oracle/postgresql/h2/oceanbase etc.

    ``dbType = ``"mysql"

    ``driverClassName = ``"com.mysql.jdbc.Driver"

    ``## ``if using mysql to store the data, recommend add rewriteBatchedStatements=``true in jdbc connection param

    ``url = ``"jdbc:mysql://127.0.0.1:3306/seata?rewriteBatchedStatements=true"

    ``user = ``"root"

    ``password = ``"mysql"

    ``minConn = ``5

    ``maxConn = ``100

    ``globalTable = ``"global_table"

    ``branchTable = ``"branch_table"

    ``lockTable = ``"lock_table"

    ``queryLimit = ``100

    ``maxWait = ``5000

  ``}

}

`
`
  1. 在nacos添加seata-server的配置文件

    方法一:
    在较低的版本中,seata-server解压完之后,在conf目录下还有一个config.txt文件,里面记录了Seata启动时必须的初始化参数,但seata-server-1.4.2中并没有这个文件

    config.txt脚本下载地址:github.com/seata/seata…

    如果配置了Nacos作为配置中心,nacos-config.sh下载:github.com/seata/seata…

    方法二:
    方法一这种方式,相对来说是比较复杂的,在seata-server-1.4.2以后的版本中,我们可以在registry.conf中配置配置中心时,就可以通过dataId来创建配置

    registry.conf的nacos配置中心的dataId默认为seataServer.properties,我们在Nacos的控制台就可以直接创建一个这样的配置,其中的配置的内容就是config.txt的内容
    本样例采用的是方法2

image.png #For details about configuration items, see https:``//seata.io/zh-cn/docs/user/configurations.html

#Transport configuration, ``for client and server

transport.type=TCP

transport.server=NIO

transport.heartbeat=``true

transport.enableTmClientBatchSendRequest=``false

transport.enableRmClientBatchSendRequest=``true

transport.enableTcServerBatchSendResponse=``false

transport.rpcRmRequestTimeout=``30000

transport.rpcTmRequestTimeout=``30000

transport.rpcTcRequestTimeout=``30000

transport.threadFactory.bossThreadPrefix=NettyBoss

transport.threadFactory.workerThreadPrefix=NettyServerNIOWorker

transport.threadFactory.serverExecutorThreadPrefix=NettyServerBizHandler

transport.threadFactory.shareBossWorker=``false

transport.threadFactory.clientSelectorThreadPrefix=NettyClientSelector

transport.threadFactory.clientSelectorThreadSize=``1

transport.threadFactory.clientWorkerThreadPrefix=NettyClientWorkerThread

transport.threadFactory.bossThreadSize=``1

transport.threadFactory.workerThreadSize=``default

transport.shutdown.wait=``3

transport.serialization=seata

transport.compressor=none

 

 

#Transaction routing rules configuration, only ``for the client

service.vgroupMapping.default_tx_group=``default

service.vgroupMapping.tx_account_service_group=``default

service.vgroupMapping.tx_order_service_group=``default

service.vgroupMapping.tx_storage_service_group=``default

#If you use a registry, you can ignore it

service.``default``.grouplist=``127.0``.``0.1``:``8091

service.enableDegrade=``false

service.disableGlobalTransaction=``false

 

#Transaction rule configuration, only ``for the client

client.rm.asyncCommitBufferLimit=``10000

client.rm.lock.retryInterval=``10

client.rm.lock.retryTimes=``30

client.rm.lock.retryPolicyBranchRollbackOnConflict=``true

client.rm.reportRetryCount=``5

client.rm.tableMetaCheckEnable=``false

client.rm.tableMetaCheckerInterval=``60000

client.rm.sqlParserType=druid

client.rm.reportSuccessEnable=``false

client.rm.sagaBranchRegisterEnable=``false

client.rm.sagaJsonParser=fastjson

client.rm.tccActionInterceptorOrder=-``2147482648

client.tm.commitRetryCount=``5

client.tm.rollbackRetryCount=``5

client.tm.defaultGlobalTransactionTimeout=``60000

client.tm.degradeCheck=``false

client.tm.degradeCheckAllowTimes=``10

client.tm.degradeCheckPeriod=``2000

client.tm.interceptorOrder=-``2147482648

client.undo.dataValidation=``true

client.undo.logSerialization=jackson

client.undo.onlyCareUpdateColumns=``true

server.undo.logSaveDays=``7

server.undo.logDeletePeriod=``86400000

client.undo.logTable=undo_log

client.undo.compress.enable=``true

client.undo.compress.type=zip

client.undo.compress.threshold=64k

#For TCC transaction mode

tcc.fence.logTableName=tcc_fence_log

tcc.fence.cleanPeriod=1h

 

#Log rule configuration, ``for client and server

log.exceptionRate=``100

 

#Transaction storage configuration, only ``for the server. The file, DB, and redis configuration values are optional.

store.mode=db

store.lock.mode=file

store.session.mode=file

#Used ``for password encryption

store.publicKey=

 

#If `store.mode,store.lock.mode,store.session.mode` are not equal to `file`, you can remove the configuration block.

store.file.dir=file_store/data

store.file.maxBranchSessionSize=``16384

store.file.maxGlobalSessionSize=``512

store.file.fileWriteBufferCacheSize=``16384

store.file.flushDiskMode=async

store.file.sessionReloadReadSize=``100

 

#These configurations are required ``if the `store mode` is `db`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `db`, you can remove the configuration block.

store.db.datasource=druid

store.db.dbType=mysql

store.db.driverClassName=com.mysql.jdbc.Driver

store.db.url=jdbc:mysql:``//127.0.0.1:3306/seata?useUnicode=true&rewriteBatchedStatements=true

store.db.user=root

store.db.password=mysql

store.db.minConn=``5

store.db.maxConn=``30

store.db.globalTable=global_table

store.db.branchTable=branch_table

store.db.distributedLockTable=distributed_lock

store.db.queryLimit=``100

store.db.lockTable=lock_table

store.db.maxWait=``5000

 

#These configurations are required ``if the `store mode` is `redis`. If `store.mode,store.lock.mode,store.session.mode` are not equal to `redis`, you can remove the configuration block.

store.redis.mode=single

store.redis.single.host=``127.0``.``0.1

store.redis.single.port=``6379

store.redis.sentinel.masterName=

store.redis.sentinel.sentinelHosts=

store.redis.maxConn=``10

store.redis.minConn=``1

store.redis.maxTotal=``100

store.redis.database=``0

store.redis.password=

store.redis.queryLimit=``100

 

#Transaction rule configuration, only ``for the server

server.recovery.committingRetryPeriod=``1000

server.recovery.asynCommittingRetryPeriod=``1000

server.recovery.rollbackingRetryPeriod=``1000

server.recovery.timeoutRetryPeriod=``1000

server.maxCommitRetryTimeout=-``1

server.maxRollbackRetryTimeout=-``1

server.rollbackRetryTimeoutUnlockEnable=``false

server.distributedLockExpireTime=``10000

server.xaerNotaRetryTimeout=``60000

server.session.branchAsyncQueueSize=``5000

server.session.enableBranchAsyncRemove=``true

 

#Metrics configuration, only ``for the server

metrics.enabled=``false

metrics.registryType=compact

metrics.exporterList=prometheus

metrics.exporterPrometheusPort=``9898

注意:  
1)修改store.mode=db  
2)修改数据库的连接地址,用户名、密码信息  
3)增加事务组配置,该事务组与代码中需保持一致 

service.vgroupMapping.tx_account_service_group=default` service.vgroupMapping.tx_order_service_group=default service.vgroupMapping.tx_storage_service_group=``default`

  1. 创建数据库文件

    如果存储方式配置的是db类型,那么就需要创建几张表来存储事务信息,sql脚本文件地址,

    github.com/seata/seata…

    本样例中相关sql脚本在工程的对应sql目录下

  2. 启动nacos
    当前目录执行:startup.cmd -m standalone

image.png

  1. 启动seata-server
    执行:seata-server.bat

image.png 访问nacos:http://127.0.0.1:8848/nacos
可以看到我们的seata服务已经注册到nacos中

image.png

业务场景

工程中主要分三个微服务来测试,启动相关工程,注册服务到nacos

seata-at-account-service:账户服务

seata-at-storage-service:订单服务

seata-at-order-service:库存服务

工程关键配置及代码

框架相关版本信息

<java.version>``1.8``</java.version>

<spring-boot.version>``2.6``.``4``</spring-boot.version>

<spring-cloud.version>``2021.0``.``1``</spring-cloud.version>

<spring-cloud-alibaba.version>``2021.0``.``1.0``</spring-cloud-alibaba.version>

<seata.version>``1.4``.``2``</seata.version>

<mybatis-plus.version>``3.5``.``1``</mybatis-plus.version>

maven依赖包

yaml文件中seata配置

order服务业务关键代码

@Transactional``(rollbackFor = Exception.``class``)``@GlobalTransactional``@Override``public void create(Order order) {``    ``Long orderId = SnowflakeUtil.nextId();``    ``order.setId(orderId);``    ``orderMapper.create(order);     ``// 远程调用库存,减少库存``    ``storageClient.decrease(order.getProductId(),order.getCount());``    ``// 远程调用账户,扣减金额``    ``accountClient.decrease(order.getUserId(),order.getMoney());``}

account服务业务关键代码

@Override``@Transactional``public void decrease(Long userId, BigDecimal money) {``    ``accountMapper.decrease(userId, money);``    ``throw new RuntimeException(``"手动跑错"``);``}

此处我们手动抛出异常,看事务是否可回滚

测试链接:

http://localhost:9202/order/create?userId=1&productId=1&count=10&money=100

order数据库中undo_log瞬时记录

{``    ``"@class"``: ``"io.seata.rm.datasource.undo.BranchUndoLog"``,``    ``"xid"``: ``"192.168.77.1:8091:909983323110068351"``,``    ``"branchId"``: ``909983323110068353``,``    ``"sqlUndoLogs"``: [``"java.util.ArrayList"``, [{``        ``"@class"``: ``"io.seata.rm.datasource.undo.SQLUndoLog"``,``        ``"sqlType"``: ``"UPDATE"``,``        ``"tableName"``: ``"storage"``,``        ``"beforeImage"``: {``            ``"@class"``: ``"io.seata.rm.datasource.sql.struct.TableRecords"``,``            ``"tableName"``: ``"storage"``,``            ``"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"``: -``5``,``                    ``"value"``: [``"java.lang.Long"``, ``1``]``                ``}, {``                    ``"@class"``: ``"io.seata.rm.datasource.sql.struct.Field"``,``                    ``"name"``: ``"used"``,``                    ``"keyType"``: ``"NULL"``,``                    ``"type"``: ``4``,``                    ``"value"``: ``0``                ``}, {``                    ``"@class"``: ``"io.seata.rm.datasource.sql.struct.Field"``,``                    ``"name"``: ``"residue"``,``                    ``"keyType"``: ``"NULL"``,``                    ``"type"``: ``4``,``                    ``"value"``: ``100``                ``}]]``            ``}]]``        ``},``        ``"afterImage"``: {``            ``"@class"``: ``"io.seata.rm.datasource.sql.struct.TableRecords"``,``            ``"tableName"``: ``"storage"``,``            ``"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"``: -``5``,``                    ``"value"``: [``"java.lang.Long"``, ``1``]``                ``}, {``                    ``"@class"``: ``"io.seata.rm.datasource.sql.struct.Field"``,``                    ``"name"``: ``"used"``,``                    ``"keyType"``: ``"NULL"``,``                    ``"type"``: ``4``,``                    ``"value"``: ``10``                ``}, {``                    ``"@class"``: ``"io.seata.rm.datasource.sql.struct.Field"``,``                    ``"name"``: ``"residue"``,``                    ``"keyType"``: ``"NULL"``,``                    ``"type"``: ``4``,``                    ``"value"``: ``90``                ``}]]``            ``}]]``        ``}``    ``}]]``}