Seata的TCC模式

1,014 阅读10分钟

为什么选择TCC模式

Seata提供了AT模式、TCC模式、Sega模式、XA模式,为什么我要选择TCC模式呢? 首先排除了XA模式了,实际开发过程中,可不只是用数据库的,而且XA是“刚性事务”,性能堪忧。 剩下的几个,其实说不上谁好谁坏。 评价一个分布式事务框架的优劣,往往从一下三个方面来判断:

  • 业务改造成本低
  • 性能损耗低
  • 隔离型保证完整 但这如同CPA理论,你永远无法同时满足以上三点,如果表示成下图:

基于业务补偿的Sega性能好且业务改造成本低但是不支持隔离性,Seata(AT模式)业务改造成本低且满足隔离型但是性能不理想,TCC性能好满足隔离性但是改造成本比较高,得自己定义每个本地事务的prepare、commit、rollback方法。公司做的是大流量的电商业务,性能和隔离性缺一不可,所以我选择TCC。

Seata的整体架构

Seata最为人熟知的是AT模式,但其实Seata也支持TCC模式。 首先看一下设计分布式事务框架普遍要参考的DTP(Distributed Transaction Processing)理论:

  • RM负责本地事务的提交,同时完成分支事务的注册、锁的判定,扮演事务参与者角色
  • TM负责整体事务的提交与回滚的指令的触发,扮演事务的总体协调者角色。

不同框架在具体实现的时候,会基于这个理论模型做适当的调整,例如TM有的是以jar包形式与应用部署在一起,有的则剥离出来需要单独部署(例如Seata中将TM的主要功能放到一个逻辑上集中的Server上,叫做TC( Transaction Coordinator )) 这么做的好处是依赖解耦,同样的JAR不用在每个应用服务器上放一份,坏处当然是TM、RM在和TC交互的时候,会多出一些网络IO。

所以Seata的整体架构如下图: 其实光看这个图,不看代码的时候,挺让人迷惑的;TC比较好理解,但是RM和TM怎么理解呢?

  • RM全程ResourceManager资源管理器,什么是资源?我们画个RPC调用拓扑图:

image.png 上图中,提供A服务的其中一台服务器A1完成一次事务,需要调用B服务,C服务,以及D服务,每个服务都由若干个生产者,例如B服务就有B1、B2、B3三台机器提供,这三台机器上被调用的这个接口就是一个RM(RM的细分粒度精确到某个IP,某个端口,某个应用的某个接口);而A1作为串联起这次调用的发起者,它掌握着全局事务,就是一个TM的角色。但不是每个RM都会参与到每一次的TM发起全局事务中来,就以B服务为例,如果本次A1调用B服务实际是由B1机器提供的,那么,在B1、B2、B3三个RM中,只有B1参与了这次的全局事务。

此外,A1上的TM发起事务的时候,可能会先调用A1机器自身的文件系统,那么此时这个文件系统就也是一个RM。

Seata中的几个关键id

Seata中关键id有:

  • xid:全局事务ID,对应上面这个例子,A1的TM发起一次全局事务,A1调用B,调用C,调用D,这整个过程中都共用一个xid,在TM发起全局事务的时候生成,放在GlobalSession中。有点类似于分布式链路中的traceId
  • branchId:分支事务ID,对应上面这个例子,A1调用B服务,假设实际走的是B1服务器资源提供的服务,那么B1在被调用的时候,就会向TC事务协调器通过netty通信申请一个branchId,放进BranchSession中。Seata在TC中维护一个GlobalSeesion对应BranchSession的1到N的映射关系。
  • resourceId:资源ID,上文已经解释了什么是资源,落实到代码层面,TCC模式下resourceId就是@TwoPhaseBusinessAction的name属性,由此可知,分布式集群的场景下,resourceId并不能唯一确定某台机器。resourceId放在RM_CHANNELS中,作为定位目标机器方法的第一个筛选条件。但是resourceId和RM_CHANNELS中的其他筛选条件又有所不同,当请求的channel被销毁时,TC会换同一个机器上的其他端口的该resource,如果还是找不到,会再往上层找其他机器上的,还找不到,就会找其他applicationId的机器。但是resourceId确实永远不会妥协的底线,这段获取channel的代码如下:
