Seata原理解析

528 阅读12分钟

Seata中的三个角色

TC

Transaction Coordinator: 事务协调者。维护全局和分支事务的状态,驱动全局事务提交或回滚。简单来说,就是seata-server端,是一个独立的服务,业务方往往并不关注。

TM

Transaction Manager: 定义全局事务的范围(开始、提交、回滚),启动时与TC建立连接

RM

Resource Manage: 管理分支事务处理的资源,启动时与TC建立连接。那资源是什么?在不同的模式中,资源有不同的含义,比如在AT模式下,资源就是一个个数据源连接;在TCC模式下,资源可以用过注解定义

当我们引入Seata客户端之后,会同时启动TM和RM

seata

seata_start

交互流程

下面以AT模式为例,介绍Seata的交互流程

假如现在有一个简单场景:有两个应用 A、B,调用链路如下: 前端 http-> A dubbo-> B ,伪代码如下:

A{
  log.info("start")
  B.do
  log.info("end")
}

B{
  DB.insert
}

从这个请求里链路中,可以看出后端的入口在A,所以此时在这个全局事务中,A是TM,即全局事务的发起者;B应用中引入了seata客户端, 对应的是全局事务中的一个分支事务,A B 之间,通过XID关联起来

  1. TM(A)先创建一个全局事务GlobalTransaction
  2. TM(A)开启全局事务: TM通过同步的方式,向TC发送一个开启全局事务的请求GlobalBeginRequest
  3. TC记录全局事务: TC收到TM发送过来的GlobalBeginRequest请求,向global_table表插入一条记录(可以认为global_table表中的每条记录对应一条全局事务,在事务结束后会删除),然后向TM返回该全局事务的唯一标识XID
  4. TM(A)执行业务逻辑: TM收到TC返回的XID,将该XID绑定到上下文中,然后执行业务逻辑,即调用DUBBO服务B。调用B的时候会将XID透传下去
  5. RM(B)接收请求: B接收到请求,然后执行SQL,因为B中用到了seata提供的数据源代理,所以在执行SQL之前,会先进行分支事务注册(向branch_table表插入一条记录),除此之外,在执行业务SQL之前,会解析SQL,生成before快照和after快照,将其当作undo_log,然后将业务SQL和undo_log放在一个本地事务中提交。这里面还涉及到全局锁,这个后面再介绍,先把整体流程介绍好
  6. TC记录分支事务:TC收到RM(B)的分支事务请求,会向branch_table表插入一条记录
  7. TM(B)发起二阶段提交: RM(B)正常执行返回后,A继续执行,A的业务逻辑执行完成之后,然后发起二阶段提交,即向TC发一个全局提交GlobalCommitResponse请求
  8. TC异步提交: TC收到TM的全局提交请求,发现该全局事务下,涉及到的都是分支事务,于是直接返回返回成功,具体的提交逻辑交给后台的定时任务来处理
  9. 事务完成: TM收到TC的响应,发现全局事务提交完成,至此整个事务结束

seat_at

上面介绍的是不报错时的逻辑,如果再中途有异常,会发生什么?

  1. 开启全局事务报错

这个很好处理,直接报错,根本不涉及到事务

  1. 在向RM发送请求之前,TM的业务逻辑报错

TM业务报错之后,TM会向TC发一个回滚请求。TC收到回滚请求,会执行回滚逻辑:根据XID去branch_table表查找相关的分支事务,但因为此时都还没有涉及到RM的调用,所以此从branch_table表中找到的分支事务列表为空,即不需要执行什么回滚操作,此时可以任务和事务没啥关系,只是多了一次向TC发送回滚请求

  1. RM报错

RM报错后,会将异常反馈到TM,TM收到异常,会向TC发一个回滚请求。TC收到回滚请求,根据XID去branch_table表查找相关的分支事务,然后向分支事务对应的RM发送回滚请求。RM收到回滚请求,执行对应的回滚逻辑,然后将反馈给TC,TC再反馈给TM,直到回滚完成

  1. TC单点问题

