深度剖析 Seata TCC 模式

前言

Seata 目前支持 AT 模式、XA 模式、TCC 模式和 SAGA 模式,之前文章更多談及的是非侵入式的 AT 模式,今天帶大家認識一下同樣是二階段提交的 TCC 模式。

什麼是 TCC

TCC 是分佈式事務中的二階段提交協議,它的全稱爲 Try-Confirm-Cancel,即資源預留(Try)、確認操作(Confirm)、取消操作(Cancel),他們的具體含義如下:

  1. Try:對業務資源的檢查並預留;

  2. Confirm:對業務處理進行提交,即 commit 操作,只要 Try 成功,那麼該步驟一定成功;

  3. Cancel:對業務處理進行取消,即回滾操作,該步驟回對 Try 預留的資源進行釋放。

TCC 是一種侵入式的分佈式事務解決方案,以上三個操作都需要業務系統自行實現,對業務系統有着非常大的入侵性,設計相對複雜,但優點是 TCC 完全不依賴數據庫,能夠實現跨數據庫、跨應用資源管理,對這些不同數據訪問通過侵入式的編碼方式實現一個原子操作,更好地解決了在各種複雜業務場景下的分佈式事務問題。

Seata TCC 模式

Seata TCC 模式跟通用型 TCC 模式原理一致,我們先來使用 Seata TCC 模式實現一個分佈式事務:

假設現有一個業務需要同時使用服務 A 和服務 B 完成一個事務操作,我們在服務 A 定義該服務的一個 TCC 接口:

public interface TccActionOne {
    @TwoPhaseBusinessAction(name = "DubboTccActionOne", commitMethod = "commit", rollbackMethod = "rollback")
    public boolean prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "a") String a);

    public boolean commit(BusinessActionContext actionContext);

    public boolean rollback(BusinessActionContext actionContext);
}

同樣,在服務 B 定義該服務的一個 TCC 接口:

public interface TccActionTwo {
    @TwoPhaseBusinessAction(name = "DubboTccActionTwo", commitMethod = "commit", rollbackMethod = "rollback")
    public void prepare(BusinessActionContext actionContext, @BusinessActionContextParameter(paramName = "b") String b);

    public void commit(BusinessActionContext actionContext);

    public void rollback(BusinessActionContext actionContext);
}

在業務所在系統中開啓全局事務並執行服務 A 和服務 B 的 TCC 預留資源方法:

@GlobalTransactional
public String doTransactionCommit(){
    //服務A事務參與者
    tccActionOne.prepare(null,"one");
    //服務B事務參與者
    tccActionTwo.prepare(null,"two");
    }

以上就是使用 Seata TCC 模式實現一個全局事務的例子,可以看出,TCC 模式同樣使用 @GlobalTransactional 註解開啓全局事務,而服務 A 和服務 B 的 TCC 接口爲事務參與者,Seata 會把一個 TCC 接口當成一個 Resource,也叫 TCC Resource。

TCC 接口可以是 RPC,也可以是 JVM 內部調用,意味着一個 TCC 接口,會有發起方和調用方兩個身份,以上例子,TCC 接口在服務 A 和服務 B 中是發起方,在業務所在系統中是調用方。如果該 TCC 接口爲 Dubbo RPC,那麼調用方就是一個 dubbo:reference,發起方則是一個 dubbo:service。

Seata 啓動時會對 TCC 接口進行掃描並解析,如果 TCC 接口是一個發佈方,則在 Seata 啓動時會向 TC 註冊 TCC Resource,每個 TCC Resource 都有一個資源 ID;如果 TCC 接口時一個調用方,Seata 代理調用方,與 AT 模式一樣,代理會攔截 TCC 接口的調用,即每次調用 Try 方法,會向 TC 註冊一個分支事務,接着才執行原來的 RPC 調用。

當全局事務決議提交 / 回滾時,TC 會通過分支註冊的的資源 ID 回調到對應參與者服務中執行 TCC Resource 的 Confirm/Cancel 方法。

Seata 如何實現 TCC 模式

從上面的 Seata TCC 模型可以看出,TCC 模式在 Seata 中也是遵循 TC、TM、RM 三種角色模型的,如何在這三種角色模型中實現 TCC 模式呢?我將其主要實現歸納爲資源解析、資源管理、事務處理。

