作者:朱晋君
大家好,我是君哥。
今天来聊一聊阿里巴巴 Seata 新版别(1.5.1)是怎样处理 TCC 形式下的幂等、悬挂和空回滚问题的。
TCC 回忆
TCC 形式是最经典的分布式业务处理方案,它将分布式业务分为两个阶段来履行,try 阶段对每个分支业务进行预留资源,假如一切分支业务都预留资源成功,则进入 commit 阶段提交大局业务,假如有一个节点预留资源失利则进入 cancel 阶段回滚大局业务。
以传统的订单、库存、账户服务为例,在 try 阶段测验预留资源,刺进订单、扣减库存、扣减金额,这三个服务都是要提交本地业务的,这儿能够把资源转入中心表。在 commit 阶段,再把 try 阶段预留的资源转入终究表。而在 cancel 阶段,把 try 阶段预留的资源进行释放,比方把账户金额回来给客户的账户。
留意:try 阶段有必要是要提交本地业务的,比方扣减订单金额,有必要把钱从客户账户扣掉,假如不扣掉,在 commit 阶段客户账户钱不够了,就会出问题。
try-commit
try 阶段首要进行预留资源,然后在 commit 阶段扣除资源。如下图:
try-cancel
try 阶段首要进行预留资源,预留资源时扣减库存失利导致大局业务回滚,在 cancel 阶段释放资源。如下图:
TCC优势
TCC 形式最大的优势是功率高。TCC 形式在 try 阶段的确定资源并不是真正意义上的确定,而是实在提交了本地业务,将资源预留到中心态,并不需求阻塞等待,因此功率比其他形式要高。
一起 TCC 形式还能够进行如下优化:
异步提交
try 阶段成功后,不当即进入 confirm/cancel 阶段,而是以为大局业务现已完毕了,发动定时任务来异步履行 confirm/cancel,扣减或释放资源,这样会有很大的性能提高。
同库形式
TCC 形式中有三个人物:
- TM:办理大局业务,包含敞开大局业务,提交/回滚大局业务;
- RM:办理分支业务;
- TC: 办理大局业务和分支业务的状况。
下图来自 Seata 官网:
TM 敞开大局业务时,RM 需求向 TC 发送注册音讯,TC 保存分支业务的状况。TM 请求提交或回滚时,TC 需求向 RM 发送提交或回滚音讯。这样包含两个个分支业务的分布式业务中,TC 和 RM 之间有四次 RPC。
优化后的流程如下图:
TC 保存大局业务的状况。TM 敞开大局业务时,RM 不再需求向 TC 发送注册音讯,而是把分支业务状况保存在了本地。TM 向 TC 发送提交或回滚音讯后,RM 异步线程首要查出本地保存的未提交分支业务,然后向 TC 发送音讯获取(本地分支业务所在的)大局业务状况,以决定是提交仍是回滚本地业务。
这样优化后,RPC 次数减少了 50%,性能大幅提高。
RM 代码示例
以库存服务为例,RM 库存服务接口代码如下:
@LocalTCC
public interface StorageService {
/**
* 扣减库存
* @param xid 大局xid
* @param productId 产品id
* @param count 数量
* @return
*/
@TwoPhaseBusinessAction(name = "storageApi", commitMethod = "commit", rollbackMethod = "rollback", useTCCFence = true)
boolean decrease(String xid, Long productId, Integer count);
/**
* 提交业务
* @param actionContext
* @return
*/
boolean commit(BusinessActionContext actionContext);
/**
* 回滚业务
* @param actionContext
* @return
*/
boolean rollback(BusinessActionContext actionContext);
}
经过 @LocalTCC 这个注解,RM 初始化的时分会向 TC 注册一个分支业务。在 try 阶段的办法(decrease办法)上有一个 @TwoPhaseBusinessAction 注解,这儿界说了分支业务的 resourceId,commit 办法和 cancel 办法,useTCCFence 这个特点下一节再讲。
TCC 存在问题
TCC 形式中存在的三大问题是幂等、悬挂和空回滚。在 Seata1.5.1 版别中,增加了一张业务控制表,表名是 tcc_fence_log 来处理这个问题。而在上一节 @TwoPhaseBusinessAction 注解中提到的特点 useTCCFence 便是来指定是否敞开这个机制,这个特点值默许是 false。
tcc_fence_log 建表语句如下(MySQL 语法):
CREATE TABLE IF NOT EXISTS `tcc_fence_log`
(
`xid` VARCHAR(128) NOT NULL COMMENT 'global id',
`branch_id` BIGINT NOT NULL COMMENT 'branch id',
`action_name` VARCHAR(64) NOT NULL COMMENT 'action name',
`status` TINYINT NOT NULL COMMENT 'status(tried:1;committed:2;rollbacked:3;suspended:4)',
`gmt_create` DATETIME(3) NOT NULL COMMENT 'create time',
`gmt_modified` DATETIME(3) NOT NULL COMMENT 'update time',
PRIMARY KEY (`xid`, `branch_id`),
KEY `idx_gmt_modified` (`gmt_modified`),
KEY `idx_status` (`status`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4;
幂等
在 commit/cancel 阶段,由于 TC 没有收到分支业务的呼应,需求进行重试,这就要分支业务支撑幂等。
我们看一下新版别是怎样处理的。下面的代码在 TCCResourceManager 类:
@Override
public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId,
String applicationData) throws TransactionException {
TCCResource tccResource = (TCCResource)tccResourceCache.get(resourceId);
//省掉判别
Object targetTCCBean = tccResource.getTargetBean();
Method commitMethod = tccResource.getCommitMethod();
//省掉判别
try {
//BusinessActionContext
BusinessActionContext businessActionContext = getBusinessActionContext(xid, branchId, resourceId,
applicationData);
Object[] args = this.getTwoPhaseCommitArgs(tccResource, businessActionContext);
Object ret;
boolean result;
//注解 useTCCFence 特点是否设置为 true
if (Boolean.TRUE.equals(businessActionContext.getActionContext(Constants.USE_TCC_FENCE))) {
try {
result = TCCFenceHandler.commitFence(commitMethod, targetTCCBean, xid, branchId, args);
} catch (SkipCallbackWrapperException | UndeclaredThrowableException e) {
throw e.getCause();
}
} else {
//省掉逻辑
}
LOGGER.info("TCC resource commit result : {}, xid: {}, branchId: {}, resourceId: {}", result, xid, branchId, resourceId);
return result ? BranchStatus.PhaseTwo_Committed : BranchStatus.PhaseTwo_CommitFailed_Retryable;
} catch (Throwable t) {
//省掉
return BranchStatus.PhaseTwo_CommitFailed_Retryable;
}
}
上面的代码能够看到,履行分支业务提交办法时,首要判别 useTCCFence 特点是否为 true,假如为 true,则走 TCCFenceHandler 类中的 commitFence 逻辑,否则走普通提交逻辑。
TCCFenceHandler 类中的 commitFence 办法调用了 TCCFenceHandler 类的 commitFence 办法,代码如下:
public static boolean commitFence(Method commitMethod, Object targetTCCBean,
String xid, Long branchId, Object[] args) {
return transactionTemplate.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
if (tccFenceDO == null) {
throw new TCCFenceException(String.format("TCC fence record not exists, commit fence method failed. xid= %s, branchId= %s", xid, branchId),
FrameworkErrorCode.RecordAlreadyExists);
}
if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
LOGGER.info("Branch transaction has already committed before. idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
return true;
}
if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
}
return false;
}
return updateStatusAndInvokeTargetMethod(conn, commitMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_COMMITTED, status, args);
} catch (Throwable t) {
status.setRollbackOnly();
throw new SkipCallbackWrapperException(t);
}
});
}
从代码中能够看到,提交业务时首要会判别 tcc_fence_log 表中是否现已有记载,假如有记载,则判别业务履行状况并回来。这样假如判别到业务的状况现已是 STATUS_COMMITTED,就不会再次提交,确保了幂等。假如 tcc_fence_log 表中没有记载,则刺进一条记载,供后边重试时判别。
Rollback 的逻辑跟 commit 相似,逻辑在类 TCCFenceHandler 的 rollbackFence 办法。
空回滚
如下图,账户服务是两个节点的集群,在 try 阶段账户服务 1 这个节点发生了故障,try 阶段在不考虑重试的情况下,大局业务有必要要走向完毕状况,这样就需求在账户服务上履行一次 cancel 操作,这样就空跑了一次回滚操作。
Seata 的处理方案是在 try 阶段 往 tcc_fence_log 表刺进一条记载,status 字段值是 STATUS_TRIED,在 Rollback 阶段判别记载是否存在,假如不存在,则不履行回滚操作。代码如下:
//TCCFenceHandler 类
public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
return transactionTemplate.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
LOGGER.info("TCC fence prepare result: {}. xid: {}, branchId: {}", result, xid, branchId);
if (result) {
return targetCallback.execute();
} else {
throw new TCCFenceException(String.format("Insert tcc fence record error, prepare fence failed. xid= %s, branchId= %s", xid, branchId),
FrameworkErrorCode.InsertRecordError);
}
} catch (TCCFenceException e) {
//省掉
} catch (Throwable t) {
//省掉
}
});
}
在 Rollback 阶段的处理逻辑如下:
//TCCFenceHandler 类
public static boolean rollbackFence(Method rollbackMethod, Object targetTCCBean,
String xid, Long branchId, Object[] args, String actionName) {
return transactionTemplate.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
// non_rollback
if (tccFenceDO == null) {
//不履行回滚逻辑
return true;
} else {
if (TCCFenceConstant.STATUS_ROLLBACKED == tccFenceDO.getStatus() || TCCFenceConstant.STATUS_SUSPENDED == tccFenceDO.getStatus()) {
LOGGER.info("Branch transaction had already rollbacked before, idempotency rejected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
return true;
}
if (TCCFenceConstant.STATUS_COMMITTED == tccFenceDO.getStatus()) {
if (LOGGER.isWarnEnabled()) {
LOGGER.warn("Branch transaction status is unexpected. xid: {}, branchId: {}, status: {}", xid, branchId, tccFenceDO.getStatus());
}
return false;
}
}
return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
} catch (Throwable t) {
status.setRollbackOnly();
throw new SkipCallbackWrapperException(t);
}
});
}
updateStatusAndInvokeTargetMethod 办法履行的 sql 如下:
update tcc_fence_log set status = ?, gmt_modified = ?
where xid = ? and branch_id = ? and status = ? ;
可见便是把 tcc_fence_log 表记载的 status 字段值从 STATUS_TRIED 改为 STATUS_ROLLBACKED,假如更新成功,就履行回滚逻辑。
悬挂
悬挂是指由于网络问题,RM 开端没有收到 try 指令,可是履行了 Rollback 后 RM 又收到了 try 指令而且预留资源成功,这时大局业务现已完毕,终究导致预留的资源不能释放。如下图:
Seata 处理这个问题的办法是履行 Rollback 办法时先判别 tcc_fence_log 是否存在当前 xid 的记载,假如没有则向 tcc_fence_log 表刺进一条记载,状况是 STATUS_SUSPENDED,而且不再履行回滚操作。代码如下:
public static boolean rollbackFence(Method rollbackMethod, Object targetTCCBean,
String xid, Long branchId, Object[] args, String actionName) {
return transactionTemplate.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
TCCFenceDO tccFenceDO = TCC_FENCE_DAO.queryTCCFenceDO(conn, xid, branchId);
// non_rollback
if (tccFenceDO == null) {
//刺进防悬挂记载
boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_SUSPENDED);
//省掉逻辑
return true;
} else {
//省掉逻辑
}
return updateStatusAndInvokeTargetMethod(conn, rollbackMethod, targetTCCBean, xid, branchId, TCCFenceConstant.STATUS_ROLLBACKED, status, args);
} catch (Throwable t) {
//省掉逻辑
}
});
}
而后边履行 try 阶段办法时首要会向 tcc_fence_log 表刺进一条当前 xid 的记载,这样就造成了主键冲突。代码如下:
//TCCFenceHandler 类
public static Object prepareFence(String xid, Long branchId, String actionName, Callback<Object> targetCallback) {
return transactionTemplate.execute(status -> {
try {
Connection conn = DataSourceUtils.getConnection(dataSource);
boolean result = insertTCCFenceLog(conn, xid, branchId, actionName, TCCFenceConstant.STATUS_TRIED);
//省掉逻辑
} catch (TCCFenceException e) {
if (e.getErrcode() == FrameworkErrorCode.DuplicateKeyException) {
LOGGER.error("Branch transaction has already rollbacked before,prepare fence failed. xid= {},branchId = {}", xid, branchId);
addToLogCleanQueue(xid, branchId);
}
status.setRollbackOnly();
throw new SkipCallbackWrapperException(e);
} catch (Throwable t) {
//省掉
}
});
}
留意:queryTCCFenceDO 办法 sql 中使用了 for update,这样就不必担心 Rollback 办法中获取不到 tcc_fence_log 表记载而无法判别 try 阶段本地业务的履行成果了。
总结
TCC 形式是分布式业务使用最多的形式,可是幂等、悬挂和空回滚一直是 TCC 形式需求考虑的问题,Seata 框架在 1.5.1 版别完美处理了这些问题。
对 tcc_fence_log 表的操作也需求考虑业务的控制,Seata 使用了代理数据源,使 tcc_fence_log 表操作和 RM 业务操作在同一个本地业务中履行,这样就能确保本地操作和对 tcc_fence_log 的操作一起成功或失利。