TC在启动的时候,会将IP注册到注册中心注册。客户端在启动的时候,会向每个TC节点都建立连接。在发送请求的时候,通过负载均衡算法选择性一个TC。因为全局事务和分支事务的状态都保存在DB,所以当某个TC宕机的时候,通过重试机制也将请求发送到其他的TC节点,对事务来说并没有什么影响

  1. TC全挂了

这种情况应该比较少出现吧。假如第一个分支事务提交成功,第二个分支事务在提交的时候,TC宕机了,如何处理?在seata中,每个全局事务都由一个失效时间,默认是一分钟,在TC中有一个定时任务负责对那些过期的事务进行回滚。所以在TC下次重启成功后,会对第一个分支事务进行回滚

  1. 回滚时发现脏数据

接着第5点,在TC重启成功后,对第一个分支事务进行回滚,该RM在回滚时候,发现undo_log中的after快照和表里的值不一样了,如何处理?这时候已经产生脏数据了,没办法,人工处理

核心实现

直接介绍通过引入seata-spring-boot-starter依赖的使用方式,seata-all原理也类似,只不过在使用时,需要手动手动注入相关Bean

SeataAutoConfiguration

自动装配相关,核心代码如下:

@Bean
@DependsOn({BEAN_NAME_SPRING_APPLICATION_CONTEXT_PROVIDER, BEAN_NAME_FAILURE_HANDLER})
@ConditionalOnMissingBean(GlobalTransactionScanner.class)
public GlobalTransactionScanner globalTransactionScanner(SeataProperties seataProperties, FailureHandler failureHandler) {
    if (LOGGER.isInfoEnabled()) {
        LOGGER.info("Automatically configure Seata GlobalTransactionScanner");
    }
    return new GlobalTransactionScanner(seataProperties.getApplicationId(), seataProperties.getTxServiceGroup(), failureHandler);
}
  1. BEAN_NAME_SPRING_APPLICATION_CONTEXT_PROVIDER: 这个对应的Bean是SpringApplicationContextProvider,干嘛用的呢?我们知道seata有两个关键文件,registry.conffile.conf,其实file.conf并不是关键的,仅当回滚日志通过文件的方式存储的时候才需要用到。最关键的是registry.conf文件,该文件中定义了一些关键的配置,比如注册中心的连接信息、日志存储方式、配置中心的连接信息。也就是说,使用seata必须要有这么个文件。SpringApplicationContextProvider的作用就是让我们在springboot应用中不需要写这个配置文件,有了它之后,只需要将相关的配置定义在application.yam中或该springboot应用对应的配置中心就好了,使用起来更方便

  2. BEAN_NAME_FAILURE_HANDLER: 这个对应的Bean是DefaultFailureHandlerImpl,里面有几个关键方法: onBeginFailure onCommitFailure onRollbackFailure 作用看名字就知道了

3、GlobalTransactionScanner: 可以将它理解成客户端的入口类,在里面设计到 RM TM 的初始化,为关键注解 GlobalTransactional 创建代理对象

GlobalTransactionScanner

可以将它理解成客户端的入口类,在里面设计到 RM TM 的初始化,为关键注解 GlobalTransactional 创建代理对象

1.TMRM初始化: 在afterPropertiesSet方法中,主要是是设置好NettyClient相关的配置,然后开启一个定时任务,定时和Server进行连接,核心代码如下

private void initClient() {
    //init TM
    TMClient.init(applicationId, txServiceGroup);

    //init RM
    RMClient.init(applicationId, txServiceGroup);

    // 消息处理
    registerSpringShutdownHook();
}

2.AbstractAutoProxyCreator: 这个类的本质是一个BeanPostProcessorAbstractAutoProxyCreator中实现了代理创建的逻辑,核心方法就是wrapIfNecessary. wrapIfNecessary方法在生命周期函数postProcessAfterInitialization中被调用,postProcessAfterInitialization方法在Bean初始化之后被调用