public static Channel getChannel(String resourceId, String clientId) {
        Channel resultChannel = null;

        String[] clientIdInfo = readClientId(clientId);

        if (clientIdInfo == null || clientIdInfo.length != 3) {
            throw new FrameworkException("Invalid Client ID: " + clientId);
        }

        String targetApplicationId = clientIdInfo[0];
        String targetIP = clientIdInfo[1];
        int targetPort = Integer.parseInt(clientIdInfo[2]);

        ConcurrentMap<String, ConcurrentMap<String, ConcurrentMap<Integer,
            RpcContext>>> applicationIdMap = RM_CHANNELS.get(resourceId);

        if (targetApplicationId == null || applicationIdMap == null ||  applicationIdMap.isEmpty()) {
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("No channel is available for resource[{}]", resourceId);
            }
            return null;
        }

        ConcurrentMap<String, ConcurrentMap<Integer, RpcContext>> ipMap = applicationIdMap.get(targetApplicationId);

        if (ipMap != null && !ipMap.isEmpty()) {
            // Firstly, try to find the original channel through which the branch was registered.
            ConcurrentMap<Integer, RpcContext> portMapOnTargetIP = ipMap.get(targetIP);
            if (portMapOnTargetIP != null && !portMapOnTargetIP.isEmpty()) {
                RpcContext exactRpcContext = portMapOnTargetIP.get(targetPort);
                if (exactRpcContext != null) {
                    Channel channel = exactRpcContext.getChannel();
                    if (channel.isActive()) {
                        resultChannel = channel;
                        if (LOGGER.isDebugEnabled()) {
                            LOGGER.debug("Just got exactly the one {} for {}", channel, clientId);
                        }
                    } else {
                        if (portMapOnTargetIP.remove(targetPort, exactRpcContext)) {
                            if (LOGGER.isInfoEnabled()) {
                                LOGGER.info("Removed inactive {}", channel);
                            }
                        }
                    }
                }

                // The original channel was broken, try another one.
                if (resultChannel == null) {
                    for (ConcurrentMap.Entry<Integer, RpcContext> portMapOnTargetIPEntry : portMapOnTargetIP
                        .entrySet()) {
                        Channel channel = portMapOnTargetIPEntry.getValue().getChannel();

                        if (channel.isActive()) {
                            resultChannel = channel;
                            if (LOGGER.isInfoEnabled()) {
                                LOGGER.info(
                                    "Choose {} on the same IP[{}] as alternative of {}", channel, targetIP, clientId);
                            }
                            break;
                        } else {
                            if (portMapOnTargetIP.remove(portMapOnTargetIPEntry.getKey(),
                                portMapOnTargetIPEntry.getValue())) {
                                if (LOGGER.isInfoEnabled()) {
                                    LOGGER.info("Removed inactive {}", channel);
                                }
                            }
                        }
                    }
                }
            }

            // No channel on the this app node, try another one.
            if (resultChannel == null) {
                for (ConcurrentMap.Entry<String, ConcurrentMap<Integer, RpcContext>> ipMapEntry : ipMap
                    .entrySet()) {
                    if (ipMapEntry.getKey().equals(targetIP)) { continue; }

                    ConcurrentMap<Integer, RpcContext> portMapOnOtherIP = ipMapEntry.getValue();
                    if (portMapOnOtherIP == null || portMapOnOtherIP.isEmpty()) {
                        continue;
                    }

                    for (ConcurrentMap.Entry<Integer, RpcContext> portMapOnOtherIPEntry : portMapOnOtherIP.entrySet()) {
                        Channel channel = portMapOnOtherIPEntry.getValue().getChannel();

                        if (channel.isActive()) {
                            resultChannel = channel;
                            if (LOGGER.isInfoEnabled()) {
                                LOGGER.info("Choose {} on the same application[{}] as alternative of {}", channel, targetApplicationId, clientId);
                            }
                            break;
                        } else {
                            if (portMapOnOtherIP.remove(portMapOnOtherIPEntry.getKey(),
                                portMapOnOtherIPEntry.getValue())) {
                                if (LOGGER.isInfoEnabled()) {
                                    LOGGER.info("Removed inactive {}", channel);
                                }
                            }
                        }
                    }
                    if (resultChannel != null) { break; }
                }
            }
        }

        if (resultChannel == null) {
            resultChannel = tryOtherApp(applicationIdMap, targetApplicationId);

            if (resultChannel == null) {
                if (LOGGER.isInfoEnabled()) {
                    LOGGER.info("No channel is available for resource[{}] as alternative of {}", resourceId, clientId);
                }
            } else {
                if (LOGGER.isInfoEnabled()) {
                    LOGGER.info("Choose {} on the same resource[{}] as alternative of {}", resultChannel, resourceId, clientId);
                }
            }
        }

        return resultChannel;

    }
  • clientId:结构是ApplicationId:IP:Port,clientId存在于RpcContext中,在TC的ChannelManager中维护了一个Map<Channel, RpcContext>的全局变量,当客户端向TC进行通信的时候,可以用通信Channel来找到RpcContext,从而取出clientId。RpcContext是在客户端启动和TC建立netty通信上报本地资源Resource信息的时候生成的。

