原文链接:《阿里分布式事务中间件 Seata 用法与原理》http://www.ytbean.com/posts/seata-intro/
quick-start
案例设计
seata 官方给出了一系列 demo 样例,不过我在用的过程中发现总有这个那个的问题,所以自己维护了一份基于 dubbo 的 demo 在 github 上,适配的 seata 版本是 0.8.0。
案例的设计直接参考官方 quick start给出的案例:
整个案例分为三个服务,分别是存储服务、订单服务和账户服务,这些服务通过 dubbo 进行发布和调用,内部调用逻辑如上面图所示。
整个 demo 的工程样例如下所示:
undo_log 表
这个案例除了在数据库需要建立业务表以外,还要额外建立一张 undo_log 表,这个表的主要作用是记录事务的前置镜像和后置镜像。
全局事务进行到提交阶段,则删除该表对应的记录,全局事务如果需要回滚,则会利用这个表里记录的镜像数据,恢复数据。
undo_log 表里的数据实际上是“朝生夕死”的,数据不需要在表里存活太久。表结构如下所示:
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,
`ext` varchar(100) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
服务逻辑
每个服务都对应了一个 starter 类,这个类主要用来在 spring 环境下,将该服务启动,并通过 dubbo 发布出去,以账户服务为例:
/**
* The type Dubbo account service starter.
*/
public class DubboAccountServiceStarter {
/**
* 2. Account service is ready . A buyer register an account: U100001 on my e-commerce platform
*
* @param args the input arguments
*/
public static void main(String[] args) {
ClassPathXmlApplicationContext accountContext = new ClassPathXmlApplicationContext(new String[]{"spring/dubbo-account-service.xml"});
accountContext.getBean("service");
JdbcTemplate accountJdbcTemplate = (JdbcTemplate) accountContext.getBean("jdbcTemplate");
accountJdbcTemplate.update("delete from account_tbl where user_id = 'U100001'");
accountJdbcTemplate.update("insert into account_tbl(user_id, money) values ('U100001', 999)");
new ApplicationKeeper(accountContext).keep();
}
}
首先通过 ClassPathXmlApplicationContext
读取 dubbo-account-service.xml 这个 spring 配置文件并启动 spring 容器环境,并通过 spring 的 jdbc template 对账户表的数据进行初始化。
dubbo-account-service.xml 配置文件中进行了各类 bean 的配置,包括 dubbo 与 spring 结合时的标准配置:
<bean id="accountDataSourceProxy" class="io.seata.rm.datasource.DataSourceProxy">
<constructor-arg ref="accountDataSource" />
</bean>
<bean id="jdbcTemplate" class="org.springframework.jdbc.core.JdbcTemplate">
<property name="dataSource" ref="accountDataSourceProxy" />
</bean>
<dubbo:application name="dubbo-demo-account-service" />
<dubbo:registry address="zookeeper://localhost:2181" />
<dubbo:protocol name="dubbo" port="20881" />
<dubbo:service interface="io.seata.samples.dubbo.service.AccountService" ref="service" timeout="10000"/>
<bean id="service" class="io.seata.samples.dubbo.service.impl.AccountServiceImpl">
<property name="jdbcTemplate" ref="jdbcTemplate"/>
</bean>
<bean class="io.seata.spring.annotation.GlobalTransactionScanner">
<constructor-arg value="dubbo-demo-account-service"/>
<constructor-arg value="my_test_tx_group"/>
</bean>
这份配置里主要有两个需要引起注意的关键点
- jdbcTemplate 这个 bean 所依赖的数据源 bean,是一个类名为 io.seata.rm.datasource.DataSourceProxy 的数据源类,通过它的名字可以很明显地看出这是一个代理模式的应用,因为 seata 为完成全局事务的逻辑,需要在普通的 sql 操作前后添加一些逻辑,比如说 sql 执行前对 sql 进行语法解析,生成前置镜像,sql 执行后生成后置镜像,通过代理的方式,可以方便地对 connection,statement 等进行代理包装,在调用的时候进行拦截,加入自己的逻辑。
配置文件中还有一个 io.seata.spring.annotation.GlobalTransactionScanner 类型的 bean,这个 bean 是支撑 seata 能在 spring 环境中通过注解的方式来划定事务边界的基础。在 spring 容器启动时,会扫描
@GlobalTransactional
注解是否存在,这个注解标识了全局事务的开始和结束,也就是我们常说的“事务的边界”业务逻辑
业务逻辑的具体详情在
BusinessServiceImpl
类中可以看到:@Override @GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx") public void purchase(String userId, String commodityCode, int orderCount) { LOGGER.info("purchase begin ... xid: " + RootContext.getXID()); storageService.deduct(commodityCode, orderCount); orderService.create(userId, commodityCode, orderCount); // throw new RuntimeException("xxx"); }
先调用存储服务,减少库存,然后调用订单服务,新建订单。这两个动作属于一个整体的事务,任何一个动作失败,都需要撤销所有的操作。
这个方法也有两个需要注意的点:- 该方法上声明了 @GlobalTransactional(timeoutMills = 300000, name = "dubbo-demo-tx") 这样的注解,用于让上文提到的 GlobalTransactionScanner 扫描的时候发现这是一个全局事务。
- 方法的最后有一行代码抛出了 RuntimeException,这主要是为了模仿全局事务的失败,并让 seata 走全局事务回滚逻辑。
事务扫描与边界定义
上文提到的 GlobalTransactionScanner 类,会在 spring 容器启动的时候,也被初始化。
在它的 afterPropertiesSet 方法被调用时,会触发 seata client 的初始化
@Override
public void afterPropertiesSet() {
if (disableGlobalTransaction) {
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Global transaction is disabled.");
}
return;
}
initClient();
}
初始化客户端做的事情主要是建立与 seata server 的连接,并注册 TM 和 RM。接下来,在 wrapIfNecessary 方法里,实现对注解的扫描,并对添加了注解的方法添加 interceptor。
这篇文章里我们暂时不讨论 TCC 模式,只讨论 AT 模式,也暂不讨论全局事务锁 GlobalLock 的实现,先忽略这些有关的逻辑,只关注事务处理逻辑。
Class<?> serviceInterface = SpringProxyUtils.findTargetClass(bean);
Class<?>[] interfacesIfJdk = SpringProxyUtils.findInterfaces(bean);
if (!existsAnnotation(new Class[] {serviceInterface})
&& !existsAnnotation(interfacesIfJdk)) {
return bean;
}
if (interceptor == null) {
interceptor = new GlobalTransactionalInterceptor(failureHandlerHook);
}
在这里,interceptor 的实现是 GlobalTransactionalInterceptor,也就是说,以上文的案例为例子,当 BusinessServiceImpl 的 purchase 方法被调用的时候,实际上这个方法会被拦截器拦截,执行拦截器里的逻辑:
@Override
public Object invoke(final MethodInvocation methodInvocation) throws Throwable {
Class<?> targetClass = (methodInvocation.getThis() != null ? AopUtils.getTargetClass(methodInvocation.getThis()) : null);
Method specificMethod = ClassUtils.getMostSpecificMethod(methodInvocation.getMethod(), targetClass);
final Method method = BridgeMethodResolver.findBridgedMethod(specificMethod);
final GlobalTransactional globalTransactionalAnnotation = getAnnotation(method, GlobalTransactional.class);
final GlobalLock globalLockAnnotation = getAnnotation(method, GlobalLock.class);
if (globalTransactionalAnnotation != null) {
return handleGlobalTransaction(methodInvocation, globalTransactionalAnnotation);
} else if (globalLockAnnotation != null) {
return handleGlobalLock(methodInvocation);
} else {
return methodInvocation.proceed();
}
}
private Object handleGlobalTransaction(final MethodInvocation methodInvocation,
final GlobalTransactional globalTrxAnno) throws Throwable {
try {
return transactionalTemplate.execute(new TransactionalExecutor() {
@Override
public Object execute() throws Throwable {
return methodInvocation.proceed();
}
public String name() {
String name = globalTrxAnno.name();
if (!StringUtils.isNullOrEmpty(name)) {
return name;
}
return formatMethod(methodInvocation.getMethod());
}
@Override
public TransactionInfo getTransactionInfo() {
TransactionInfo transactionInfo = new TransactionInfo();
transactionInfo.setTimeOut(globalTrxAnno.timeoutMills());
transactionInfo.setName(name());
Set<RollbackRule> rollbackRules = new LinkedHashSet<>();
for (Class<?> rbRule : globalTrxAnno.rollbackFor()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (String rbRule : globalTrxAnno.rollbackForClassName()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (Class<?> rbRule : globalTrxAnno.noRollbackFor()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
for (String rbRule : globalTrxAnno.noRollbackForClassName()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
transactionInfo.setRollbackRules(rollbackRules);
return transactionInfo;
}
});
} catch (TransactionalExecutor.ExecutionException e) {
TransactionalExecutor.Code code = e.getCode();
switch (code) {
case RollbackDone:
throw e.getOriginalException();
case BeginFailure:
failureHandler.onBeginFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case CommitFailure:
failureHandler.onCommitFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case RollbackFailure:
failureHandler.onRollbackFailure(e.getTransaction(), e.getCause());
throw e.getCause();
default:
throw new ShouldNeverHappenException("Unknown TransactionalExecutor.Code: " + code);
}
}
}
在执行 handleGlobalTransaction 方法时,实际上采用模板模式,委托给了 TransactionalTemplate 类去执行标准的事务处理流程。如下所示:
/**
* Execute object.
*
* @param business the business
* @return the object
* @throws TransactionalExecutor.ExecutionException the execution exception
*/
public Object execute(TransactionalExecutor business) throws Throwable {
// 1. get or create a transaction
GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
// 1.1 get transactionInfo
TransactionInfo txInfo = business.getTransactionInfo();
if (txInfo == null) {
throw new ShouldNeverHappenException("transactionInfo does not exist");
}
try {
// 2. begin transaction
beginTransaction(txInfo, tx);
Object rs = null;
try {
// Do Your Business
rs = business.execute();
} catch (Throwable ex) {
// 3.the needed business exception to rollback.
completeTransactionAfterThrowing(txInfo,tx,ex);
throw ex;
}
// 4. everything is fine, commit.
commitTransaction(tx);
return rs;
} finally {
//5. clear
triggerAfterCompletion();
cleanUp();
}
}
事务处理逻辑实际上是一种模板,将事务相关的处理逻辑放在 try 块里,发现异常后执行回滚,正常执行则执行提交。
在这里有个需要注意的地方是,seata 不把提交这个动作放在 try 块里,因为在 seata 里,全局事务的提交实际上是可以异步执行的。
因为全局事务如果进行到提交这一阶段,那么意味着各个分支事务已经执行过本地提交,全局事务的提交阶段仅仅是删除 undo_log 里的记录,这个记录删除或者不删除,实际上不会改变全局事务已经正常完成的事实。所以它可以用程序异步去做,或者以人工介入的方式去做,所以 seata 认为,全局事务提交失败,不需要执行回滚流程。
设计思想
二阶段提交协议的由来
X/Open 组织提出了分布式事务处理的规范 DTP 模型(Distributed Transaction Processing),该模型中主要定义了三个基本组件,分别是
- 应用程序(Application Program ,简称AP):用于定义事务边界(即定义事务的开始和结束),并且在事务边界内对资源进行操作。
- 资源管理器(Resource Manager,简称RM):如数据库、文件系统等,并提供访问资源的方式。
- 事务管理器(Transaction Manager ,简称TM):负责分配事务唯一标识,监控事务的执行进度,并负责事务的提交、回滚等。
一般,我们称 TM 为事务的协调者,而称 RM 为事务的参与者。TM 与 RM 之间的通信接口,则由 XA 规范来约定。
在 DTP 模型的基础上,才引出了二阶段提交协议来处理分布式事务。
二阶段提交基本算法
前提
二阶段提交协议能够正确运转,需要具备以下前提条件:
- 存在一个协调者,与多个参与者,且协调者与参与者之间可以进行网络通信
- 参与者节点采用预写式日志,日志保存在可靠的存储设备上,即使参与者损坏,不会导致日志数据的消失
- 参与者节点不会永久性损坏,即使后仍然可以恢复
实际上,条件2和3所要求的,现今绝大多数关系型数据库都能满足。
基本算法
第一阶段
- 协调者节点向所有参与者节点询问是否可以执行提交操作,并开始等待各参与者节点的响应。
- 参与者节点执行询问发起为止的所有事务操作,并将Undo信息和Redo信息写入日志。
- 各参与者节点响应协调者节点发起的询问。如果参与者节点的事务操作实际执行成功,则它返回一个"同意"消息;如果参与者节点的事务操作实际执行失败,则它返回一个"中止"消息。
第二阶段
当协调者节点从所有参与者节点获得的相应消息都为"同意"时:
- 协调者节点向所有参与者节点发出"正式提交"的请求。
- 参与者节点正式完成操作,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送"完成"消息。
- 协调者节点收到所有参与者节点反馈的"完成"消息后,完成事务。
如下图所示:
如果任一参与者节点在第一阶段返回的响应消息为"终止",或者 协调者节点在第一阶段的询问超时之前无法获取所有参与者节点的响应消息时:
- 协调者节点向所有参与者节点发出"回滚操作"的请求。
- 参与者节点利用之前写入的Undo信息执行回滚,并释放在整个事务期间内占用的资源。
- 参与者节点向协调者节点发送"回滚完成"消息。
- 协调者节点收到所有参与者节点反馈的"回滚完成"消息后,取消事务。
如下图所示:
缺陷分析
二阶段提交协议除了协议本身具有的局限性之外,如果我们把以下情况也考虑在内:
- 协调者宕机
- 参与者宕机
- 网络闪断(脑裂)
那么二阶段提交协议实际上是存在很多问题的
协议本身的缺陷
协议本身的缺陷是指,在协议正常运行的情况下,无论全局事务最终是被提交还是被回滚,依然存在的问题,而暂不考虑参与者或者协调者宕机,或者脑裂的情况。
性能问题
参与者的本地事务开启后,直到它接收到协调者的 commit 或 rollback 命令后,它才会提交或回滚本地事务,并且释放由于事务的存在而锁定的资源。不幸的是,一个参与者收到协调者的 commit 或者 rollback 的前提是:协调者收到了所有参与者在一阶段的回复。
如果说,协调者一阶段询问多个参与者采用的是顺序询问的方式,那么一个参与者最快也要等到协调者询问完所有其它的参与者后才会被通知提交或回滚,在协调者未询问完成之前,这个参与者将保持占用相关的事务资源。
即使,协调者一阶段询问多个参与者采用的是并发询问的方式,那么一个参与者等待收到协调者的提交或者回滚通知的时间,将取决于在一阶段过程中,响应协调者最慢的那个参与者的响应时间。
无论是哪一种情况,参与者都将在整个一阶段持续的时间里,占用住相关的资源,参与者事务的处理时间增加。若此时在参与者身上有其它事务正在进行,那么其它事务有可能因为与这个延迟的事务有冲突,而被阻塞,这些被阻塞的事务,进而会引起其它事务的阻塞。
总而言之,整体事务的平均耗时增加了,整体事务的吞吐量也降低了。这会使得整个应用系统的延迟变高,吞吐量降低,可扩展性降低(当参与者变多的时候,延迟可能更严重)。
总的来说,二阶段提交协议,不是一个高效的协议,会带来性能上的损失。
全局事务隔离性的问题
全局事务的隔离性与单机事务的隔离性是不同的。
当我们在单机事务中提到不允许脏读时,那么意味着在事务未提交之前,它对数据造成的影响不应该对其它事务可见。
当我们在全局事务中提到不允许脏读时,意味着,在全局事务未提交之前,它对数据造成的影响不应该对其它事务可见。
在二阶段提交协议中,当在第二阶段所有的参与者都成功执行 commit 或者 rollback 之后,全局事务才算结束。但第二阶段存在这样的中间状态:即部分参与者已执行 commit 或者 rollback,而其它参与者还未执行 commit 或者 rollback。此刻,已经执行 commit 或者 rollback 的参与者,它对它本地数据的影响,对其它全局事务是可见的,即存在脏读的风险。对于这种情况,二阶段协议并没有任何机制来保证全局事务的隔离性,无法做到“读已提交”这样的隔离级别。
协调者宕机
如果在第一阶段,协调者发生了宕机,那么因为所有参与者无法再接收到协调者第二阶段的 commit 或者 rollback 命令,所以他们会阻塞下去,本地事务无法结束,
如果协调者在第二阶段发生了宕机,那么可能存在部分参与者接收到了 commit/rollback 命令,而部分没有,因此这部分没有接收到命令的参与者也会一直阻塞下去。
协调者宕机属于单点问题,可以通过另选一个协调者的方式来解决,但这只能保证后续的全局事务正常运行。而因为之前协调者宕机而造成的参与者阻塞则无法避免。如果这个新选择的协调者也宕机了,那么一样会带来阻塞的问题。
参与者宕机
如果在第一阶段,某个参与者发生了宕机,那么会导致协调者一直等待这个参与者的响应,进而导致其它参与者也进入阻塞状态,全局事务无法结束。
如果在第二阶段,协调者发起 commit 操作时,某个参与者发生了宕机,那么全局事务已经执行了 commit 的参与者的数据已经落盘,而宕机的参与者可能还没落盘,当参与者恢复过来的时候,就会产生全局数据不一致的问题。
网络问题-脑裂
当网络闪断发生在第一阶段时,可能会有部分参与者进入阻塞状态,全局事务无法结束。
当发生在第二阶段时,可能发生部分参与者执行了 commit 而部分参与者未执行 commit,从而导致全局数据不一致的问题。
三阶段提交
在二阶段提交中,当协调者宕机的时候,无论是在第一阶段还是在第二阶段发生宕机,参与者都会因为等待协调者的命令而进入阻塞状态,从而导致全局事务无法继续进行。因此,如果在参与者中引入超时机制,即,当指定时间过去之后,参与者自行提交或者回滚。但是,参与者应该进行提交还是回滚呢?悲观的做法是,统一都回滚。但事情往往没那么简单。
当第一阶段,协调者宕机时,那么所有被阻塞的参与者选择超时后回滚事务是最明智的做法,因为还未进入第二阶段,所以参与者都不会接收到提交或者回滚的请求,当前这个事务是无法继续进行提交的,因为参与者不知道其它参与者的执行情况,所以统一回滚,结束分布式事务。
在二阶段提交协议中的第二阶段,当协调者宕机后,由于参与者无法知道协调者在宕机前给其他参与者发了什么命令,进入了第二阶段,全局事务要么提交要么回滚,参与者如果引入超时机制,那么它应该在超时之后提交还是回滚呢,似乎怎么样都不是正确的做法。执行回滚,太保守,执行提交,太激进。
如果在二阶段提交协议中,在第一阶段和第二阶段中间再引入一个阶段,如果全局事务度过了中间这个阶段,那么在第三阶段,参与者就可以认为此刻进行提交的成功率会更大。但这难道不是治标不治本吗,当进入第三阶段,全局事务需要进行回滚时候,如果协调者宕机,那么参与者超时之后自行进行提交事务,就会造成全局事务的数据不一致。
再考虑参与者宕机的情况下,协调者应该在超时之后,对全局事务进行回滚。
总结起来,三阶段提交主要在二阶段提交的基础上,为了解决参与者和协调者宕机的问题,而引入了超时机制,并因为超时机制,附带引入中间这一层。
并且,三阶段提交并没有解决二阶段提交的存在的脑裂的问题。
seata 二阶段提交
fescar 是阿里最近开源的一个关于分布式事务的处理组件,它的商业版是阿里云上的 GTS。
在其官方wiki上,我们可以看到,它对XA 二阶段提交思考与改进。
在我们上面提到的参与者中,这个参与者往往是数据库本身,在 DTP 模型中,往往称之为 RM,即资源管理器。fescar 的二阶段提交模型,也是在 DTP 模型的基础上构建。
RM逻辑不与数据库绑定
fescar 2PC 与 XA 2PC 的第一个不同是,fescar 把 RM 这一层的逻辑放在了 SDK 层面,而传统的 XA 2PC,RM的逻辑其实就在数据库本身。fescar 这样做的好处是,把提交与回滚的逻辑放在了 SDK 层,从而不必要求底层的数据库必须对 XA 协议进行支持。对于业务来说,业务层也不需要为本地事务和分布式事务两类不同场景来适配两套不同的数据库驱动。
基于我们先前对 XA 2PC 讨论,XA 2PC 存在参与者宕机的情况,而 fescar 的 2PC 模型中,参与者实际上是 SDK。参与者宕机这个问题之所以在 XA 2PC 中是个大问题,主要是因为 XA 中,分支事务是有状态的,即它是跟会话绑定在一起的,无法跨连接跨会话对一个分支事务进行操作,因此在 XA 2PC 中参与者一旦宕机,分支事务就已经无法再进行恢复。
fescar 2PC 中,参与者实际上是SDK,而SDK是可以做高可用设计的。并且,在其第一阶段,分支事务实际上已经是被提交了的,后续的全局上的提交和回滚,实际上是操作数据的镜像,全局事务的提交会异步清理 undo_log,回滚则会利用保存好的数据镜像,进行恢复。fescar 的 2PC 中,实际上是利用了 TCC 规范的无状态的理念。因为全局事务的执行、提交和回滚这几个操作间不依赖于公共状态,比如说数据库连接。所以参与者实际上是可以成为无状态的集群的。
也就是说,在 fescar 2PC 中,协调者如果发现参与者宕机或者超时,那么它可以委托其他的参与者去做。
第二阶段非阻塞化
fescar 2PC 的第一阶段中,利用 SDK 中的 JDBC 数据源代理通过对业务 SQL 的解析,把业务数据在更新前后的数据镜像组织成回滚日志,利用本地事务 的 ACID 特性,将业务数据的更新和回滚日志的写入在同一个 本地事务 中提交。
这就意味着在阻塞在第一阶段过后就会结束,减少了对数据库数据和资源的锁定时间,明显效率会变更高。根据 fescar 官方的说法,一个正常运行的业务,大概率是 90% 以上的事务最终应该是成功提交的,因此可以在第一阶段就将本地事务提交呢,这样 90% 以上的情况下,可以省去第二阶段持锁的时间,整体提高效率。
fescar 的这个设计,直接优化了 XA 2PC 协议本身的性能缺陷。
协调者的高可用
XA 2PC 中存在协调者宕机的情况,而 fescar 的整体组织上,是分为 server 层和 SDK 层的,server 层作为事务的协调者,fescar 的话术中称协调者为 TC(Transaction Coordinator ),称 SDK 为 TM(Transaction Manager)。截止至这篇文章发表前,fescar 的server层高可用还未实现,依据其官方的蓝图,它可能会采用集群内自行协商的方案,也可能直接借鉴高可用KV系统。自行实现集群内高可用方案,可能需要引进一套分布式一致性协议,例如raft,我认为这是最理想的方式。而直接利用高可用KV系统,例如 redis cluster,则会显得系统太臃肿,但实现成本低。
事务的隔离性
XA 2PC 是没有机制去支持全局事务隔离级别的,fescar 是提供全局事务的隔离性的,它把全局锁保存在了 server 层。全局事务隔离级别如果太高,性能会有很大的损耗。目前的隔离界别默认是读未提交,如果需要读已提交或者更高的级别,就会涉及到全局锁,则意味着事务的并发性会受影响。应用层业务层应该选择合适的事务隔离级别。
脑裂的问题仍然没有完美解决
无论是 XA 还是 fescar,都未解决上述提到的脑裂的问题。脑裂的问题主要影响了全局事务的最后的提交和回滚阶段。
没有完美的分布式事务解决方案,即使是 fescar 或者 GTS,它们也必然需要人工介入。但脑裂问题是小概率事件,并且不是致命性错误,可以先通过重试的方法来解决,如果不行,可以收集必要的事务信息,由运维介入,以自动或者非自动的方式,恢复全局事务。
通信机制
RPC
seata client 和 seata server 间是需要通过网络通信来传递信息的,client 发送请求消息给 server,server 根据实际的处理逻辑,可能会给 client 发送相应的响应消息,或者不响应任何消息。在 seata 中,客户端和服务端的通信实现,被抽象成来公共的模块,它的 package 位于 io.seata.core.rpc
中。
这个包名叫 rpc,这个包下的很多类名也有 rpc 相关的字眼,而实际上在我看来,这个通信框架并不是一个常规意义的 rpc 框架,如果硬要揪书本知识,那么 rpc 的解释如下:
远程过程调用(英语:Remote Procedure Call,缩写为 RPC)是一个计算机通信协议。该协议允许运行于一台计算机的程序调用另一台计算机的子程序,而程序员无需额外地为这个交互作用编程。如果涉及的软件采用面向对象编程,那么远程过程调用亦可称作远程调用或远程方法调用。
在以 dubbo 为代表的微服务时代下,dubbo 常规意义上我们都称之为 rpc 框架,rpc 的理论原则是:程序员无需额外地为这个交互作用编程。那么对于像 dubbo 这样的 rpc 实现,它能让 client 像调用本地代码 api 一样,来调用远程 server 上的某个 method。
在 client 这一层直接面向 interface 编程,通过动态代理的方式,对上层屏蔽掉通信细节,在底层,将方法调用,通过序列化方式,封装成一个二进制数据串发送给 server,server 层解析该消息,通过反射的方式,将 interface 对应的 implemention 执行起来,将执行结果,扁平化成一个二进制数据串,回送给 client,client 收到数据后,拼装成 interface api 所定义的返回值类型的一个实例,作为方法调用的返回值。整个底层的细节,应用层面并不需要了解,应用层只需要以 interface.method 的方式,就像代码在本地执行一样,就能把远端 interface_implemention.method 给调用起来。
而 seata 的 rpc 框架上,实际上仅仅是一个普通的基于 netty 的网络通信框架,client 与 server 之间通过发送 request 和 response 来达到相互通信的目的,在 seata 中的每个 request 和 response 类,都实现了如何把自己序列化的逻辑。
各种消息类型,都实现了 io.seata.core.protocol.MessageCodec
接口
public interface MessageCodec {
/**
* Gets type code.
*
* @return the type code
*/
short getTypeCode();
/**
* Encode byte [ ].
*
* @return the byte [ ]
*/
byte[] encode();
/**
* Decode boolean.
*
* @param in the in
* @return the boolean
*/
boolean decode(ByteBuf in);
}
以 io.seata.core.protocol.GlobalBeginRequest
为例,它都 decode 和 encode 实现如下所示:
@Override
public byte[] encode() {
ByteBuffer byteBuffer = ByteBuffer.allocate(256);
byteBuffer.putInt(timeout);
if (this.transactionName != null) {
byte[] bs = transactionName.getBytes(UTF8);
byteBuffer.putShort((short)bs.length);
if (bs.length > 0) {
byteBuffer.put(bs);
}
} else {
byteBuffer.putShort((short)0);
}
byteBuffer.flip();
byte[] content = new byte[byteBuffer.limit()];
byteBuffer.get(content);
return content;
}
@Override
public void decode(ByteBuffer byteBuffer) {
this.timeout = byteBuffer.getInt();
short len = byteBuffer.getShort();
if (len > 0) {
byte[] bs = new byte[len];
byteBuffer.get(bs);
this.setTransactionName(new String(bs, UTF8));
}
}
这意味着,发送方先对 message 做 encode 动作形成字节数组,将字节数组发往接收方,接收方收到字节数组后,对字节数组先判断 message type,再用对应的 message 类型对字节数组做 decode 动作。
类的组织形式
从 seata server 的入口类 io.seata.server.Server
分析,main 方法如下所示:
/**
* The entry point of application.
*
* @param args the input arguments
* @throws IOException the io exception
*/
public static void main(String[] args) throws IOException {
RpcServer rpcServer = new RpcServer(WORKING_THREADS);
int port = SERVER_DEFAULT_PORT;
//server port
if (args.length > 0) {
try {
port = Integer.parseInt(args[0]);
} catch (NumberFormatException e) {
System.err.println("Usage: sh services-server.sh $LISTEN_PORT $PATH_FOR_PERSISTENT_DATA");
System.exit(0);
}
}
rpcServer.setListenPort(port);
//log store mode : file、db
String storeMode = null;
if (args.length > 1) {
storeMode = args[1];
}
UUIDGenerator.init(1);
SessionHolder.init(storeMode);
DefaultCoordinator coordinator = new DefaultCoordinator(rpcServer);
coordinator.init();
rpcServer.setHandler(coordinator);
// register ShutdownHook
ShutdownHook.getInstance().addDisposable(coordinator);
if (args.length > 2) {
XID.setIpAddress(args[2]);
} else {
XID.setIpAddress(NetUtil.getLocalIp());
}
XID.setPort(rpcServer.getListenPort());
rpcServer.init();
System.exit(0);
}
可以看到 seata server 使用一个 RpcServer 类来启动它的服务监听端口,这个端口用来接收 seata client 的消息,RpcServer 这个类是通信层的实现分析的入口。
在这里,SessionHolder 用来做全局事务树的管理,DefaultCoordinator 用来处理事务执行逻辑,而 RpcServer 是这两者可以正常运行的基础,这篇文章的重点在于剖析 RpcServer 的实现,进而延伸到 seata 整个通信框架的细节。
如果先从 RpcServer 的类继承图看的话,那么我们能发现一些与常规思维不太一样的地方,类继承图如下:
褐色部分是 netty 的类,灰色部分是 seata 的类。
在一般常规的思维中,依赖 netty 做一个 server,大致的思路是:
- 定义一个 xxx server 类
- 在这个类中设置初始化 netty bootstrap,eventloop,以及设置相应的 ChannelHandler
在这种思维下,很容易想到,server 与 ChannelHandler 之间的关系应该是一个“组合”的关系,即在我们构建 server 的过程中,应该把 ChannelHandler 当作参数传递给 server,成为 server 类的成员变量。
没错,这是我们一般情况下的思维。不过 seata 在这方面却不那么“常规”,从上面的类继承图中可以看到,从 RpcServer 这个类开始向上追溯,发现它其实是 ChannelDuplexHandler 的一个子类或者实例。这种逻辑让人一时很困惑,一个问题在我脑海里浮现:“当我启动一个 RpcServer 的时候,我是真的在启动一个 server 吗?看起来我好像在启动一个 ChannelHandler,可是 ChannelHandler 怎么谈得上‘启动’呢?”
异步转同步的 Future 机制
首先分析 AbstractRpcRemoting 这个类,它直接继承自 ChannelDuplexHandler 类,而 ChannelDuplexHandler 是 netty 中 inbound handler 和 outbound handler 的结合体。
AbstractRpcRemoting 的 init 方法里,仅仅通过 Java 中的定时任务执行线程池启动了一个定时执行的任务:
/**
* Init.
*/
public void init() {
timerExecutor.scheduleAtFixedRate(new Runnable() {
@Override
public void run() {
List<MessageFuture> timeoutMessageFutures = new ArrayList<MessageFuture>(futures.size());
for (MessageFuture future : futures.values()) {
if (future.isTimeout()) {
timeoutMessageFutures.add(future);
}
}
for (MessageFuture messageFuture : timeoutMessageFutures) {
futures.remove(messageFuture.getRequestMessage().getId());
messageFuture.setResultMessage(null);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("timeout clear future : " + messageFuture.getRequestMessage().getBody());
}
}
nowMills = System.currentTimeMillis();
}
}, TIMEOUT_CHECK_INTERNAL, TIMEOUT_CHECK_INTERNAL, TimeUnit.MILLISECONDS);
}
这个定时任务的逻辑也比较简单:扫描 ConcurrentHashMap<Long, MessageFuture> futures
这个成员变量里的 MessageFuture,如果这个 Future 超时了,就将 Future 的结果设置为 null。逻辑虽然简单,但这个功能涉及到了异步通信里一个很常见的功能,即异步转同步的功能。
在 netty 这种基于 NIO 的通信方式中,数据的发送,接收,全部是非阻塞的,因此判断一个动作完成与否,并不能像传统的 Java 同步代码一样,代码执行完了就认为相应的动作也真正完成了,例如,在 netty 中,如果通过 channel.write(); 方法往对端发送一个数据,这个方法执行完了,并不代表数据发送出去了,
channel.write() 方法会返回一个 future,应用代码应该利用这个 future ,通过这个 future 可以知道数据到底发送出去了没有,也可以为这个 future 添加动作完成后的回调逻辑,也可以阻塞等待这个 future 所关联的动作执行完毕。
在 seata 中,存在着发送一个请求,并等待相应这样的使用场景,上层的 api 可能是这么定义的: public Response request(Request request) {}
而基于 nio 的底层数据发送逻辑却是这样的:
1. send request message
2. 为业务的请求构建一个业务层面的 future 实例
3. 阻塞等待在这个 future 上
4. 当收到对应的 response message 后,唤醒上面的 future,阻塞等待在这个 future 上的线程继续执行
5. 拿到结果,request 方法结束
AbstractRpcRemoting 定义了几个数据发送相关的方法,分别是:
/**
* Send async request with response object.
*
* @param address the address
* @param channel the channel
* @param msg the msg
* @return the object
* @throws TimeoutException the timeout exception
*/
protected Object sendAsyncRequestWithResponse(String address, Channel channel, Object msg) throws TimeoutException;
/**
* Send async request with response object.
*
* @param address the address
* @param channel the channel
* @param msg the msg
* @param timeout the timeout
* @return the object
* @throws TimeoutException the timeout exception
*/
protected Object sendAsyncRequestWithResponse(String address, Channel channel, Object msg, long timeout) throws
TimeoutException;
/**
* Send async request without response object.
*
* @param address the address
* @param channel the channel
* @param msg the msg
* @return the object
* @throws TimeoutException the timeout exception
*/
protected Object sendAsyncRequestWithoutResponse(String address, Channel channel, Object msg) throws
TimeoutException;
这几个方法就符合上面说到的发送一个请求,并等待相应这样的使用场景,上面这三个方法,其实都委托给了 sendAsyncRequest
来实现,这个方法的代码是这样子的:
private Object sendAsyncRequest(String address, Channel channel, Object msg, long timeout)
throws TimeoutException {
if (channel == null) {
LOGGER.warn("sendAsyncRequestWithResponse nothing, caused by null channel.");
return null;
}
final RpcMessage rpcMessage = new RpcMessage();
rpcMessage.setId(RpcMessage.getNextMessageId());
rpcMessage.setAsync(false);
rpcMessage.setHeartbeat(false);
rpcMessage.setRequest(true);
rpcMessage.setBody(msg);
final MessageFuture messageFuture = new MessageFuture();
messageFuture.setRequestMessage(rpcMessage);
messageFuture.setTimeout(timeout);
futures.put(rpcMessage.getId(), messageFuture);
if (address != null) {
ConcurrentHashMap<String, BlockingQueue<RpcMessage>> map = basketMap;
BlockingQueue<RpcMessage> basket = map.get(address);
if (basket == null) {
map.putIfAbsent(address, new LinkedBlockingQueue<RpcMessage>());
basket = map.get(address);
}
basket.offer(rpcMessage);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("offer message: " + rpcMessage.getBody());
}
if (!isSending) {
synchronized (mergeLock) {
mergeLock.notifyAll();
}
}
} else {
ChannelFuture future;
channelWriteableCheck(channel, msg);
future = channel.writeAndFlush(rpcMessage);
future.addListener(new ChannelFutureListener() {
@Override
public void operationComplete(ChannelFuture future) {
if (!future.isSuccess()) {
MessageFuture messageFuture = futures.remove(rpcMessage.getId());
if (messageFuture != null) {
messageFuture.setResultMessage(future.cause());
}
destroyChannel(future.channel());
}
}
});
}
if (timeout > 0) {
try {
return messageFuture.get(timeout, TimeUnit.MILLISECONDS);
} catch (Exception exx) {
LOGGER.error("wait response error:" + exx.getMessage() + ",ip:" + address + ",request:" + msg);
if (exx instanceof TimeoutException) {
throw (TimeoutException)exx;
} else {
throw new RuntimeException(exx);
}
}
} else {
return null;
}
}
先抛开方法的其它细节,比如说同步写还是异步写,以及发送频率控制。我们可以发现,这个方法其实从大角度来划分,就是如下的步骤:
- 构造请求 message
- 为这个请求 message 构造一个 message future
- 发送数据
- 阻塞等待在 message future
不过 AbstractRpcRemoting 也定义了方法用于仅发送消息,不接收响应的使用场景,如下所示:
/**
* Send request.
*
* @param channel the channel
* @param msg the msg
*/
protected void sendRequest(Channel channel, Object msg) {
RpcMessage rpcMessage = new RpcMessage();
rpcMessage.setAsync(true);
rpcMessage.setHeartbeat(msg instanceof HeartbeatMessage);
rpcMessage.setRequest(true);
rpcMessage.setBody(msg);
rpcMessage.setId(RpcMessage.getNextMessageId());
if (msg instanceof MergeMessage) {
mergeMsgMap.put(rpcMessage.getId(), (MergeMessage)msg);
}
channelWriteableCheck(channel, msg);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("write message:" + rpcMessage.getBody() + ", channel:" + channel + ",active?"
+ channel.isActive() + ",writable?" + channel.isWritable() + ",isopen?" + channel.isOpen());
}
channel.writeAndFlush(rpcMessage);
}
/**
* Send response.
*
* @param msgId the msg id
* @param channel the channel
* @param msg the msg
*/
protected void sendResponse(long msgId, Channel channel, Object msg) {
RpcMessage rpcMessage = new RpcMessage();
rpcMessage.setAsync(true);
rpcMessage.setHeartbeat(msg instanceof HeartbeatMessage);
rpcMessage.setRequest(false);
rpcMessage.setBody(msg);
rpcMessage.setId(msgId);
channelWriteableCheck(channel, msg);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("send response:" + rpcMessage.getBody() + ",channel:" + channel);
}
channel.writeAndFlush(rpcMessage);
}
这样的场景就不需要引入 future 机制,直接调用 netty 的 api 把数据发送出去就完事了。
分析思路回到有 future 的场景,发送数据后,要在 future 上进行阻塞等待,即调用 get 方法,那 get 方法什么返回呢,我们上面说到 future 被唤醒的时候,我们先不讨论 future 的实现细节,一个 future 什么时候被唤醒呢,在这种 请求-响应 的模式下,显然是收到了响应的时候。所以我们需要查看一下 AbstractRpcRemoting 的 channelRead 方法
@Override
public void channelRead(final ChannelHandlerContext ctx, Object msg) throws Exception {
if (msg instanceof RpcMessage) {
final RpcMessage rpcMessage = (RpcMessage)msg;
if (rpcMessage.isRequest()) {
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String.format("%s msgId:%s, body:%s", this, rpcMessage.getId(), rpcMessage.getBody()));
}
try {
AbstractRpcRemoting.this.messageExecutor.execute(new Runnable() {
@Override
public void run() {
try {
dispatch(rpcMessage.getId(), ctx, rpcMessage.getBody());
} catch (Throwable th) {
LOGGER.error(FrameworkErrorCode.NetDispatch.getErrCode(), th.getMessage(), th);
}
}
});
} catch (RejectedExecutionException e) {
LOGGER.error(FrameworkErrorCode.ThreadPoolFull.getErrCode(),
"thread pool is full, current max pool size is " + messageExecutor.getActiveCount());
if (allowDumpStack) {
String name = ManagementFactory.getRuntimeMXBean().getName();
String pid = name.split("@")[0];
int idx = new Random().nextInt(100);
try {
Runtime.getRuntime().exec("jstack " + pid + " >d:/" + idx + ".log");
} catch (IOException exx) {
LOGGER.error(exx.getMessage());
}
allowDumpStack = false;
}
}
} else {
MessageFuture messageFuture = futures.remove(rpcMessage.getId());
if (LOGGER.isDebugEnabled()) {
LOGGER.debug(String
.format("%s msgId:%s, future :%s, body:%s", this, rpcMessage.getId(), messageFuture,
rpcMessage.getBody()));
}
if (messageFuture != null) {
messageFuture.setResultMessage(rpcMessage.getBody());
} else {
try {
AbstractRpcRemoting.this.messageExecutor.execute(new Runnable() {
@Override
public void run() {
try {
dispatch(rpcMessage.getId(), ctx, rpcMessage.getBody());
} catch (Throwable th) {
LOGGER.error(FrameworkErrorCode.NetDispatch.getErrCode(), th.getMessage(), th);
}
}
});
} catch (RejectedExecutionException e) {
LOGGER.error(FrameworkErrorCode.ThreadPoolFull.getErrCode(),
"thread pool is full, current max pool size is " + messageExecutor.getActiveCount());
}
}
}
}
}
可以看到调用了 messageFuture 当 setResultMessage() 方法,设置 future 的结果,也就是说,唤醒了 future,那么阻塞在 future 的 get 方法上的线程就被唤醒了,得到结果,继续往下执行。
接下来我们讨论 MessageFuture 的实现细节,其实 seata 里面有很多种 future 相关的类,实现方式也不太一样,不过都大同小异,有的是基于 CompletableFuture 实现,有的是基于 CountDownLatch 实现。比如说,MessageFuture 就是基于 CompletableFuture 实现的,先看看它的成员变量:
private RpcMessage requestMessage;
private long timeout;
private long start = System.currentTimeMillis();
private transient CompletableFuture origin = new CompletableFuture();
CompletableFuture 是它的一个成员变量,它被利用来阻塞当前线程。MessageFuture 的 get 方法,依赖于 CompletableFuture 的 get 方法,来实现有一定时间限制的等待,直到另一个线程唤醒 CompletableFuture。如下所示:
/**
* Get object.
*
* @param timeout the timeout
* @param unit the unit
* @return the object
* @throws TimeoutException the timeout exception
* @throws InterruptedException the interrupted exception
*/
public Object get(long timeout, TimeUnit unit) throws TimeoutException,
InterruptedException {
Object result = null;
try {
result = origin.get(timeout, unit);
} catch (ExecutionException e) {
throw new ShouldNeverHappenException("Should not get results in a multi-threaded environment", e);
} catch (TimeoutException e) {
throw new TimeoutException("cost " + (System.currentTimeMillis() - start) + " ms");
}
if (result instanceof RuntimeException) {
throw (RuntimeException)result;
} else if (result instanceof Throwable) {
throw new RuntimeException((Throwable)result);
}
return result;
}
/**
* Sets result message.
*
* @param obj the obj
*/
public void setResultMessage(Object obj) {
origin.complete(obj);
}
既然说到了 future 机制,这里也顺便把 io.seata.config.ConfigFuture
提一下,它就是上面提到的基于 CountDownLatch 实现的一种 future 机制,虽然实现方式两者不一样,但完成的功能和作用是一样的。
private final CountDownLatch latch = new CountDownLatch(1);
/**
* Get object.
*
* @param timeout the timeout
* @param unit the unit
* @return the object
* @throws InterruptedException the interrupted exception
*/
public Object get(long timeout, TimeUnit unit) {
this.timeoutMills = unit.toMillis(timeout);
try {
boolean success = latch.await(timeout, unit);
if (!success) {
LOGGER.error(
"config operation timeout,cost:" + (System.currentTimeMillis() - start) + " ms,op:" + operation
.name()
+ ",dataId:" + dataId);
return getFailResult();
}
} catch (InterruptedException exx) {
LOGGER.error("config operate interrupted,error:" + exx.getMessage());
return getFailResult();
}
if (operation == ConfigOperation.GET) {
return result == null ? content : result;
} else {
return result == null ? Boolean.FALSE : result;
}
}
/**
* Sets result.
*
* @param result the result
*/
public void setResult(Object result) {
this.result = result;
latch.countDown();
}
阻塞操作调用了 CountDownLatch 的 await 方法,而唤醒操作则调用 countDown 方法,核心在于需要把 CountDownLatch 的 latch 值设置为 1。
实际上,Java 语言本身已经提供了 java.util.concurrent.Future 这个类来提供 Future 机制,但 Java 原生的 Future 机制功能过于单一,比如说不能主动设置 future 的结果,也不能为它添加 listener,所有有许多像 seata 这样的软件,会选择去重新实现一种 future 机制来满足异步转同步的需求。也有像 netty 这样的软件,它不会借助类似于 countdownlatch 来实现,而是直接扩展 java.util.concurrent.Future,在它的基础上添加功能。
防洪机制
在 AbstractRpcRemoting 中,往外发数据的时候,它都会先进行一个检查,即检查当前的 channel 是否可写。
private void channelWriteableCheck(Channel channel, Object msg) {
int tryTimes = 0;
synchronized (lock) {
while (!channel.isWritable()) {
try {
tryTimes++;
if (tryTimes > NettyClientConfig.getMaxNotWriteableRetry()) {
destroyChannel(channel);
throw new FrameworkException("msg:" + ((msg == null) ? "null" : msg.toString()),
FrameworkErrorCode.ChannelIsNotWritable);
}
lock.wait(NOT_WRITEABLE_CHECK_MILLS);
} catch (InterruptedException exx) {
LOGGER.error(exx.getMessage());
}
}
}
}
这要从 netty 的内部机制说起,当调用 ChannelHandlerContext 或者 Channel 的 write 方法时,netty 只是把要写的数据放入了自身的一个环形队列里面,再由后台线程真正往链路上发。如果接受方的处理速度慢,也就是说,接收的速度慢,那么根据 tcpip 协议的滑动窗口机制,它也会导致发送方发送得慢。
我们可以把 netty 的环形队列想像成一个水池,调用 write 方法往池子里加水,netty 通过后台线程,慢慢把池子的水流走。这就有可能出现一种情况,由于池子水流走的速度远远慢于往池子里加水的速度,这样会导致池子的总水量随着时间的推移越来越多。所以往池子里加水时应该考虑当前池子里的水量,否则最终会导致应用的内存溢出。
netty 对于水池提供了两个设置,一个是高水位,一个是低水位,当池子里的水高于高水位时,这个时候 channel.isWritable() 返回 false,并且直到水位慢慢降回到低水位时,这个方法才会返回 true。
上述的 channelWriteableCheck 方法,发现channel 不可写的时候,进入循环等待,等待的目的是让池子的水位下降到 low water mark,如果等待超过最大允许等待的时间,那么将会抛出异常并关闭连接。
消息队列
在 AbstractRpcRemoting 中,发送数据有两种方式,一种是直接调用 channel 往外写,另一种是先把数据放进“数据篮子”里,它实际上是一个 map, key 为远端地址,value为一个消息队列。数据放队列后,再由其它线程往外发。下面是 sendAsycRequest 方法的一部分代码,显示了这种机制:
ConcurrentHashMap<String, BlockingQueue<RpcMessage>> map = basketMap;
BlockingQueue<RpcMessage> basket = map.get(address);
if (basket == null) {
map.putIfAbsent(address, new LinkedBlockingQueue<RpcMessage>());
basket = map.get(address);
}
basket.offer(rpcMessage);
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("offer message: " + rpcMessage.getBody());
}
if (!isSending) {
synchronized (mergeLock) {
mergeLock.notifyAll();
}
}
但我们在 AbstractRpcRemoting 里面没有看有任何额外的线程在晴空这个 basketMap。回顾一下上面的 RpcServer 的类继承体系,接下来我们要分析一下,AbstractRpcRemotingServer 这个类。
AbstractRpcRemotingServer 这个类主要定义了于netty 启动一个 server bootstrap 相关的类,可见真正启动服务监听端口的是在这个类,先看一下它的start方法
@Override
public void start() {
this.serverBootstrap.group(this.eventLoopGroupBoss, this.eventLoopGroupWorker)
.channel(nettyServerConfig.SERVER_CHANNEL_CLAZZ)
.option(ChannelOption.SO_BACKLOG, nettyServerConfig.getSoBackLogSize())
.option(ChannelOption.SO_REUSEADDR, true)
.childOption(ChannelOption.SO_KEEPALIVE, true)
.childOption(ChannelOption.TCP_NODELAY, true)
.childOption(ChannelOption.SO_SNDBUF, nettyServerConfig.getServerSocketSendBufSize())
.childOption(ChannelOption.SO_RCVBUF, nettyServerConfig.getServerSocketResvBufSize())
.childOption(ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(nettyServerConfig.getWriteBufferLowWaterMark(),
nettyServerConfig.getWriteBufferHighWaterMark()))
.localAddress(new InetSocketAddress(listenPort))
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new IdleStateHandler(nettyServerConfig.getChannelMaxReadIdleSeconds(), 0, 0))
.addLast(new MessageCodecHandler());
if (null != channelHandlers) {
addChannelPipelineLast(ch, channelHandlers);
}
}
});
if (nettyServerConfig.isEnableServerPooledByteBufAllocator()) {
this.serverBootstrap.childOption(ChannelOption.ALLOCATOR, NettyServerConfig.DIRECT_BYTE_BUF_ALLOCATOR);
}
try {
ChannelFuture future = this.serverBootstrap.bind(listenPort).sync();
LOGGER.info("Server started ... ");
RegistryFactory.getInstance().register(new InetSocketAddress(XID.getIpAddress(), XID.getPort()));
initialized.set(true);
future.channel().closeFuture().sync();
} catch (Exception exx) {
throw new RuntimeException(exx);
}
}
这个类很常规,就是遵循 netty 的使用规范,用合适的配置启动一个 server,并调用注册中心 api 把自己作为一个服务发布出去。
我们可以看到,配置中确实也出现了我们上文中提到过的上下水位的配置。
另外,channelpipeline 中,除了添加一个保持链路有效性探测的 IdleStateHandler,和一个 MessageCodec,处理事务逻辑相关的 Handler 还需要由参数传入。
接下来我们看 RpcServer 这个类,从它的 init 方法里,我们可以看到,它把自己做为一个 ChannelHandler,加入到了 channel pipeline 中
/**
* Init.
*/
@Override
public void init() {
super.init();
setChannelHandlers(RpcServer.this);
DefaultServerMessageListenerImpl defaultServerMessageListenerImpl = new DefaultServerMessageListenerImpl(
transactionMessageHandler);
defaultServerMessageListenerImpl.init();
defaultServerMessageListenerImpl.setServerMessageSender(this);
this.setServerMessageListener(defaultServerMessageListenerImpl);
super.start();
}
RpcServer 自身也实现了 channelRead 方法,但它只处理心跳相关的信息和注册相关的信息,其它的业务消息,它交给父类处理,而先前我们也已经看到,父类的channelRead
方法里,反过来会调用 dispatch 这个抽象方法去做消息的分发,而 RpcServer 类实现了这个抽象方法,在接收到不同的消息类型是,采取不同的处理流程。
关于事务的处理流程的细节,本篇文章暂不涉及,后续文章再慢慢分析。
行文至此,回想我们先前提到的一个疑惑:
“当我启动一个 RpcServer 的时候,我是真的在启动一个 server 吗?看起来我好像在启动一个 ChannelHandler,可是 ChannelHandler 怎么谈得上‘启动’呢?”
是的,我们既在启动一个 server,这个 server 也实现了事务处理逻辑,它同时也是个 ChannelHandler。
没有一定的事实标准去衡量这样写的代码是好是坏,我们也没必要去争论 Effective Java 提到的什么时候该用组合,什么时候该用继承。
配置机制
seata 的客户端代码和服务端代码逻辑里,读取配置时统一采用的以下这种 API
ConfigurationFactory.getInstance().getString()
seata 目前(0.8.0)支持以下几种配置方式
- 本地文件方式
- zookeeper
- nacos
- apollo
- consul
- etcd
在分析 seata 的配置解析细节之前,先看看 seata 对于配置解析机制的设计
具体来说,seata 的配置项的命名风格都是类似于 computer.apple.macbookpro 这种的文本扁平化风格。
它本质上还是一个结构形的配置方式,即每个具体配置项都有父节点,举个例子:
computer.apple {
macbookpro = 12000
macbookair = 8000
}
这种配置方式与普通的 xml 配置文件在结构上没有什么大的区别,但与 xml 相比 还是有不少的好处和优点。
- 简洁明了,xml 本质上还是个标记型语言,引入了许多不必要的复杂标记,间接增加了解析成本
- 解析阶段,xml 定位到某个配置项的逻辑更加繁琐
- 配置更新时如果需要更新文件,xml 的文件的更新动作不够轻量级,如果依赖一些第三方实现,还会造成代码入侵,可扩展性差。
相比之下,目前主流的配置中心例如 zookeeper 或者 apollo,这些软件设计之初对数据结构的选型就与 computer.apple.macbookpro 这样的配置形态很相称。
例如,zookeeper 的数据结构是类似于文件系统的树状风格,所以 zookeeper 之前是很适合拿来做公共配置中心的,直到后面更优秀的配置中心出现,zookeeper 才慢慢淡出配置界,毕竟 zookeeper 它更擅长做的事情是分布式一致性的协调。
正是 seata 采用了这种配置风格,所以 seata 在配置中心的支持这一块,就很方便地与当前主流的配置中心做集成。
试想一下,如果要把一个 xml 配置文件存到 zookeeper 上做全局管理。大概有这么两种方式吧:
- 把整个 xml 文本存到一个 znode 上
- 把 xml 的结构解析称树状结构,再一个一个对应地到 zookeeper 上创建节点
第一种方式,显然很 low 哦。干脆存到数据库算了。
第二种方式,会带来额外的解析成本,并且不容易做配置变更这样的逻辑,因为一个配置项的变更,意味着要重新在内存里生成整个 xml 文本,然后再写进本地配置文件里面。
下面说一说 seata 的配置机制,因为 seata 支持上述这么多主流的配置中心,但是实际上使用的时候,必须也只能用一个。
因此,一个 seata 相关的进程启动的时候,必然要从本地某个地方直到要用什么配置中心。
而实际上,seata 是先读取本地的一个配置文件 registry.conf,再决定要用什么样的配置方式的。
这个逻辑在我们上面提到的 API ConfigurationFactory.getInstance()
的源码里可以看到具体的细节。
下面是 buildConfiguration 方法:
private static Configuration buildConfiguration() {
ConfigType configType = null;
String configTypeName = null;
try {
// 这里读取的是本地 registry.conf 配置文件,获取配置中心的类型
configTypeName = CURRENT_FILE_INSTANCE.getConfig(
ConfigurationKeys.FILE_ROOT_CONFIG + ConfigurationKeys.FILE_CONFIG_SPLIT_CHAR
+ ConfigurationKeys.FILE_ROOT_TYPE);
configType = ConfigType.getType(configTypeName);
} catch (Exception e) {
throw new NotSupportYetException("not support register type: " + configTypeName, e);
}
if (ConfigType.File == configType) {
String pathDataId = ConfigurationKeys.FILE_ROOT_CONFIG + ConfigurationKeys.FILE_CONFIG_SPLIT_CHAR
+ FILE_TYPE + ConfigurationKeys.FILE_CONFIG_SPLIT_CHAR
+ NAME_KEY;
String name = CURRENT_FILE_INSTANCE.getConfig(pathDataId);
return new FileConfiguration(name);
} else {
return EnhancedServiceLoader.load(ConfigurationProvider.class, Objects.requireNonNull(configType).name())
.provide();
}
}
可以看到,seata 先读取本地的一个配置文件,默认是读取 registry.conf 文件,再从这个文件里面读取具体的配置类型。
那么 registry.conf 里面关于配置中心的配置是怎么样的呢,大概像这样:
config {
# file、nacos 、apollo、zk、consul、etcd3
type = "file"
nacos {
serverAddr = "localhost"
namespace = "public"
cluster = "default"
}
consul {
serverAddr = "127.0.0.1:8500"
}
apollo {
app.id = "seata-server"
apollo.meta = "http://192.168.1.204:8801"
}
zk {
serverAddr = "127.0.0.1:2181"
session.timeout = 6000
connect.timeout = 2000
}
etcd3 {
serverAddr = "http://localhost:2379"
}
file {
name = "file.conf"
}
}
buildConfiguration()
方法会先读取 type,再根据 type 继续读取这个 type 所对应的具体配置。
比如说,type 如果是 file 类型,那么就会读取 config.file.name,获取到本地配置文件的名字,然后再去读取具体的配置文件。
如果 type 是 zk,那么就会读取 config.zk.* ,获取到 zk 的地址信息,然后通过请求 zk 的方式获取存放在 zk 上的信息。
读取配置时,主要是通过 Configuration
接口去获取,Configuration
接口有许多不同的实现,例如 FileConfiguration
或者 ZooKeeperConfiguration
,分别代表不同的配置中心。
通过 buildConfiguration()
代码的逻辑可以看出,除了本地文件配置方式,其它配置方式都是采用 SPI 的服务发现机制进行扩展的。
不过这个 SPI 并不是直接用的 JDK 本身自带的 SPI 机制,而是 seata 自己实现的一种比 JDK SPI 功能更多的 SPI 机制,不过两者的主要思想是一样的。
阿里系的很多中间件软件都很喜欢用 SPI 机制,比如说 Dubbo,也是疯狂地 SPI。
这种自己实现的 SPI 机制的好处在于灵活性比较强,比如说可以自定义注解来标识实现类的优先级。
Configuration
接口定义的方法基本上是很基本的 getInt 或者 getString 这类方法,例如:
/**
* Gets int.
*
* @param dataId the data id
* @param defaultValue the default value
* @param timeoutMills the timeout mills
* @return the int
*/
int getInt(String dataId, int defaultValue, long timeoutMills);
/**
* Gets int.
*
* @param dataId the data id
* @param defaultValue the default value
* @return the int
*/
int getInt(String dataId, int defaultValue);
这里有个需要注意的地方是,这些方法都有两个不同的参数列表,多了一个 timeoutMillis。
因为读取配置已经不能假设在本地文件读取来,而是有可能通过网络去某个注册中心读取,因为需要经过网络,那么必然会有读取不到的情况,这个参数是用来限制配置读取的超时时间。
接下来以 FileConfiguration
这个实现来看看 seata 的一些配置解析细节。
首先,本地配置文件的读取, seata 引用的是 typesafe 公司的一个解析库叫做 Config,这个解析库支持的配置风格,就是包括上文展示的 registry.conf 的这种类似于 json 的风格。
其官方介绍是这样的:纯Java写成、零外部依赖、代码精简、功能灵活、API友好。支持Java properties、JSON、JSON超集格式HOCON以及环境变量。
不过,它有一个重要的功能未实现,那就是配置文件的写入,所以目前 seata 也没有配置变更后更新本地配置文件的功能。
可以看一下 ConfigOperateRunnable
的 run 方法:
@Override
public void run() {
if (null != configFuture) {
if (configFuture.isTimeout()) {
setFailResult(configFuture);
return;
}
try {
if (configFuture.getOperation() == ConfigOperation.GET) {
String result = fileConfig.getString(configFuture.getDataId());
configFuture.setResult(result);
} else if (configFuture.getOperation() == ConfigOperation.PUT) {
//todo
configFuture.setResult(Boolean.TRUE);
} else if (configFuture.getOperation() == ConfigOperation.PUTIFABSENT) {
//todo
configFuture.setResult(Boolean.TRUE);
} else if (configFuture.getOperation() == ConfigOperation.REMOVE) {
//todo
configFuture.setResult(Boolean.TRUE);
}
} catch (Exception e) {
setFailResult(configFuture);
LOGGER.warn("Could not found property {}, try to use default value instead.",
configFuture.getDataId());
}
}
}
赫然写着 TODO。
前面提到来读取配置有超时,那 seata 读取的时候怎么样做到检测超时呢,从下面这个方法入手:
@Override
public String getConfig(String dataId, String defaultValue, long timeoutMills) {
String value;
if ((value = getConfigFromSysPro(dataId)) != null) {
return value;
}
ConfigFuture configFuture = new ConfigFuture(dataId, defaultValue, ConfigOperation.GET, timeoutMills);
configOperateExecutor.submit(new ConfigOperateRunnable(configFuture));
return (String)configFuture.get();
}
每次读取配置时,都会读取封装成一个任务,扔给一个指定的线程池,再通过 ConfigFuture 去获取结果,ConfigFuture 是支持超时时间的设置的,只不过这里 FileConfiguration 没有在乎这个超时时间,毕竟超时是用来针对需要通过网络访问的第三方配置中心的。
这里用到了一个“异步转同步”的思想,也比较好地运用了 Future 机制。
此外,Configuration 接口还定义了配置变更监听器相关的接口,允许监听某个配置项的变更。不过这块实现还不是很完整,比如说 zookeeper 的监听器目前还没有任何一种实现。
SDK 执行逻辑分析
seata 客户端在处理事务逻辑的时候,实际上采用模板模式,委托给了 TransactionalTemplate 类去执行标准的事务处理流程,如下代码所示:
public Object execute(TransactionalExecutor business) throws Throwable {
// 1. get or create a transaction
GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();
// 1.1 get transactionInfo
TransactionInfo txInfo = business.getTransactionInfo();
if (txInfo == null) {
throw new ShouldNeverHappenException("transactionInfo does not exist");
}
try {
// 2. begin transaction
beginTransaction(txInfo, tx);
Object rs = null;
try {
// Do Your Business
rs = business.execute();
} catch (Throwable ex) {
// 3.the needed business exception to rollback.
completeTransactionAfterThrowing(txInfo,tx,ex);
throw ex;
}
// 4. everything is fine, commit.
commitTransaction(tx);
return rs;
} finally {
//5. clear
triggerAfterCompletion();
cleanUp();
}
}
在客户端的事务处理流程中,流程比较清晰,处理流程也不复杂,除了客户端自身采用了一些机制,其实 seata 把比较“重”的逻辑都放在了 server 端。
比如说,开启一个全局事务时,事务 id 如何生成,事务的信息如何存储,这些是不需要客户端的关心的。
当我们使用标准的 JDBC 规范来处理单库数据库事务时,代码几乎都和下面是同一个模板:
conn.setAutocommit(false);
try {
// 在这里使用 connection 进行 sql 操作;
conn.xxxxxx
//如果一切正常,则直接进行提交
conn.commit();
} catch (Exception e) {
conn.rollback();
}
虽然分布式事务和单机事务在“分布式” 和 “单机” 上区别很大,但在 “事务” 这个角度,却是相同的。
我们可以看到 seata 客户端的事务处理逻辑,跟单机事务的处理逻辑大同小异。
有差异的两个地方主要是:
- seata 中的全局事务如果提交失败,是不需要进行回滚,会有别的补救措施。
- 针对事务主体执行期间发生的异常,是不一定要回滚的,遇到有些异常可以直接提交,而有时候又可以直接忽略,不过这块跟具体场景有关系,默认出现了异常就是要回滚的。
事务信息
TransactionTemplate 处理事务时,必须知道事务的配置信息,包括:
- 超时时间
- 事务标识名称
- 回滚时机或者不回滚时机
这些信息主要通过在方法上的 @GlobalTransactional 注解携带进来,我们可以看一下这个注解包含了哪些属性:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Inherited
public @interface GlobalTransactional {
int timeoutMills() default TransactionInfo.DEFAULT_TIME_OUT;
String name() default "";
Class<? extends Throwable>[] rollbackFor() default {};
String[] rollbackForClassName() default {};
Class<? extends Throwable>[] noRollbackFor() default {};
String[] noRollbackForClassName() default {};
}
这些信息由 TransactionExecutor 携带给 TransactionTemplate。
TransactionExecutor 这个类除了携带事务信息 TransactionInfo,它还携带了事务的执行主体,即标识了 @GlobalTransactional 注解的方法的方法体。
调用 TransactionExecutor 的 invoke 方法,就相当于执行了事务的执行主体。
TransactionExecutor 是一个接口,我们通过观察它的匿名实现类的构造方式,就能正式上面说的观点,例如 GlobalTransactionInterceptor 的 handleGlobalTransaction 方法:
private Object handleGlobalTransaction(final MethodInvocation methodInvocation,
final GlobalTransactional globalTrxAnno) throws Throwable {
try {
return transactionalTemplate.execute(new TransactionalExecutor() {
@Override
public Object execute() throws Throwable {
return methodInvocation.proceed();
}
public String name() {
String name = globalTrxAnno.name();
if (!StringUtils.isNullOrEmpty(name)) {
return name;
}
return formatMethod(methodInvocation.getMethod());
}
@Override
public TransactionInfo getTransactionInfo() {
TransactionInfo transactionInfo = new TransactionInfo();
transactionInfo.setTimeOut(globalTrxAnno.timeoutMills());
transactionInfo.setName(name());
Set<RollbackRule> rollbackRules = new LinkedHashSet<>();
for (Class<?> rbRule : globalTrxAnno.rollbackFor()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (String rbRule : globalTrxAnno.rollbackForClassName()) {
rollbackRules.add(new RollbackRule(rbRule));
}
for (Class<?> rbRule : globalTrxAnno.noRollbackFor()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
for (String rbRule : globalTrxAnno.noRollbackForClassName()) {
rollbackRules.add(new NoRollbackRule(rbRule));
}
transactionInfo.setRollbackRules(rollbackRules);
return transactionInfo;
}
});
} catch (TransactionalExecutor.ExecutionException e) {
TransactionalExecutor.Code code = e.getCode();
switch (code) {
case RollbackDone:
throw e.getOriginalException();
case BeginFailure:
failureHandler.onBeginFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case CommitFailure:
failureHandler.onCommitFailure(e.getTransaction(), e.getCause());
throw e.getCause();
case RollbackFailure:
failureHandler.onRollbackFailure(e.getTransaction(), e.getCause());
throw e.getCause();
default:
throw new ShouldNeverHappenException("Unknown TransactionalExecutor.Code: " + code);
}
}
全局事务的创建
- 事务的发起者,需要创建全局事务,向 seata server 申请全局事务 id
- 事务的参与者,则需要获取已经创建好的全局事务,以便将自己注册为正确全局事务下的一个事务分支,认清自己的身份....
GlobalTransactionContext 这个类负责上述说的两个功能,事务发起者调用它提供的 getCurrentOrCreate 方法创建全局事务,事务的参与者调用它的 getCurrent 方法获取当前所处的全局事务(实际上参与者也是根据全局事务 xid 进行创建)。
通过这些方法获取到的是一个 GlobalTransaction 接口的具体实现实例。代表了一个全局事务。
GlobalTransaction 提供了事务的操作流程 api,例如基本的 begin、commit 和 rollback,另外还有事务的状态,还有 server 分配的全局事务 id;
接口定义如下所示:
public interface GlobalTransaction {
void begin() throws TransactionException;
void begin(int timeout) throws TransactionException;
void begin(int timeout, String name) throws TransactionException;
void commit() throws TransactionException;
void rollback() throws TransactionException;
GlobalStatus getStatus() throws TransactionException;
String getXid();
}
无论是参与者还是发起者,都需要在创建 GlobalTransaction 实例的时候,把它自己的身份讲清楚。由 GlobalTransactionRole 来定义这些角色。
全局事务 xid 的传播
全局事务的参与者和发起者是不要求部署在同一个操作系统环境下的,否则就分布式事务就谈不上分布式了。
因此,全局事务的发起者在向 server 注册了全局事务 id 后,必须通过某种方式把全局事务 xid 通过服务的调用链传下去。
如果 seata 是在 dubbo 的环境下运行的,那么通过 dubbo 调用方式(例如说泛化调用、或者可变参数、或者直接修改底层协议),就能够在服务调用时,将 xid 在整个服务调用链上传播。
在 seata 中,RootContext 这个类,就是用来保存参与者和发起者之间传播的 xid 的。传播流程大致如下:
- 发起者开启全局事务后,将 xid 塞进 RootContext 里
- 服务框架将 xid 沿着服务调用链一直传播,塞进每个参与者进程的 RootContext 里。
- 参与者发现 RootContext 里的 xid 存在时,它便知道自己处于全局事务中,并且知道 xid 是什么。
通过 GlobalTransactionContext 的 getCurrent 方法,我们可以看到这些事实:
/**
* Get GlobalTransaction instance bind on current thread.
*
* @return null if no transaction context there.
*/
private static GlobalTransaction getCurrent() {
String xid = RootContext.getXID();
if (xid == null) {
return null;
}
return new DefaultGlobalTransaction(xid, GlobalStatus.Begin, GlobalTransactionRole.Participant);
}
与 server 端如何交互
GlobalTransaction 定义的事务操作 api,其具体实现类需要真正与服务端通信了。以 begin 这个 api 为例,它的默认实现类 DefaultGlobalTransaction 是这样实现的:
public void begin(int timeout, String name) throws TransactionException {
if (role != GlobalTransactionRole.Launcher) {
check();
if (LOGGER.isDebugEnabled()) {
LOGGER.debug("Ignore Begin(): just involved in global transaction [" + xid + "]");
}
return;
}
if (xid != null) {
throw new IllegalStateException();
}
if (RootContext.getXID() != null) {
throw new IllegalStateException();
}
xid = transactionManager.begin(null, null, name, timeout);
status = GlobalStatus.Begin;
RootContext.bind(xid);
if (LOGGER.isInfoEnabled()) {
LOGGER.info("Begin new global transaction [" + xid + "]");
}
}
实际上除了一些必要的条件检查和其它附带操作,它把与服务端具体的通信委托给了 TransactionManager 去做,那我们再看看 TransactionManager 做了什么事:
@Override
public String begin(String applicationId, String transactionServiceGroup, String name, int timeout)
throws TransactionException {
GlobalBeginRequest request = new GlobalBeginRequest();
request.setTransactionName(name);
request.setTimeout(timeout);
GlobalBeginResponse response = (GlobalBeginResponse)syncCall(request);
if (response.getResultCode() == ResultCode.Failed) {
throw new TransactionException(TransactionExceptionCode.BeginFailed, response.getMsg());
}
return response.getXid();
}
逻辑也十分简单,就是发送一个请求,然后接收一个响应。
不得不说,seata 在代码逻辑的抽象和结构层次这方面做得很好。
钩子及异常处理
seata 客户端通过 TransactionHook 这个接口,在一个事务的处理过程中,允许为它的各个阶段添加钩子逻辑
public interface TransactionHook {
void beforeBegin();
void afterBegin();
void beforeCommit();
void afterCommit();
void beforeRollback();
void afterRollback();
void afterCompletion();
}
由于全局事务的提交和回滚依然是由可能失败的,比如说全局事务如果提交失败,意味着 undo_log 表里的数据目前是没有删除成功的,可能需要记录并在后面重试删除;
如果回滚失败,意味着当前这个全局事务属于异常结束,需要特殊处理、甚至人工介入。
seata 通过 FailureHandler 这个接口,提供了一个可扩展的点:
public interface FailureHandler {
void onBeginFailure(GlobalTransaction tx, Throwable cause);
void onCommitFailure(GlobalTransaction tx, Throwable cause);
void onRollbackFailure(GlobalTransaction tx, Throwable cause);
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。