分布式框架下,如何保证事物一致性一直是一个热门话题。当然事物一致性解决方案有很多种(请参考:分布式事物一致性设计思路),我们今天主要介绍TCC方案解决的思路。以下是参与设计讨论的一种解决思路,大家有问题请留言。
1、基本概念
TI:Transaction Interceptor,事务拦截器,位于dapeng容器的filterChain链中。
由于TI的逻辑会比较复杂, 不太适合在IO线程中操作
TM:Transaction Manager, 事务管理器,作为一个独立的服务存在。
事务发起方: 服务调用链或者说请求会话中第一个加入全局事务的接口方法,称为事务发起方。
事务参与方: 服务调用链或者说请求会话中除事务发起方的其它加入了全局事务的接口方法,称为事务参与方。
例如,对于服务a,b,c, d: client调用a.m1, a.m1调用b.m2以及c.m3, b.m2调用d.m4. 其中,a.m1以及b.m2,d.m4都声明为TCC事务,
那么在这次服务调用中, a.m1为事务发起方,b.m2,d.m4为事务参与方。
由事务参与方发起confirm或者cancel操作。
事务管理器负责confirm或者cancel失败后的重试。
在定义接口的时候, 需要加上以下注解,以表明该接口需要加入全局事务。@TCC(confirm="",cancel="", asyncCC="true")
该注解有3个可选参数, 其中, confirm代表该接口的confirm方法名字,cancel代表该接口的cancel方法名字,asyncCC代表CC阶段是否采用异步方式。
默认情况下,methodA的confirm方法名为methodA_confirm, cancel方法名为methodA_cancel, asyncCC默认为true
2、数据表结构
t_gtx
CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx` (
`id` INT(11) NOT NULL,
`gtx_id` INT(11) NOT NULL COMMENT '全局事务id,一般使用服务的会话id(sesstionTid)',
`status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '全局事务状态, 1:新建(CREATED);2:成功(SUCCEED);3:失败(FAILED);4:完成(DONE)',
`expired_time` DATETIME(0) NOT NULL COMMENT '超时时间。事务管理器的定时任务会根据全局事务表的状态以及超时时间去过滤未完成且超时的事务。默认为事务创建时间后1分钟。',
`async` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否异步confirm/cancel,默认是',
`created_time` DATETIME(0) NOT NULL COMMENT '创建时间',
`updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
`remark` VARCHAR(255) NULL COMMENT '备注, 每次状态变更都需要追加到remark字段。',
PRIMARY KEY (`id`),
INDEX `index_gtx_id` (`gtx_id` ASC))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '全局事务表'
t_gtx_step
CREATE TABLE IF NOT EXISTS `gtx_db`.`t_gtx_step` (
`id` INT NOT NULL,
`gtx_id` INT(11) NOT NULL COMMENT '全局事务id,一般使用服务的会话id(sesstionTid)',
`step_seq` SMALLINT(2) NOT NULL COMMENT '子事务序号',
`status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '子事务状态, 1:新建(CREATED);2:成功(SUCCEED);3:失败(FAILED);4:完成(DONE)',
`service_name` VARCHAR(128) NOT NULL COMMENT '服务名',
`version` VARCHAR(32) NOT NULL DEFAULT '1.0.0' COMMENT '服务版本号',
`method_name` VARCHAR(32) NOT NULL,
`request` BLOB NULL,
`confirm_method_name` VARCHAR(32) NULL,
`cancel_method_name` VARCHAR(32) NULL,
`redo_times` INT(11) NOT NULL DEFAULT 0,
`created_time` DATETIME(0) NOT NULL COMMENT '创建时间',
`updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
`remark` VARCHAR(45) NOT NULL DEFAULT '' COMMENT '备注, 每次状态变更都需要追加到remark字段。',
PRIMARY KEY (`id`)),
INDEX `index_gtx_id` (`gtx_id` ASC))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '全局事务流程表'
t_gtx_journal 对于参与分布式事务的服务接口,需要在本地有个事务流水表 本流水表可用于幂等(例如confirm或者cancel的重试,如果状态是完成,那么就不需要执行confirm/cancel逻辑),或者在confirm/cancel逻辑中找到之前try阶段修改过的记录。
该流水表跟业务密切相关且应用在业务逻辑上(框架本身不操作该表),可由业务团队自行设计(甚至表名也可以自定义)。
下面给出一个参考实现 (例如orderDb):
CREATE TABLE IF NOT EXISTS `mydb`.`t_gtx_journal` (
`id` INT(11) NOT NULL,
`gtx_id` INT(11) NOT NULL COMMENT '全局事务id',
`step_id` INT(11) NOT NULL COMMENT '子事务id',
`biz_tag` VARCHAR(45) NOT NULL COMMENT '本次全局事务操作的本地业务表名字',
`biz_id` INT(11) NOT NULL COMMENT '本次全局事务操作的本地业务记录id',
`status` SMALLINT(2) NOT NULL DEFAULT 1 COMMENT '本地子事务状态, 可在confirm/cancel阶段用于判断try阶段是否成功 1:新建(CREATED);4:完成(DONE)',
`old_values` VARCHAR(255) NULL COMMENT '修改前的值。可选,用于在cancel阶段恢复原始值。例如修改字符串的操作。格式为:fieldName:fieldValue fieldName:fieldValue',
`created_time` DATETIME(0) NOT NULL COMMENT '创建时间',
`updated_time` TIMESTAMP(0) NOT NULL DEFAULT DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP(0) COMMENT '更新时间',
`remark` VARCHAR(255) NOT NULL DEFAULT '' COMMENT '备注, 每次状态变更都需要追加到remark字段。',
PRIMARY KEY (`id`))
ENGINE = InnoDB
DEFAULT CHARACTER SET = utf8mb4
COMMENT = '子事务的本地流' /* comment truncated */ /*水表。 当本地事务成功时, 由本地业务*/
本地事务流水是否需要创建,需要创建多少,是否记录oldValues,根据业务性质去定。 例如, 创建订单的时候,会创建一个主单若干个子单。 这时候, 只需要插入一条本地事务流水(跟主单挂钩)即可。 因为在confirm或者cancel中, 根据主单id可以招到所有的子单id。
3、案例描述
这里以订单创建为例。
用户创建订单,同时扣除库存。
其中订单、库存分别为两个不同的服务。同时, TM也是一个单独的服务。
本流程有2个业务服务参与,分别是订单服务的创建订单接口以及库存服务的库存扣减接口。
业务主流程如下:
1、客户端调用orderService.createOrder, 发起订单创建流程
2、orderService调用stockService.decreaseStock, 扣减库存
3、orderService创建订单,并返回客户端。
3.1. 客户端发起订单创建的操作
对应时序图的No.1调用
参数
3.2、全局事务的Try阶段
订单服务的全局事务拦截器(TI)收到请求后, 识别到目标方法带有TCC标识,即进入Trying
阶段。
3.2.1、订单服务开启全局事务
TI向事务管理服务请求开启全局事务,对应时序图的No.2。 tm.beginGTX(params)
全局事务开启失败的话, 返回Err-Gtx-001: Begin gtx err。
gtxId通过TransactionContext传过去(如果存在的话), params可直接用bytes
3.2.2、事务管理器处理订单服务请求
对应时序图的No.3/4/5
事务管理器根据TransactionContext是否含有gtxId去决定调用方是事务发起者还是事务参与者。 这里,orderService是事务发起方, 那么: 1、TM首先生成全局唯一的gtxId,通过createGTX(gtxId)方法创建一个全局事务(插入一条全局事务记录到t_gtx表中,状态为新建) 2、通过createStep(txId, params)方法创建一个子事务日志(插入一条子事务记录到t_gtx_step表中, 状态为新建)
全局事务开启, 操作成功后返回(gtxId, stepId),继续下一步,否则失败后直接返回调用方,由调用方决定是继续还是回滚(在这个案例中, 这里的调用方是client)。
3.2.3、订单服务的TI转发请求到具体的业务服务方法
对应时序图中的No.6/7 全局事务开启成功后, TI转发请求到业务服务。这里为orderService.createOrder
。
在这个方法中, 首先调用库存服务的扣减库存接口:stockService.decreaseStock
如果全局事务开启失败,那么TI会直接报错返回给调用方(Err-Gtx-001: begin gtx error)
3.2.4、库存服务开启全局事务
对应时序图的No.8
同3.2.1,库存服务的TI收到扣减库存请求后,开启全局事务: `tm.beginGTX'
如果本子事务在加入全局事务时失败, 那么由调用端决定是否继续执行全局事务。 如果继续执行全局事务的其它子事务, 那么后续在CC阶段,本子事务将不会confirm或者cancel
TimeOut怎么办 建议事务发起者做cancel处理。
3.2.5、事务管理器处理库存服务请求
对应时序图的No.9/10
事务管理器通过gtxId发现全局事务已经开启,那么该请求来自事务参与方而不是发起方。 这时候,直接通过createStep
插入一条子事务日志到t_gtx_step表中即可,并返回(gtxId,stepId)。
3.2.6、库存服务本地逻辑处理
对应时序图的No.11/12/13
TI开始全局事务成功后, 转发扣减库存请求给具体的业务方法。 库存服务执行本地事务(库存余额扣减,冻结库存增加)后返回到TI
同时,需要插入一条本地事务流水表到t_gtx_journal中,
INSERT INTO `t_gtx_journal` (`id`, `gtx_id`, `step_id`, `biz_tag`, `biz_id`, `status`, `old_values`)
VALUES (id, gtxId, stepId, 't_stock', stockId, 1, NULL);
本案例不需要记录oldValues, 因为根据接口的入参可以推算出oldValues
3.2.7、订单服务本地业务逻辑处理
对应时序图的No.14/15/16
订单服务根据库存扣减的结果,决定是继续往前走还是失败回退。
如果继续往前走的话,就完成本地事务后返回结果给订单服务的TI; 如果失败回退的话,就把失败信息返回给订单服务的TI。
至此,Trying阶段完成。
根据本阶段的结果, TI将会进入TCC的confirm
(成功)或者cancel
阶段(失败)
3.3、confirm阶段
对应序列图的No.17~30 理论上, Trying阶段成功的话,confirm阶段一定能成功(最终一致).
Confirm操作由TI发起,而具体的逻辑由TM控制。
3.3.1 事务管理器的confirm操作
首先事务管理器根据gtxId
得到全局事务记录以及子事务记录集合(gtx_steps
)。
然后通过独立的事务,把全局事务状态更新为"成功"
然后按照子事务的seq从小到大的顺序,依次异步调用子事务的confirm方法。 在异步回调中根据调用结果,如果confirm成功,那么更新子事务的状态为"完成"
只有全部子事务的状态为完成,全局事务状态才能更新为完成。
TI发起confirm操作后,不管本次confirm操作是否成功, 都返回成功给client。
3.4、cancel阶段
对应序列图的No.31~44 本阶段跟confirm阶段逻辑类似,但是子事务的执行顺序相反。
TI发起cancel操作后,不管本次cancel操作是否成功, 都返回失败给client。
3.5、confirm/cancel阶段的异常处理
TM通过定时器,定时扫描全局事务日志表中状态为非完成的记录(5分钟前),再次执行confirm/cancel操作。
4. 业务场景
TCC场景:
4.1. 客户端调用单独的TCC服务
4.1.1 正常流程
try成功,confirm成功
- try阶段:
1.1 t_gtx, t_gtx_step插入事务日志成功, 状态皆为新建
1.2 tccServiceA本地事务成功
- confirm阶段 2.1 TM调用tccServiceA成功,更新t_gtx, t_gtx_step成功,状态为完成。
try失败,cancel成功
- try阶段:
1.1 t_gtx, t_gtx_step插入事务日志成功, 状态皆为新建
1.2 tccServiceA本地事务失败
- cancel阶段 2.1 TM调用tccServiceA成功,更新t_gtx, t_gtx_step成功,状态为完成。
4.1.2 异常流程
try成功,confirm阶段或者cancel阶段失败 那么后续由TM定时任务继续重试。
4.1.3 异常流程
try阶段TI插入事务日志失败(Err-Gtx-001: begin gtx error) 如果是事务发起方(本案例), 那么TI直接返回Err-Gtx-001,本次服务调用失败。 如果是事务参与方, 那么TI直接返回Err-Gtx-001,由调用方决定是否继续下一个子事务流程。 同时,本子事务流程不参与cancel/confirm操作
4.2. 客户端先后调用2个TCC服务
这时候, 这两次服务调用分别构成一个全局事务, 是两个互不相关的全局事务
4.3. 客户端调用TCC服务a,服务a再调用TCC服务b
4.4. 客户端调用TCC服务a,服务a再分别调用TCC服务b以及TCC服务c
4.5. 客户端调用TCC服务a,服务a调用TCC服务b,服务b再调用TCC服务c
问题
定时器发起的全局事务, 不经过TI。。。
定时器可通过客户端的方式调用服务,而不是直接调用action。