3.解析GlobalTransactional注解: GlobalTransactional注解在seata中表示一个全局事务的入口。GlobalTransactionScanner用于解析GlobalTransactional注解并为其创建一个切面。GlobalTransactionScanner继承自AbstractAutoProxyCreator抽象类并重写了wrapIfNecessary方法。在该方法中,会解析GlobalTransactional注解TCC相关的注解(这里不多说明)。找到GlobalTransactional注解,然后为带有该注解的Bean创建一个Advisor,即 GlobalTransactionalInterceptor

4.GlobalTransactionalInterceptor: 一个针对GlobalTransactional注解的拦截器。在其invoke方法中,会判断当前执行的方法是否存在GlobalTransactional注解,如果存在,则执行它的handleGlobalTransaction方法,可以把这里当作是事务发起的地方

TransactionalTemplate

GlobalTransactionalInterceptor#handleGlobalTransaction方法,将事务相关的逻辑委托给TransactionalTemplate处理吗,大概逻辑如下:

public Object execute(TransactionalExecutor business) throws Throwable {
    // 1 get transactionInfo
    TransactionInfo txInfo = business.getTransactionInfo();
    // 1.1 get or create a transaction
    GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();

    // 1.2 Handle the Transaction propatation and the branchType
    Propagation propagation = txInfo.getPropagation();
    SuspendedResourcesHolder suspendedResourcesHolder = null;
    try {

        // 2. begin transaction
        beginTransaction(txInfo, tx);

        Object rs = null;
        try {
            // 3. Do Your Business
            rs = business.execute();

        } catch (Throwable ex) {
            // 4. the needed business exception to rollback.
            completeTransactionAfterThrowing(txInfo, tx, ex);
            throw ex;
        }

        // 5. everything is fine, commit.
        commitTransaction(tx);

        return rs;
    } finally {
        //5. clear
        triggerAfterCompletion();
        cleanUp();
    }
}
  1. 创建一个DefaultGlobalTransaction: 如果当前线程上下文中存在XID,则在创建DefaultGlobalTransaction的时候绑定该XID;否则创建一个XID为空的DefaultGlobalTransaction
  2. 开启全局事务: 向TC发送一个GlobalBeginRequest请求,TC收到该请求,会在global_table表中插入一条记录标致该全局事务,默认失效时间60秒,然后返回一个是否开启成功的状态
  3. 执行业务逻辑: 这里分几种情况讨论: 普通业务逻辑(比如单纯的计算)SQL操作RPC调用
    • 普通逻辑: 和事务不相关,你单纯点执行就好了,出错了就抛异常,然后全局事务回滚
    • SQL操作: 如果这时应用中使用了ATXA模式,则涉及到一个分支事务,这其实进入到了RM的概念。以AT模式为例,此时会解析业务SQL,然后生成before快照after快照作为undo_logRM然后向TC发送一个BranchRegisterRequest请求,TC收到该请求会在branch_table表中插入一条记录,代表一个分支事务。RM收到TC的相应后,会将undo_log和业务SQL作为一个本地事务提交,然后返回到TM继续往下执行
    • RPC调用: 以Dubbo调用为例A -> B。A发起RPC调用的时候,将XID透传下去,如果B中使用了ATXA模式,则接下来的逻辑和上面类似
  4. 全局回滚: 执行到步骤4说明抛出异常了,这时候需要全局回滚。向TC发送一个GlobalRollbackRequest请求,TC收到该请求后,根据XID从branch_table表中找到相关的分支事务,然后一一对这些分支事务进行回滚。如何回滚?TC向分支事务对应的RM发送一个BranchRollbackRequest请求,RM收到请求后,执行对应的分支事务回滚逻辑,然后返回给TC(这部分逻辑在AbstractRMHandler#doBranchRollback方法中,想详细了解的可以看看)。TC对所有分支事务回滚完成之后,然后返回给TM。至此,该全局事务结束
  5. 全局提交: 流程和全局回滚类似,只不过全局提交TC端是异步的。首先向向TC发送一个GlobalCommitRequest请求,TC收到该请求收,直接返回成功,然后在TC端有一个定时任务去执分支事务的二阶段提交。至此,该全局事务结束

其实和客户端相关的流程主要就这些,介绍的比较粗糙,具体的可以自己看一看,比如 数据源代理、SQL解析相关。

RegistryService

服务发现相关。这里引入服务发现主要是为了TC的高可用。目前TC的各个节点之间是没有交互的,所以为了实现TC的高可用,事务的状态信息不能保存在本地文件中,需要一个其它的组件来保存事务信息,目前提供的支持有DB和Redis。从下面可以看到,支持的注册中心类型很多

  1. Server在启动的时候向注册中心注册自己的IP
  2. Client在启动的时候,从注册中心获取到Server列表,然后与其一一建立TCP连接
  3. Client发送请求时,根据负载均衡算法从Server列表选择一个进行发送

Configuration

配置中心相关。seata中有非常多个配置,有些作用于Client,有些作用于Server,还有一些Client和Server都能用。seata支持的配置中心类型也很多

seata版本:1.3.0

transport.type=TCP
transport.server=NIO
transport.heartbeat=true
transport.enableClientBatchSendRequest=false
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
service.vgroupMapping.my_test_tx_group=default
service.default.grouplist=127.0.0.1:8091
service.enableDegrade=false
service.disableGlobalTransaction=false
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.sqlParserType=druid
client.rm.reportSuccessEnable=false
client.rm.sagaBranchRegisterEnable=false
client.tm.commitRetryCount=5
client.tm.rollbackRetryCount=5
client.tm.degradeCheck=false
client.tm.degradeCheckAllowTimes=10
client.tm.degradeCheckPeriod=2000
store.mode=file
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
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
store.db.user=username
store.db.password=password
store.db.minConn=5
store.db.maxConn=30
store.db.globalTable=global_table
store.db.branchTable=branch_table
store.db.queryLimit=100
store.db.lockTable=lock_table
store.db.maxWait=5000
store.redis.host=127.0.0.1
store.redis.port=6379
store.redis.maxConn=10
store.redis.minConn=1
store.redis.database=0
store.redis.password=null
store.redis.queryLimit=100
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
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.log.exceptionRate=100
transport.serialization=seata
transport.compressor=none
metrics.enabled=false
metrics.registryType=compact
metrics.exporterList=prometheus
metrics.exporterPrometheusPort=9898

其实这里我对配置中心的使用有点疑惑,为什么要这样设计呢?Server可以是一个独立的应用,它完全可以有自己的配置中心,和Server相关的配置,直接使用应用本身的配置中心就可以了。Client的配置一般是通用的,这时我们一般会将这些相关的配置放到一个公共的namespace里面,对于应用本身,每个应用本身就有一个自己的配置namespace,这时候只需要将Client相关的namespace关联上就可以使用了。这样客户端接入非常方便,公共配置的维护交给中间件维护就好了。

ChannelManager

连接管理相关。连接主要包括两方面: TM和TC的连接RM和TC的连接

1.IDENTIFIED_CHANNELS

ConcurrentMap<Channel, RpcContext> IDENTIFIED_CHANNELS = new ConcurrentHashMap<>();

不管是RM还是TM,与TC建立连接时候,都会在TC端将这份关系维护在IDENTIFIED_CHANNELS中。一个Channel对应一个RpcContext。在registerRMChannel方法中,首先基于appIdresourceIds创建一个RpcContext,然后将该RpcContextChannel维护到IDENTIFIED_CHANNELS

2.RM_CHANNELS

// resourceId -> applicationId -> ip -> port -> RpcContext
ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>>>> RM_CHANNELS= new ConcurrentHashMap<>();

一个资源可能涉及到多个应用,比如数据源连接; 一个应用可能涉及到多个节点(IP); 但是一个应用节点可能涉及到多个Channel?不知道什么情况下会产生这种情况

3.TM_CHANNELS

// appId + ip -> port
ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>> TM_CHANNELS = new ConcurrentHashMap<String, ConcurrentMap<>();

在RM_CHANNELS和TM_CHANNELS中,不是很清楚,为什么要维护一层 port -> RpcContext 的关系

TM与TC建立来连接

核心逻辑在ChannelManager#registerTMChannel方法中

public static void registerTMChannel(RegisterTMRequest request, Channel channel) throws IncompatibleVersionException {
    // 校验TM seata版本
    Version.checkVersion(request.getVersion());

    // 基于 appId 创建一个 RpcContext
    RpcContext rpcContext = buildChannelHolder(NettyPoolKey.TransactionRole.TMROLE, request.getVersion(),
        request.getApplicationId(),
        request.getTransactionServiceGroup(),
        null, channel);
    
    // 维护到 IDENTIFIED_CHANNELS 中
    rpcContext.holdInIdentifiedChannels(IDENTIFIED_CHANNELS);
    String clientIdentified = rpcContext.getApplicationId() + Constants.CLIENT_ID_SPLIT_CHAR
        + ChannelUtil.getClientIpFromChannel(channel);
      
    // 维护到 TM_CHANNELS 中
    TM_CHANNELS.putIfAbsent(clientIdentified, new ConcurrentHashMap<Integer, RpcContext>());
    ConcurrentMap<Integer, RpcContext> clientIdentifiedMap = TM_CHANNELS.get(clientIdentified);

    // 将 port -> RpcContext 的映射,设置到该RpcContext实例中,
    rpcContext.holdInClientChannels(clientIdentifiedMap);
}
  1. 校验TM seata版本
  2. 基于appId创建一个 RpcContext
  3. 将步骤2创建的RpcContext和当前Channel维护到IDENTIFIED_CHANNELS中
  4. 将 port -> RpcContext 的映射,设置到该 RpcContext 实例中,在 RpcContext 中有一个属性 ConcurrentMap<Integer, RpcContext> clientTMHolderMap

RM与TC建立来连接

核心逻辑在ChannelManager#registerTMChannel方法中

public static void registerRMChannel(RegisterRMRequest resourceManagerRequest, Channel channel) throws IncompatibleVersionException {
    // 校验RM seata版本
    Version.checkVersion(resourceManagerRequest.getVersion());

    Set<String> dbkeySet = dbKeytoSet(resourceManagerRequest.getResourceIds());
    RpcContext rpcContext;

    // 如果在IDENTIFIED_CHANNELS中不存在,就添加到IDENTIFIED_CHANNELS中; 如果存在,就更新该Channel对应RpcContext的资源列表
    if (!IDENTIFIED_CHANNELS.containsKey(channel)) {
        rpcContext = buildChannelHolder(NettyPoolKey.TransactionRole.RMROLE, resourceManagerRequest.getVersion(),
            resourceManagerRequest.getApplicationId(), resourceManagerRequest.getTransactionServiceGroup(),
            resourceManagerRequest.getResourceIds(), channel);
        rpcContext.holdInIdentifiedChannels(IDENTIFIED_CHANNELS);
    } else {
        rpcContext = IDENTIFIED_CHANNELS.get(channel);
        rpcContext.addResources(dbkeySet);
    }
    if (dbkeySet == null || dbkeySet.isEmpty()) { return; }

    // 更新 RM_CHANNELS
    for (String resourceId : dbkeySet) {
        String clientIp;
        ConcurrentMap<Integer, RpcContext> portMap = RM_CHANNELS.computeIfAbsent(resourceId, resourceIdKey -> new ConcurrentHashMap<>())
                .computeIfAbsent(resourceManagerRequest.getApplicationId(), applicationId -> new ConcurrentHashMap<>())
                .computeIfAbsent(clientIp = ChannelUtil.getClientIpFromChannel(channel), clientIpKey -> new ConcurrentHashMap<>());

        rpcContext.holdInResourceManagerChannels(resourceId, portMap);

        // 更新RpcContext中的clientRMHolderMap
        updateChannelsResource(resourceId, clientIp, resourceManagerRequest.getApplicationId());
    }

}
  1. 校验RM seata版本
  2. 如果该Channel在IDENTIFIED_CHANNELS中不存在,就添加到IDENTIFIED_CHANNELS中;否则就更新该Channel对应RpcContext的资源列表
  3. 更新RM_CHANNELS,涉及到更新RpcContext中的clientRMHolderMap

感觉这关系看起来有点乱?