資源解析

資源解析即是把 TCC 接口進行解析並註冊,前面說過,TCC 接口可以是 PRC,也可以是 JVM 內部調用,在 Seata TCC 模塊中中一個 remoting 模塊,該模塊專門用於解析具有 TwoPhaseBusinessAction 註解的 TCC 接口資源:

RemotingParser 接口主要有 isRemotingisReferenceisServicegetServiceDesc 等方法,默認的實現爲 DefaultRemotingParser,其餘各自的 RPC 協議解析類都在 DefaultRemotingParser 中執行,Seata 目前已經實現了對 Dubbo、HSF、SofaRpc、LocalTCC 的 RPC 協議的解析,同時具備 SPI 可擴展性,未來歡迎大家爲 Seata 提供更多的 RPC 協議解析類。

在 Seata 啓動過程中,有個 GlobalTransactionScanner 註解進行掃描,會執行以下方法:

io.seata.spring.util.TCCBeanParserUtils#isTccAutoProxy

該方法目的是判斷 bean 是否已被 TCC 代理,在過程中會先判斷 bean 是否是一個 Remoting bean,如果是則調用 getServiceDesc 方法對 remoting bean 進行解析,同時判斷如果是一個發起方,則對其進行資源註冊:

io.seata.rm.tcc.remoting.parser.DefaultRemotingParser#parserRemotingServiceInfo

public RemotingDesc parserRemotingServiceInfo(Object bean,String beanName,RemotingParser remotingParser){
    RemotingDesc remotingBeanDesc=remotingParser.getServiceDesc(bean,beanName);
    if(remotingBeanDesc==null){
    return null;
    }
    remotingServiceMap.put(beanName,remotingBeanDesc);

    Class<?> interfaceClass=remotingBeanDesc.getInterfaceClass();
    Method[]methods=interfaceClass.getMethods();
    if(remotingParser.isService(bean,beanName)){
    try{
    //service bean, registry resource
    Object targetBean=remotingBeanDesc.getTargetBean();
    for(Method m:methods){
    TwoPhaseBusinessAction twoPhaseBusinessAction=m.getAnnotation(TwoPhaseBusinessAction.class);
    if(twoPhaseBusinessAction!=null){
    TCCResource tccResource=new TCCResource();
    tccResource.setActionName(twoPhaseBusinessAction.name());
    tccResource.setTargetBean(targetBean);
    tccResource.setPrepareMethod(m);
    tccResource.setCommitMethodName(twoPhaseBusinessAction.commitMethod());
    tccResource.setCommitMethod(interfaceClass.getMethod(twoPhaseBusinessAction.commitMethod(),
    twoPhaseBusinessAction.commitArgsClasses()));
    tccResource.setRollbackMethodName(twoPhaseBusinessAction.rollbackMethod());
    tccResource.setRollbackMethod(interfaceClass.getMethod(twoPhaseBusinessAction.rollbackMethod(),
    twoPhaseBusinessAction.rollbackArgsClasses()));
    // set argsClasses
    tccResource.setCommitArgsClasses(twoPhaseBusinessAction.commitArgsClasses());
    tccResource.setRollbackArgsClasses(twoPhaseBusinessAction.rollbackArgsClasses());
    // set phase two method's keys
    tccResource.setPhaseTwoCommitKeys(this.getTwoPhaseArgs(tccResource.getCommitMethod(),
    twoPhaseBusinessAction.commitArgsClasses()));
    tccResource.setPhaseTwoRollbackKeys(this.getTwoPhaseArgs(tccResource.getRollbackMethod(),
    twoPhaseBusinessAction.rollbackArgsClasses()));
    //registry tcc resource
    DefaultResourceManager.get().registerResource(tccResource);
    }
    }
    }catch(Throwable t){
    throw new FrameworkException(t,"parser remoting service error");
    }
    }
    if(remotingParser.isReference(bean,beanName)){
    //reference bean, TCC proxy
    remotingBeanDesc.setReference(true);
    }
    return remotingBeanDesc;
    }

