JfireTCC概要设计
背景
名词定义
设计理念
数据一致性保证
宕机恢复
完成阶段的重试
TCC事务的状态变化
数据结构设计
xid
TccOperation
TccInvoke
LocalTransaction
TccTransaction
TccConnection
RemoteResource
重点功能设计
事务管理器
TCC事务
远程调用
完整调用流程
日志设计
数据库日志表
文件日志
宕机恢复流程
异常处理
完成阶段处理异常
性能优化
多线程执行完成阶段
多节点异步应答完成阶段执行请求
框架限制
TCC调用的完成阶段不能再调用远程方法
背景
略
名词定义
本地事务
JDBC的单机事务
TCC事务
类比于本地事务的概念,TCC事务意在分布式范畴下,保证数据的一致性。也就是在一个TCC事务的作用域下,多个节点数据具备最终一致性。
TCC管理器
用于启动,提交TCC的管理者。
TCC操作
TCC的理念将一个完整的业务操作区分为两个阶段:尝试阶段和完成阶段。将尝试阶段和完成阶段涉及的操作总和一起称之为TCC操作。其中根据根据尝试阶段是否出现错误,完成阶段有两种不同的可能性:确认、取消。
TCC调用
TCC操作是一个静态的注册数据。而TCC调用则是描述每一次调用的对象。其中会包含本次调用涉及的TCC操作信息,入参数组,调用者对象等。
协调者节点与参与者节点
在分布式应用中,在本地上自行开启TCC事务的应用节点,在整个TCC事务的处理过程中承担协调者的角色。经由网络传递的xid进而开启的TCC事务的应用节点,承担着参与者的角色。
协调者与参与者的区别在于:协调者能够自主启动完成阶段,而参与者只能被动等待外部请求,才能启动完成阶段。
应用标识符
TCC在使用过程中,会涉及到远程调用。因此每一个应用实例都需要有一个唯一的标识符,具备两个作用:
- 标识自身。
- 供上级节点判断是否相同实例
标识符的格式包含三个数据:{port}:${applicationName},例如192.168.30.61:60:userService,标识符是大小写敏感的。
设计理念
数据一致性保证
在调用一个需要TCC事务保证一致性的业务方法前,首先启动一个TCC事务。并且将这个业务方法中涉及到所有的资源(该TCC调用本身,本地其他TCC调用,远程调用)都注册到TCC事务。当业务方法结束时,执行TCC事务的完成阶段。根据业务方法是否抛出异常,可以标记TCC事务关联的当前本地事务是否提交成功,进而决定是执行TCC事务的确认分支还是取消分支。
为了保证TCC事务自身的数据正确性以及宕机恢复,在所有会导致TCC事务状态变化的操作执行前,都需要优先写入日志。同时,所有的TCC调用本身都需要在本地事务的作用域下。一个TCC事务本身就是由多个本地事务组成。为了能够明确业务方法是否提交成功,每开启一个本地事务前,都会分配一个xid并写入日志,在本地事务提交前,将xid写入到日志表中,通过本地事务来确保业务和日志的一致性。这样在宕机之后就可以根据数据日志表的信息得知业务操作的数据是否成功提交。
宕机恢复
在尝试阶段,TCC事务涉及到的相关操作是顺序写入日志的,因此以顺序读取的方式,可以将TCC事务对象的大部分信息恢复出来。
本地事务开启前都需要向TCC事务注册本地事务,因此localTxList
列表是可以完全恢复的。通过查询日志表中指定xid的记录是否存在,可以判断出localTxList
中的本地事务是否提交成功。如果LocalTxList[0]提交成功,意味着完成阶段应该执行确认分支,反之则是取消分支。
如果是确认分支,遍历tccInvokeList
,查询tccInvoke
的completeStageXid
值是否在日志表中,就可以判断确认方法是否成功执行。
如果是取消分支,遍历tccInvokeList
,查询tccInvoke
关联的本地事务是否提交,如果没有提交,意味着该tccInvoke
可以认为回滚成功(因为本地事务没有提交,数据本身也没有写入)。如果提交了,则查询日志表中是否存在completeStageXid
值,就可以判断取消方法是否已经执行成功。
至此,一个没有执行完毕的TCC事务就完全恢复完毕了。
完成阶段的重试
尝试阶段完成后,就进入完成阶段。为了保证数据的一致性,完成阶段是必须要全部正确执行的。因此一旦完成阶段的方法执行出错,则应该不断重试,直到成功。或者在无法重试到成功的情况下,通知应用,进入人工处理流程。而不能静默的放任不管。
TCC事务的状态变化
从逻辑上来说,TCC事务的状态应该有如下变化节点:
- 初始化:TCC创建后就处于这个状态。
- 标记为提交/回滚:这个是一个中间状态,用于标识后续的完成步骤前进分支。
- 结束:TCC事务中涉及到的TCC调用和远程资源的完成阶段方法都执行完毕后,处于该状态。
每一次状态变化都需要写入日志,用于宕机后的重启恢复。
初始化就不必说了,是一个TCC事务的开始。
在多个应用上同属一个全局事务的分支事务,一旦进入这个标记为提交/回滚,无需等待上级节点的请求,即可完成后续阶段。
结束状态写入日志,意味着该TCC事务对象可以将自身写入清除日志后安全销毁。
数据结构设计
xid
类比JTA中的xid概念,一个xid由两个部分的数据组成:
- globalID:全局唯一ID,用于标识一个TCC事务,必不为空。
- branchID:分支ID,用于标识参与到TCC事务中的本地事务,为空则意味着该xid代表TCC事务本身。
TccOperation
静态的信息对象,用于存储一个TCC操作中会涉及的相关方法。该信息一旦生成,整个应用生命周期内不会改变。
//TCC操作使用的接口类对象Class tccInterface;//TCC操作使用的方法Method tccMethod;tranaint Class confirmClass;tranaint Class cancelClass;
TccInvoke
动态的调用信息,用于存储一次TCC调用的相关信息。
//尝试阶段对方法调用使用的参数Object[] params;//本次TCC调用对应的TCC操作对象TccOperation operation;//该TCC调用的完成阶段的确认分支方法的本地事务是否提交成功tranaint boolean confirmed;//该TCC调用的完成阶段的取消分支方法的本地事务是否提交成功tranaint boolean canceled;//该TCC调用完成阶段使用的xid。Xid completeStageXid;LocalTransaction localTx;
LocalTransaction
//当前本地事务之前的本地事务在TCC事务的localTxList列表中的下标。事务的传播要求可能会遇到方法的调用要求开启新的事务,此时就需要当前的事务设置为新建事务的前向值,并且将新建事务作为当前事务int preLocalTxIndex;//自身在列表中的下标int txIndex;//该本地被分配到xid,其中globalID的值与TccTransaction中的一致Xid xid;transient boolean commited;transient boolean rollbacked;
TccTransaction
//当前生效的本地事务。LocalTransaction currentLocalTx;List LocalTxList;List tccInvokeList;List remoteList;//远端调用的执行xid集合Set remoteCallXids;//0x00代表协调者,0x01代表参与者byte role;
TccConnection
static ThreadLocal localTccInvoke;TccTransactionManager manager;
用于TCC相关操作的链接,其内部功能只是物理链接的直接透传。但是在调用方法commit
时,需要做两个判断:
- 通过属性
localTccInvoke
判断当前线程是否绑定了TccInvoke
对象,如果有,则将TccInvoke
的completeStageXid
属性写入日志表,并且提交事务。 - 情况1不存在时,通过Tcc事务管理器判断当前是否存在Tcc事务。如果存在的话,则将当前生效的本地事务关联的xid以日志形式写入日志表。用于保证本地操作的数据一致性。
RemoteResource
//完成阶段是否执行成功boolean completed;
用来抽象在TCC调用过程中执行远程调用涉及到的远端资源。这是一个接口,具体的实现类和使用的远程访问技术相关,接口应该包含几个重要的接口:
isSameInstance
一次TCC事务中可能会向同一个应用实例发起多次请求,为了避免后续的complete请求多次发往同一个实例,因此该接口需要实现一个重要的方法isSameInstance
。该方法用于判断两个RemoteResource
是否同一个实例。这个方法主要是在将远端资源注册到TCC事务中使用。
commit
用于向远端资源请求执行commit操作。携带的参数是关联的TCC事务的xid。
cancel
与commit方法相似,只不过执行的远端的cancel操作。
重点功能设计
事务管理器
新建协调者TCC事务
在调用一个TCC操作前,如果当前线程没有绑定TCC事务,则需要新建协调者TCC事务。
通过TCC事务管理器启动一个TCC事务。初始化一个TCC事务的时候会分配一个不包含branchID的xid。并且该TCC事务与当前的线程进行绑定。
事务创建后应该放入事务仓库。
新建参与者TCC事务
应用收到远端传递的TCC事务信息,则需要在本地新建一个参与者TCC事务(也就是角色为参与者节点)。参与者TCC事务的xid由外部参数传入,而非自己新建。
事务创建后应该放入事务仓库。
TCC事务
注册本地事务
TCC事务初始化完毕后,其currentLocalTx
属性为null。如果业务方法需要开启本地事务,则在开启之前需要调用TCC事务的注册本地事务方法。新注册的本地事务会将当前的本地事务设置为自身的前向值,然后修改当前本地事务为新注册的本地事务。这样的数据结构就支持了业务方法调用中,新开一个本地事务的需求。
注册本地事务时,将该数据信息添加到列表localTxList
中。
注册本地事务时,会给本地事务分配一个xid,该xid的globalID与TCC事务的xid相同。注册本地事务,改变了TCC事务的状态,需要写入日志。
注册本地TCC调用
在调用一个TCC操作之前,需要先将该调用信息注册到TCC事务。如果当前线程还没有绑定TCC事务,则抛出异常。
入参是一个TccInvoke
对象,将该对象和当前本地事务对象关联起来。将tccInvoke
对象放入列表tccInvokeList
中。
同时,这里因为对TCC事务产生了影响,因此要写入日志。
注册远端资源
在TCC操作内执行远程调用前,首先需要注册远端资源到TCC事务。注册时,需要遍历TCC事务的remoteList
属性,如果有任意已经入列的RemoteResource
的isSameInstance
返回true,则意味着相同的远端资源已经入列,不用添加到列表中。
添加成功后,写入事务日志。
提交当前本地事务
本地事务提交成功后,将currentLocalTx
指向的当前本地事务的commited设置为true。将currentLocalTx
属性设置为其前向值,也就是pre的值。
回滚当前本地事务
本地事务回滚成功后,将currentLocalTx
指向的当前本地事务的rollbacked设置为true。将currentLocalTx
属性设置为其前向值,也就是pre的值。
标记为提交/回滚
当最外围的业务方法完成后,根据是否抛出异常,可以更新TCC事务的状态,将状态更新为标记为提交/回滚,并写入事务日志。
一旦TCC事务进入这个状态,后续就可以执行完成阶段直到成功为止。
执行完成阶段
完成阶段有两种可能性:确认和取消。无论是确认分支还是取消分支,完成阶段的执行都有共同的几个步骤:
- 遍历本地参与者,执行其完成阶段方法。
- 遍历远端参与者,执行其完成阶段方法。
- 更新TCC事务状态,写入日志。
需要注意,如果在完成阶段执行时异常,则应该将事务放入事务管理器的重试集合,通过后台的定时任务重新执行完成阶段,直到成功为止。
执行确认分支
执行本地参与者的完成阶段方法
遍历tccInvokeList
列表,从TccInvoke
中取得对应的TccOperation
对象,依靠该对象找到执行确认分支对应的类。依靠Bean容器获取该类对应的Bean实例,并且使用这个Bean实例和尝试阶段的方法入参,执行确认分支的方法。
由于完成阶段可能被反复重试,因此在执行本地参与者的完成阶段方法之前,首先需要确定该完成方法是否已经成功执行过(可以在对象中记录属性,也可以通过查询数据库日志的方式)。
执行远端参与者的完成阶段方法
遍历TCC事务的remoteList
列表,执行方法RemoteResource.commit
,这个方法的内在逻辑就是携带上TCC事务的xid,发送到远端,要求执行commit操作。
更新TCC事务状态并写入日志
步骤一和步骤二都正确执行完毕,更新TCC事务状态为结束,并且将状态变化写入事务日志。
执行取消分支
取消分支和确认分支的逻辑是一模一样的,区别主要有两个:
- 如果本地参与者的本地事务提交失败,被回滚了,则取消方法不必执行,因为本身也没有生效。可以直接将
tccInvoke
对象标记为回滚成功。 - 执行接口方法的Bean实例是取消类的Bean实例。
远程调用
在TCC作用域下,在尝试阶段,如果业务方法需要调用远端应用,首先是需要注册远端资源到TCC事务中。然后在实际执行rpc方法时,还需要携带上TCC执行指令的相关信息。而在业务方法完成,最外围本地事务提交或者回滚后,进入完成阶段,也需要发送TCC事务提交/回滚指令到远端。
每一个远程的TCC指令共同需要包含的信息有:
- TCC事务的xid。
- 传播节点的标识符。
TCC执行指令
远端应用收到TCC开启指令时,在调用业务方法之前,先从TCC事务仓库中获取指令中xid对应的TCC事务对象。如果不存在,则需要通过事务管理器新建参与者TCC事务对象。
很多RPC框架在失败的时候具备自动重试功能,这就导致服务提供端在处理TCC执行指令时出现并发竞争的情况。为了处理并发竞争,TCC执行指令额外携带一个参数:本次调用的xid。该xid全局唯一,且每一次远程调用都会唯一分配,服务提供者可以根据这个参数进行防重控制,具体的处理流程如下
由于上级节点需要知道远程调用的结果(是否异常)才能决定是否执行后续步骤,以及完成阶段确认分支和取消分支的选择,因此这个处于TCC事务作用域下的远程调用不能以异步的形式执行(异步情况下就无法将处理结果回传)。
TCC回滚指令
当协调者节点的TCC事务的尝试阶段完成后,则开始完成阶段的内容。如果尝试阶段出错,则协调者节点的TCC事务状态会更新为标记为回滚并执行完成阶段取消分支。此时会向所有的远端应用发送回滚指令。
远端应用收到回滚指令时,首先更新TCC事务状态为标记为回滚并写入日志,然后立刻发送成功响应。后续的回滚操作放入到异步线程执行即可。
处理TCC回滚指令可能遇到几种异常。
异常一:收到重复的TCC回滚指令
RPC框架的重试,协调者事务的完成阶段重试,都会导致参与者节点收到重复的TCC回滚指令。这会导致在参与者节点上的并发竞争。
处理并发的两种思路:加锁和CAS。
假设采用CAS,考虑如下场景
假设线程A和B都收到提交指令。其中A赢得CAS竞争,则B直接返回成功,而A尚未写入日志就宕机崩溃。上机节点收到B的响应,后续不会再与参与节点通信。而参与节点重启恢复后,因为没有写入日志,仍然会等待上级节点的指令,导致这个事务无法提交。多节点的数据就出现不一致了。
因此,采用cas方案,无论是否赢得竞争,都需要写入日志。而额外日志的设计又会增大宕机恢复的难度。因此简单实用加锁模式。简单描述如下:
- 参与者节点收到提交或回滚指令后,从事务仓库获取事务对象,以TCC事务进行加锁。
- 如果当TCC事务的状态是初始化,则变更为标记为提交/回滚,并且写入日志。使用异步任务去执行该事务的完成阶段。
- 如果TCC事务的状态不是初始化,不做任何操作。
- 解除锁定,响应ok给调用端。
异常二:执行指令尚未执行就收到回滚指令
由于网络的不确定性,上级节点发送执行指令时网络超时,使得上级节点认为需要回滚,又发送了回滚指令。在本节点就出现开启指令对应的业务方法尚未执行结束就收到回滚指令的情况。
回滚指令和执行指令都需要对TCC事务对象加锁,因此虽然收到回滚指令的时候尝试阶段尚未完成,但是能够执行回滚指令的时候尝试阶段却是已经完成了,因此不会影响到参与者节点这端的正确性。而对于上级节点而言,可能出现的情况就是回滚指令的处理超时了,这个可以通过重试来进行确认,也不会影响上级节点的正确性。
TCC提交指令
当协调者节点的TCC事务的尝试阶段完成后,则开始完成阶段的内容。如果尝试阶段正常且事务提交成功,则协调者节点的TCC事务状态会更新为标记为提交并执行完成阶段确认分支。此时会向所有的远端应用发送提交指令。
远端应用收到提交指令时,首先更新TCC事务状态为标记为提交并写入日志,然后立刻发送成功响应。后续的提交操作放入到异步线程执行即可。
TCC提交指令和TCC回滚指令一样,都是完成阶段指令,因此会遇到相似的异常情况,处理的手段也是相同的。
TCC事务存在超时
可能会出现一种异常,上级节点发送了TCC执行指令,但是后续再也没有发送完成阶段的指令,造成这个事务一直存储在事务仓库中。框架层面应该在后台定时扫描,将超时到一定程度的事务自动执行取消分支流程。
完整调用流程
一次完整的调用流程,会涉及到多方的参与,时序图如下
日志设计
数据库日志表
数据库表的日志主要是用于业务操作的防重。通过将业务操作和日志表在一个本地事务中进行提交的方式,通过判断日志表即可确认业务操作的事务是否成功提交。
日志表设计如下
字段名 | 类型 | 非空 | 说明 |
---|---|---|---|
globalID | String | 是 | xid中globalID的16进制表达 |
branchID | String | 否 | xid中branchID的16进制表达 |
createTime | datetime | 是 | 提交时间 |
文件日志
除了数据库日志表用于和业务操作一起确认数据的一致性外,还需要其他的相关日志来追踪TCC事务的状态变化。目前暂时以文件日志的方式来作为日志。
日志格式
日志由一个个的记录构成。为了保证扩展性和性能,每一个记录的格式都各自不同,不同的记录格式使用不同的序列化工具进行读取和写入。
大的方向上,事务日志的记录格式如下
序号 | 长度 | 内容 |
---|---|---|
1 | 1 | 用于标明内容体的格式 |
2 | 不限定 | 序号1的值可以明确当前值的格式,获取对应的处理器进行读取即可 |
序号1的取值可能有:
1:TCC事务创建
序号 | 长度 | 内容 |
---|---|---|
1 | 8 | TCC事务中的xid的globalID的内容 |
2 | 1 | 角色,0:协调者,1:参与者 |
3 | 2 | 序号4的长度 |
4 | - | 传播者标识符,如果序号3的值为0,则该项不存在 |
2:注册本地事务
序号 | 长度 | 内容 |
---|---|---|
1 | 8 | 本地事务分配的xid的globalID |
2 | 8 | 本地事务分配的xid的branchID |
3 | 1 | 本地事务的前向事务的下标。如果不存在,则是-1 |
4 | 1 | 本地事务自身的下标。 |
3:注册本地TCC调用
序号 | 长度 | 内容 |
---|---|---|
1 | 8 | TCC事务的xid |
2 | 2 | 有符号short值,用于表达序号3的长度 |
3 | - | 方法签名的utf8内容,方法签名格式为a.b#c,其中a.b为package,c为方法名。这里需要特别注意,这个方法签名写入的类名部分,需要是@TCC注解所在的类,不能用接口的名称,否则后续宕机恢复时就没有足够的信息可以恢复。 |
4 | 1 | 关联的本地事务在TCC事务中的下标 |
5 | 8 | 分配的完成阶段xid的branchID |
6 | 4 | 序号8的长度 |
7 | - | 入参数组的序列化值 |
4:注册远端资源
序号 | 长度 | 内容 |
---|---|---|
1 | 8 | TCC事务的xid |
2 | 2 | short值,标识序号3的长度 |
3 | - | 远端资源的标识符 |
5:事务状态更新
序号 | 长度 | 内容 |
---|---|---|
1 | 8 | TCC事务的xid |
2 | 1 | 事务状态 |
日志归档
框架在运行过程中,会不断将事务信息写入事务日志。随着时间的增长,因为事务的完结,事务日志中的无效信息越来越多,如果一直不清理的话,会拖慢宕机后的恢复。此外,随着TCC事务的进行,日志表中的数据也在增长,随着事务的结束,这些数据就变得无效,需要清理。因此在框架的运行过程中,在满足触发条件的情况下,框架会创建新的事务日志文件,并且将旧的事务日志中的数据整理归档后形成紧密的归档日志,清理已经无效的事务信息。归档日志由多个归档记录构成,归档记录的格式如下
实际上,一个归档记录是将事务日志中分散的信息聚合在了一起,以完整的形式留存下来。在一个事务日志文件中,已经记录了状态为结束的事务,是可以安全删除的,不需要在记录在归档日志中。归档日志中只聚合当前事务日志文件中尚未结束的事务信息。
在写事务日志过程,进行日志归档的流程分为两个步骤:
- 当前写日志逻辑中触发了归档条件,启动归档工作者线程。
- 归档工作者线程进行日志归档。
首先来看启动归档工作者线程,如下
然后是归档工作者线程的归档流程,如下
宕机恢复流程
如果应用宕机,在重启之后,首先需要进行的是宕机恢复。宕机恢复的主要内容是:读取归档日志和事务日志文件,将运行中的TCC事务全部恢复。
宕机恢复的逻辑可以放在TCC事务管理器的初始化逻辑中,因为TCC事务管理器是整个TCC服务对外的入口。
宕机恢复以目录中是否存在Archiving.log文件为分界点。存在则意味着之前的归档工作尚未完成,需要首先将之前中断的归档工作完成后再将当前的事务日志也进行归档处理。如果不存在,则意味着之前的归档工作已经结束,归档文件已经生成了,那么首先应该将归档工作之后的遗留文件删除,然后将当前的事务日志进行归档处理。具体的执行流程如下
步骤一则较为复杂,以目录中是否存在Archiving.log文件为分界点。存在则意味着之前的归档工作尚未完成,需要首先将之前中断的归档工作完成后再将当前的事务日志也进行归档处理。如果不存在,则意味着之前的归档工作已经结束,归档文件已经生成了,那么首先应该将归档工作之后的遗留文件删除,然后将当前的事务日志进行归档处理。具体的执行流程如下
异常处理
完成阶段处理异常
TCC事务状态变更为标记为提交/回滚后,就可以重复执行完成阶段流程。但是如果完成阶段流程执行出错,则需要不断重试(或者留存记录,通知用户)。如果完成阶段执行出错,需要将TCC事务放入事务仓库的异常事务集合,由后台定时任务发起重试指令。
当后台任务线程决定对一个TCC事务进行完成阶段的重试时,应该将该事务从事务仓库删除,以避免潜在的并发执行可能性。
性能优化
多线程执行完成阶段
完成阶段主要有:
- 向所有的远端发送对应的指令。
- 本地TCC调用执行完成阶段方法。
这两者都需要遍历列表,针对每一个元素进行操作。可以通过多线程进行并行化进行加速。而主线程可以通过CountDownLatch
的形式进行等待或者异步通知的形式进行告知。
由于涉及到多线程,因此TccInvoke
中的confirmed
或canceled
属性需要用volatile
修饰,同理,RemoteResource
的completed
也是如此。
使用CountDownLatch
会导致主线程堵塞,因此使用异步通知的方式会更好一些。无论本地还是远程,在执行完对应的流程后都进行通知,只需要遍历所有的本地TCC调用和远程调用的完成情况,最后一个完成的线程可以看到全部完成的视图。而这个线程就可以执行最终的写入清除日志的动作。
不过上述的处理方式有一种问题,如果完成阶段出错,TCC事务会被放入事务仓库,并且由定时任务线程进行重试。对于单一的本地参与者或者远端资源而言,其完成阶段不能并发执行,否则会导致数据错误。因此,TCC事务的完成阶段的重试有一个前提,就是所有的本地参与者和远端资源都没有正在执行中。解决这个问题,可以使用一个标识数字,用于标记当前有多少正在处于执行中的本地参与者和远端资源。标识数字会被多线程竞争,从这个角度考虑,可以让TCC事务对象继承AtomicInteger类,
在准备执行完成阶段之前,首先判断当前的标识数字是否为-1,如果不为-1则放弃。为-1的话,则统计当前需要的操作数(尚未成功的本地参与者个数和远端资源个数)。使用cas方式更新,如果更新成功则获得执行权。
在本地参与者和远端资源的执行方法中都放入监听器,当执行完毕的时候都对标识数字进行减一操作。看到0的线程可以根据当前TCC事务内本地参与者和远端资源的完成情况决定,如果全部都完成了,则更新事务的状态,写入事务日志和清除日志。如果还有未完成的话,则将事务再次放入事务仓库的异常事务集合,供下一次尝试。
多节点异步应答完成阶段执行请求
由于TCC事务状态更新为标记为提交/回滚后,后续阶段可以无副作用重试,因此参与者节点在收到上级节点的提交/回滚指令后,一旦写入日志就可以先返回响应。而将完成阶段的执行放入到异步任务线程上,这样可以降低整个链路的延迟。