Seata源码中的关键组件

  • TCCResourceCache:和几乎所有分布式框架一样,seata中的resource信息,在本地和担任中央管理器角色的TC中都会保存一份,TCCResourceCache就是本地保存的resource信息,
  • RpcContext和RM_CHANNELS:和TCCResourceCache相对的,RpcContex和RM_CHANNELS是TC中保存的resource信息,RM_CHANNELS的是一个多层的键值对的映射表,结构为resourceId -> applicationId -> ip -> port -> RpcContext,可以猜想到,后续TC发起确认或回滚的时候,可以通过这个键值对,一层层找到具体应该调用哪个IP的哪个端口上的哪个接口方法。
  • TransactionInfo:包装了@GlobalTransactional注解中的属性
  • GlobalSession:全局事务的session,由全局事务的发起者创建,创建的同时会生成全局的xid并存入GlobalSession中。BranchSession维护在TC中
  • BranchSession:运行时生成的,具体调用到某台机器上的某个方法的时候生成的,所以这个时候xid、resourceId,包括调用到的是哪台机器,都是知晓的,这为后续使用xid查询到应该调用哪台机器上的哪个commit方法\rollback方法提供了基础。可以说,BranchSession是串联起xid、branchId、resourceId、clientId的桥梁。BranchSession维护在TC中。GlobalSession和BranchSession的关系是一对多。

看Seata过程中关键注解

  • @LocalTCC:不一定需要,也不一定要和@TwoPhaseBusinessAction配合使用,@LocalTCC的战术地位和dubbo接口、sofa接口、HSF接口是一样的,是为了让服务启动的时候,本地资源能够注册到TC中。
  • @TwoPhaseBusinessAction:是必须的,没有这个注解,就无法作为resource注册到TC中
  • @GlobalTransactional:是必须的,没有这个,方法就无法被切面MethodInterceptor(GlobalTransactionalInterceptor)拦截到,后续的生成xid等操作就无从谈起,也就不可能有什么分布式事务了

一次完整的调用会经历的生命流程

整体过程就体现在下面小小的一段代码中

  1. 开启全局事务,创建一个新的GlobalSession,并生成全局唯一的xid
  2. 执行真正的业务逻辑
  3. 全局事务中,又一个发生异常,就会调用所有方法的rollback方法
  4. 全局事务全部成功,那么调用所有方法的commit方法

那么系统是怎么知道调用哪台机器上的rollback方法或commit方法的呢?

如上所述,3和4是如何具体落实的呢?

这里要知道,seata的tcc实现使用到了两个切面:

  • 一个是GlobalTransactionalInterceptor,用来解析@GlobalTransactional注解,并且生成GlobalSession,在SessionManager维护xid和GlobalSession的关系。
  • 一个是TccActionInterceptor,用来解析@TwoPhaseBusinessAction注解,并且将本地commit方法、rollback方法、用户自定义的上下文参数值、机器IP汇报给TC,在TC端生成BranchSession,维护GlobalSession和BranchSession的一对N的映射关系。

概览一下这个过程:

  1. 负责发起GlobalTransaction的TM在整个流程下来,都没有遇到异常后,向TC发起全局事务提交请求
  2. TC收到全局事务提交请求后,通过xid找出GlobalSession,再通过GlobalSession找出所有所有关联的BranchSession
  3. 遍历所有BranchSession,通过resourceId和clientId,来定位到channel(容错机制上面已经讲了),进一步找到RpcContext,来找到要执行的二阶段commit方法,和客户端建立通信
  4. 客户端获知自己要执行哪个commit方法后,通过反射来执行,并向TC汇报方法的执行情况
  5. TC根据每一个Branch的执行情况和配置,来决定重试或最终完成。(默认重试5次)

再次整理一下,哪些是事务执行前生成,哪些是事务执行时生成

  • resource相关的东西都是事务执行前就生成的,每个资源的ResourceManager都会在服务启动的时候,就向TC汇报自己的相关信息,并在本地和TC中都保存一份信息。相当于rpc框架中的服务注册和动态发现的实现,服务提供方向zk注册以及消费者动态拉取zk上的注册信息,信息在本地和zk上都会保存一份。实际上,分布式框架很多地方都是共通的。

其实,RM注册这一步,最重要的意义在于容错,当TC发起commit或rollback请求的时候,某个节点的channel挂掉,可以换其他节点上的相同resource方法。

  • GlobalSessioin、xid;BranchSession、branchId;每一个方法的入参值;这些都是事务执行时,由切面去实时解析获取的。大致逻辑就是被调用到的方法会被TccActionInterceptor切面切到,然后执行方法的机器将方法信息、commit方法、rollback方法、用户自定义的上下文参数值、机器IP通过netty通信汇报给TC。xid和GlobalSession的映射关系维护在SessionManager中。

最后留一些问题

1、GlobalSession信息和BranchSession, xid和GlobalSession信息等,放在本地缓存或者数据库中,越来越多怎么办?又不敢删。

源码解析可以参考 Seata实战-TCC模式分布式事务原理、源码分析