以上方法,先調用解析類 getServiceDesc 方法對 remoting bean 進行解析,並將解析後的 remotingBeanDesc 放入 本地緩存 remotingServiceMap 中,同時調用解析類 isService 方法判斷是否爲發起方,如果是發起方,則解析 TwoPhaseBusinessAction 註解內容生成一個 TCCResource,並對其進行資源註冊。

資源管理

1、資源註冊

Seata TCC 模式的資源叫 TCCResource,其資源管理器叫 TCCResourceManager,前面講過,當解析完 TCC 接口 RPC 資源後,如果是發起方,則會對其進行資源註冊:

io.seata.rm.tcc.TCCResourceManager#registerResource

public void registerResource(Resource resource){
    TCCResource tccResource=(TCCResource)resource;
    tccResourceCache.put(tccResource.getResourceId(),tccResource);
    super.registerResource(tccResource);
    }

TCCResource 包含了 TCC 接口的相關信息,同時會在本地進行緩存。繼續調用父類 registerResource 方法(封裝了通信方法)向 TC 註冊,TCC 資源的 resourceId 是 actionName,actionName 就是 @TwoParseBusinessAction 註解中的 name。

2、資源提交 / 回滾

io.seata.rm.tcc.TCCResourceManager#branchCommit

public BranchStatus branchCommit(BranchType branchType,String xid,long branchId,String resourceId,
    String applicationData)throws TransactionException{
    TCCResource tccResource=(TCCResource)tccResourceCache.get(resourceId);
    if(tccResource==null){
    throw new ShouldNeverHappenException(String.format("TCC resource is not exist, resourceId: %s",resourceId));
    }
    Object targetTCCBean=tccResource.getTargetBean();
    Method commitMethod=tccResource.getCommitMethod();
    if(targetTCCBean==null||commitMethod==null){
    throw new ShouldNeverHappenException(String.format("TCC resource is not available, resourceId: %s",resourceId));
    }
    try{
    //BusinessActionContext
    BusinessActionContext businessActionContext=getBusinessActionContext(xid,branchId,resourceId,
    applicationData);
    // ... ... 
    ret=commitMethod.invoke(targetTCCBean,args);
    // ... ... 
    return result?BranchStatus.PhaseTwo_Committed:BranchStatus.PhaseTwo_CommitFailed_Retryable;
    }catch(Throwable t){
    String msg=String.format("commit TCC resource error, resourceId: %s, xid: %s.",resourceId,xid);
    LOGGER.error(msg,t);
    return BranchStatus.PhaseTwo_CommitFailed_Retryable;
    }
    }

當 TM 決議二階段提交,TC 會通過分支註冊的的資源 ID 回調到對應參與者(即 TCC 接口發起方)服務中執行 TCC Resource 的 Confirm/Cancel 方法。

資源管理器中會根據 resourceId 在本地緩存找到對應的 TCCResource,同時根據 xid、branchId、resourceId、applicationData 找到對應的 BusinessActionContext 上下文,執行的參數就在上下文中。最後,執行 TCCResource 中獲取 commit 的方法進行二階段提交。

二階段回滾同理類似。

事務處理

前面講過,如果 TCC 接口時一個調用方,則會使用 Seata TCC 代理對調用方進行攔截處理,並在處理調用真正的 RPC 方法前對分支進行註冊。

執行方法io.seata.spring.util.TCCBeanParserUtils#isTccAutoProxy除了對 TCC 接口資源進行解析,還會判斷 TCC 接口是否爲調用方,如果是調用方則返回 true:

io.seata.spring.annotation.GlobalTransactionScanner#wrapIfNecessary

如圖,當 GlobalTransactionalScanner 掃描到 TCC 接口調用方(Reference)時,會使 TccActionInterceptor 對其進行代理攔截處理,TccActionInterceptor 實現 MethodInterceptor

在 TccActionInterceptor 中還會調用 ActionInterceptorHandler 類型執行攔截處理邏輯,事務相關處理就在 ActionInterceptorHandler#proceed 方法中:

