为什么选择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调用拓扑图:
上图中,提供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等操作就无从谈起,也就不可能有什么分布式事务了
一次完整的调用会经历的生命流程
整体过程就体现在下面小小的一段代码中
- 开启全局事务,创建一个新的GlobalSession,并生成全局唯一的xid
- 执行真正的业务逻辑
- 全局事务中,又一个发生异常,就会调用所有方法的rollback方法
- 全局事务全部成功,那么调用所有方法的commit方法
那么系统是怎么知道调用哪台机器上的rollback方法或commit方法的呢?
如上所述,3和4是如何具体落实的呢?
这里要知道,seata的tcc实现使用到了两个切面:
- 一个是GlobalTransactionalInterceptor,用来解析@GlobalTransactional注解,并且生成GlobalSession,在SessionManager维护xid和GlobalSession的关系。
- 一个是TccActionInterceptor,用来解析@TwoPhaseBusinessAction注解,并且将本地commit方法、rollback方法、用户自定义的上下文参数值、机器IP汇报给TC,在TC端生成BranchSession,维护GlobalSession和BranchSession的一对N的映射关系。
概览一下这个过程:
- 负责发起GlobalTransaction的TM在整个流程下来,都没有遇到异常后,向TC发起全局事务提交请求
- TC收到全局事务提交请求后,通过xid找出GlobalSession,再通过GlobalSession找出所有所有关联的BranchSession
- 遍历所有BranchSession,通过resourceId和clientId,来定位到channel(容错机制上面已经讲了),进一步找到RpcContext,来找到要执行的二阶段commit方法,和客户端建立通信
- 客户端获知自己要执行哪个commit方法后,通过反射来执行,并向TC汇报方法的执行情况
- 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模式分布式事务原理、源码分析