public Object proceed(Method method,Object[]arguments,String xid,TwoPhaseBusinessAction businessAction,
    Callback<Object> targetCallback)throws Throwable{
    //Get action context from arguments, or create a new one and then reset to arguments
    BusinessActionContext actionContext=getOrCreateActionContextAndResetToArguments(method.getParameterTypes(),arguments);
    //Creating Branch Record
    String branchId=doTccActionLogStore(method,arguments,businessAction,actionContext);
    // ... ... 
    try{
    // ... ...
    return targetCallback.execute();
    }finally{
    try{
    //to report business action context finally if the actionContext.getUpdated() is true
    BusinessActionContextUtil.reportContext(actionContext);
    }finally{
    // ... ... 
    }
    }
    }

以上,在執行 TCC 接口一階段之前,會調用 doTccActionLogStore 方法分支註冊,同時還會將 TCC 相關信息比如參數放置在上下文,上面講的資源提交 / 回滾就會用到這個上下文。

如何控制異常

在 TCC 模型執行的過程中,還可能會出現各種異常,其中最爲常見的有空回滾、冪等、懸掛等。下面我講下 Seata 是如何處理這三種異常的。

如何處理空回滾

什麼是空回滾?

空回滾指的是在一個分佈式事務中,在沒有調用參與方的 Try 方法的情況下,TM 驅動二階段回滾調用了參與方的 Cancel 方法。

那麼空回滾是如何產生的呢?

如上圖所示,全局事務開啓後,參與者 A 分支註冊完成之後會執行參與者一階段 RPC 方法,如果此時參與者 A 所在的機器發生宕機,網絡異常,都會造成 RPC 調用失敗,即參與者 A 一階段方法未成功執行,但是此時全局事務已經開啓,Seata 必須要推進到終態,在全局事務回滾時會調用參與者 A 的 Cancel 方法,從而造成空回滾。

要想防止空回滾,那麼必須在 Cancel 方法中識別這是一個空回滾,Seata 是如何做的呢?

Seata 的做法是新增一個 TCC 事務控制表,包含事務的 XID 和 BranchID 信息,在 Try 方法執行時插入一條記錄,表示一階段執行了,執行 Cancel 方法時讀取這條記錄,如果記錄不存在,說明 Try 方法沒有執行。

如何處理冪等

冪等問題指的是 TC 重複進行二階段提交,因此 Confirm/Cancel 接口需要支持冪等處理,即不會產生資源重複提交或者重複釋放。

那麼冪等問題是如何產生的呢?

如上圖所示,參與者 A 執行完二階段之後,由於網絡抖動或者宕機問題,會造成 TC 收不到參與者 A 執行二階段的返回結果,TC 會重複發起調用,直到二階段執行結果成功。

Seata 是如何處理冪等問題的呢?

同樣的也是在 TCC 事務控制表中增加一個記錄狀態的字段 status,該字段有有 3 個值,分別爲:

  1. tried:1

  2. committed:2

  3. rollbacked:3

二階段 Confirm/Cancel 方法執行後,將狀態改爲 committed 或 rollbacked 狀態。當重複調用二階段 Confirm/Cancel 方法時,判斷事務狀態即可解決冪等問題。

如何處理懸掛

懸掛指的是二階段 Cancel 方法比 一階段 Try 方法優先執行,由於允許空回滾的原因,在執行完二階段 Cancel 方法之後直接空回滾返回成功,此時全局事務已結束,但是由於 Try 方法隨後執行,這就會造成一階段 Try 方法預留的資源永遠無法提交和釋放了。

那麼懸掛是如何產生的呢?

如上圖所示,在執行參與者 A 的一階段 Try 方法時,出現網路擁堵,由於 Seata 全局事務有超時限制,執行 Try 方法超時後,TM 決議全局回滾,回滾完成後如果此時 RPC 請求才到達參與者 A,執行 Try 方法進行資源預留,從而造成懸掛。

Seata 是怎麼處理懸掛的呢?

在 TCC 事務控制表記錄狀態的字段 status 中增加一個狀態:

  1. suspended:4

當執行二階段 Cancel 方法時,如果發現 TCC 事務控制表有相關記錄,說明二階段 Cancel 方法優先一階段 Try 方法執行,因此插入一條 status=4 狀態的記錄,當一階段 Try 方法後面執行時,判斷 status=4 ,則說明有二階段 Cancel 已執行,並返回 false 以阻止一階段 Try 方法執行成功。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/oeQWwf9bSXM5Abraf-r3rA