Seata 分散式事務框架 TCC 模式原始碼分析

YoungChen發表於2019-05-02

Seata 是什麼

Seata 是阿里近期開源的分散式事務框架,地址:github.com/seata/seata。框架包括了集團的 TXC(雲版本叫 GTS)和螞蟻金服的 TCC 兩種模式,短短數月 Github 上的 star 數已經接近一萬,算是目前唯一有大廠背書的分散式事務解決方案。

TXCSeata 中又叫 AT 模式,意為補償方法是框架自動生成的,對使用者完全遮蔽,使用者可以向使用本地事務那樣使用分散式事務,缺點是僅支援關係型資料庫(目前支援 MySQL),引入 Seata AT 的服務需要本地建表儲存 rollback_info,隔離級別預設 RU 適用場景有限。

TCC 不算是新概念,很早就有了,使用者通過定義 try/confirm/cancel 三個方法在應用層面模擬兩階段提交,區別在於 TCC 中 try 方法也需要運算元據庫進行資源鎖定,後續兩個補償方法由框架自動呼叫,分別進行資源提交和回滾,這點同單純的儲存層 2PC 不太一樣。螞蟻金服向 Seata 貢獻了自己的 TCC 實現,據說已經演化了十多年,大量應用在在金融、交易、倉儲等領域。

分散式事務的誕生背景

早期應用都是單一架構,例如支付服務涉及到的賬戶、金額、訂單系統等都由單一應用負責,底層訪問同一個資料庫例項,自然事務操作也是本地事務,藉助 Spring 可以輕鬆實現;但是由於量級越來越大,單一服務需要進行職責拆分變為三個獨立的服務,通過 RPC 進行呼叫,資料也存在不同的資料庫例項中,由於這時一次業務操作涉及對多個資料庫資料的修改,無法再依靠本地事務,只能通過分散式事務框架來解決。

Seata 分散式事務框架 TCC 模式原始碼分析

TCC 就是分散式事務的一種解決方案,屬於柔性補償型,優點在於理解簡單、僅 try 階段加鎖併發效能較好,缺點在於程式碼改造成本。

什麼是 TCC 本文就不再贅述了,TCC 的概念本身並不複雜

Seata TCC 使用方法

在分析原始碼之前,我們先簡要提及下 Seata TCC 模式的使用方法,有助於後續理解整個 TCC 流程。

Seata TCC 參與方

Seata 中的 TCC 模式要求 TCC 服務的參與方在介面上增加 @TwoPhaseBusinessAction 註解,註明 TCC 介面的名稱(全域性唯一),TCC 介面的 confirmcancel 方法的名稱,用於後續框架反射呼叫,下面是一個 TCC 介面的案例:

public interface TccAction {
    @TwoPhaseBusinessAction(name = "yourTccActionName", commitMethod = "confirm", rollbackMethod = "cancel")
    public boolean try(BusinessActionContext businessActionContext, int a, int b);
    public boolean confirm(BusinessActionContext businessActionContext);
    public boolean cancel(BusinessActionContext businessActionContext);
}
複製程式碼

緊接著定義實現類 Impl 實現這個介面,為三個方法提供具體實現。最後將參與方服務進行釋出,註冊到遠端,主要為了後續能讓 Seata 框架呼叫到參與方的 confirm 或者 cancel 方法閉環整個 TCC 事務。

Seata TCC 發起方

Seata TCC 的發起方類似於我們上圖中的 payment service,參與方需要在業務方法上增加 @GlobalTransactional 註解,用於開啟切面註冊全域性事務,業務方法中呼叫 TCC 參與方的若干 try 方法,一旦業務方法呼叫成功,Seata 框架會通知 TC 回撥這些參與方的 confirmcancel 方法。

原始碼分析

SeataTCC 模式的原始碼並不複雜,主要集中於:

module class 功能
seata-spring GlobalTransactionalInterceptor.class 全域性事務切面邏輯,包括註冊全域性事務,拿到 xid
seata-spring TccActionInterceptor.class TCC 參與方切面邏輯
seata-tcc TCCResourceManager.class 解析 TCC Bean,儲存 TCC Resources,便於後續回撥
seata-tcc ActionInterceptorHandler.class TCC 分支事務註冊實現
seata-server DefaultCoordinator.class、FileTransactionStoreManager.class 主要是 TC 的實現、事務儲存等實現

註冊 TCC Resources

Seata 中一個 TCC 介面被稱作一個 TCC Resources,其結構如下:

public class TCCResource implements Resource {

    private String resourceGroupId = "DEFAULT";

    private String appName;

    private String actionName; // TCC 介面名稱     

    private Object targetBean; // TCC Bean

    private Method prepareMethod; // try 方法

    private String commitMethodName;

    private Method commitMethod; // confirm 方法

    private String rollbackMethodName;

    private Method rollbackMethod; // cancel 方法

    // …… 省略
}
複製程式碼

Seata 解析到應用中存在 TCC Bean,則通過 parserRemotingServiceInfo 方法生成一個 TCCResource 物件,進而呼叫 TCCResourceManager 類的 registerResource 方法,將 TCCResource 物件儲存到本地的 tccResourceCache 中,它是一個 ConcurrentHashMap 結構,同時通過 RmRpcClient 將該 TCCResourceresourceIdaddress 等資訊註冊到服務端,便於後續 TC 通過 RPC 回撥到正確的地址。

// 解析 TCCResource 的部分程式碼
Class<?> interfaceClass = remotingBeanDesc.getInterfaceClass();
Method[] methods = interfaceClass.getMethods();
if(isService(bean, beanName)){
    try {
        // 如果是 TCC service Bean,解析並註冊該 resource
        Object targetBean = remotingBeanDesc.getTargetBean();
        for(Method m : methods){
            TwoPhaseBusinessAction twoPhaseBusinessAction = m.getAnnotation(TwoPhaseBusinessAction.class);
            if(twoPhaseBusinessAction != null){
                // 如果有 TCC 參與方註解,定義一個 TCCResource,
                TCCResource tccResource = new TCCResource();
                tccResource.setActionName(twoPhaseBusinessAction.name());
                // TCC Bean
                tccResource.setTargetBean(targetBean); 
                // try 方法
                tccResource.setPrepareMethod(m); 
                // confirm 方法名稱
                tccResource.setCommitMethodName(twoPhaseBusinessAction.commitMethod());
                // confirm 方法物件
                tccResource.setCommitMethod(ReflectionUtil.getMethod(interfaceClass, twoPhaseBusinessAction.commitMethod(), new Class[]{BusinessActionContext.class}));
                // cancel 方法名稱
                tccResource.setRollbackMethodName(twoPhaseBusinessAction.rollbackMethod());
                // cancel 方法物件
                tccResource.setRollbackMethod(ReflectionUtil.getMethod(interfaceClass, twoPhaseBusinessAction.rollbackMethod(), new Class[]{BusinessActionContext.class}));
                // 呼叫到 TCCResourceManager 的 registerResource 方法
                DefaultResourceManager.get().registerResource(tccResource);
            }
        }
    }catch (Throwable t){
        throw new FrameworkException(t, "parser remting service error");
    }
}
複製程式碼

我們看一下 TCCResourceManagerregisterResource 方法的實現:

// 記憶體中儲存的 resourceId 和 TCCResource 的對映關係
private Map<String, Resource> tccResourceCache = new ConcurrentHashMap<String, Resource>();

@Override
public void registerResource(Resource resource) {
    TCCResource tccResource = (TCCResource) resource;
    tccResourceCache.put(tccResource.getResourceId(), tccResource);
    // 呼叫父類的方法通過 RPC 註冊到遠端
    super.registerResource(tccResource);
}
複製程式碼

我們看下 TCCResource 是如何註冊到服務端的:

public void registerResource(Resource resource) {
    // 拿到 RmRpcClient 例項,呼叫其 registerResource 方法
    RmRpcClient.getInstance().registerResource(resource.getResourceGroupId(), resource.getResourceId());
}

public void registerResource(String resourceGroupId, String resourceId) {
    if (LOGGER.isInfoEnabled()) {
        LOGGER.info("register to RM resourceId:" + resourceId);
    }
    synchronized (channels) {
        for (Map.Entry<String, Channel> entry : channels.entrySet()) {
            String serverAddress = entry.getKey();
            Channel rmChannel = entry.getValue();
            if (LOGGER.isInfoEnabled()) {
                LOGGER.info("register resource, resourceId:" + resourceId);
            }
            // 註冊 resourceId,遠端將其解析為一個 RpcContext 儲存在記憶體中
            sendRegisterMessage(serverAddress, rmChannel, resourceId);
        }
    }
}
複製程式碼

GlobalTransaction 註冊全域性事務

GlobalTransaction 註解是全域性事務的入口,其切面邏輯實現在 GlobalTransactionalInterceptor 類中。如果判斷進入 @GlobalTransaction 修飾的方法,會呼叫 handleGlobalTransaction 方法進入切面邏輯,其中關鍵方法是 transactionalTemplateexecute 方法。

public Object execute(TransactionalExecutor business) throws Throwable {
    
    // 如果上游已經有 xid 傳過來說明自己是下游,直接參與到這個全域性事務中就可以,不必新開一個,角色是 Participant
    // 如果上游沒有 xid 傳遞過來,說明自己是發起方,新開啟一個全域性事務,角色是 Launcher
    GlobalTransaction tx = GlobalTransactionContext.getCurrentOrCreate();

    // …… …… 省略 

    try {

        // 開啟全域性事務
        beginTransaction(txInfo, tx);

        Object rs = null;
        try {

            // 呼叫業務方法
            rs = business.execute();

        } catch (Throwable ex) {

            // 如果拋異常,通知 TC 回滾全域性事務
            completeTransactionAfterThrowing(txInfo,tx,ex);
            throw ex;
        }

        // 如果不拋異常,通知 TC 提交全域性事務
        commitTransaction(tx);

        return rs;
    } 

    // …… …… 省略
}
複製程式碼

beginTransaction 方法呼叫了 transactionManagerbegin 方法:

// 客戶端
@Override
public String begin(String applicationId, String transactionServiceGroup, String name, int timeout)
    throws TransactionException {
    GlobalBeginRequest request = new GlobalBeginRequest();
    request.setTransactionName(name);
    request.setTimeout(timeout);
    // 傳送 RPC,獲取 TC 下發的 xid
    GlobalBeginResponse response = (GlobalBeginResponse)syncCall(request);
    return response.getXid();
}

// 服務端
@Override
public String begin(String applicationId, String transactionServiceGroup, String name, int timeout)
    throws TransactionException {
    // 全域性事務用 GlobalSession 來表示
    GlobalSession session = GlobalSession.createGlobalSession(
        applicationId, transactionServiceGroup, name, timeout);
    session.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
    // 將 GlobalSession 寫入檔案儲存
    session.begin();
    // 返回 UUID 作為全域性事務 ID
    return XID.generateXID(session.getTransactionId());
}
複製程式碼

TwoPhaseBusinessAction 註冊分支事務

全域性事務呼叫業務方法時,會進入 TCC 參與方的切面邏輯,主要實現在 TccActionInterceptor 類中,關鍵方法是 actionInterceptorHandlerproceed 方法。

public Map<String, Object> proceed(Method method, Object[] arguments, TwoPhaseBusinessAction businessAction, Callback<Object> targetCallback) throws Throwable {
    
    // …… …… 省略

    // 建立分支事務
    String branchId = doTccActionLogStore(method, arguments, businessAction, actionContext);
    actionContext.setBranchId(branchId);
    
    // 記錄方法引數
    Class<?>[] types = method.getParameterTypes();
    int argIndex = 0;
    for (Class<?> cls : types) {
        if (cls.getName().equals(BusinessActionContext.class.getName())) {
            arguments[argIndex] = actionContext;
            break;
        }
        argIndex++;
    }
    
    // …… …… 省略
}
複製程式碼

doTccActionLogStore 方法負責註冊分支事務:

// 客戶端
protected String doTccActionLogStore(Method method, Object[] arguments, TwoPhaseBusinessAction businessAction, BusinessActionContext actionContext) {
    String actionName = actionContext.getActionName();
    // 拿到全域性事務 ID
    String xid = actionContext.getXid();
    
    // …… …… 省略

    try {
        // resourceManager 通過 RPC 向 TC 註冊分支事務
        Long branchId = DefaultResourceManager.get().branchRegister(BranchType.TCC, actionName, null, xid, applicationContextStr, null);
        // 拿到 TC 返回的分支事務 ID
        return String.valueOf(branchId);
    }

    // …… …… 省略
}

// 服務端
@Override
public Long branchRegister(BranchType branchType, String resourceId, String clientId, String xid,
                            String applicationData, String lockKeys) throws TransactionException {
    GlobalSession globalSession = assertGlobalSession(XID.getTransactionId(xid), GlobalStatus.Begin);
    // 分支事務用 BranchSession 表示,新建一個 BranchSession
    BranchSession branchSession = SessionHelper.newBranchByGlobal(globalSession, branchType, resourceId,
        applicationData, lockKeys, clientId);

    if (!branchSession.lock()) {
        throw new TransactionException(LockKeyConflict);
    }
    try {
        // 將分支事務加入全域性事務中,也會寫檔案
        globalSession.addBranch(branchSession);
    } catch (RuntimeException ex) {
        throw new TransactionException(FailedToAddBranch);
    }
    // 返回分支事務 ID
    return branchSession.getBranchId();
}
複製程式碼

TC 回撥參與方補償方法

分支事務註冊完畢,業務方法呼叫成功則通知 TC 提交全域性事務。

@Override
public void commit() throws TransactionException {
    // 如果是參與者,無需發起提交請求
    if (role == GlobalTransactionRole.Participant) {
        return;
    }
    // 由 TM 向 TC 發出提交全域性事務的請求
    status = transactionManager.commit(xid);
}
複製程式碼

TC 收到客戶端 TMcommit 請求後:

@Override
public GlobalStatus commit(String xid) throws TransactionException {
    // 根據 xid 找出 GlobalSession
    GlobalSession globalSession = SessionHolder.findGlobalSession(XID.getTransactionId(xid));
    if (globalSession == null) {
        return GlobalStatus.Finished;
    }
    GlobalStatus status = globalSession.getStatus();

    // 關閉這個 GlobalSession,不讓後續的分支事務再註冊上來
    globalSession.closeAndClean(); 

    if (status == GlobalStatus.Begin) {
        // 修改狀態為提交進行中
        globalSession.changeStatus(GlobalStatus.Committing);
        // 一旦分支事務中存在 TCC,做同步提交,其實 TCC 分支也可以非同步提交,要求高效能時可以選擇非同步
        if (globalSession.canBeCommittedAsync()) {
            asyncCommit(globalSession);
        } else {
            doGlobalCommit(globalSession, false);
        }
    }
    return globalSession.getStatus();
}
複製程式碼

doGlobalCommit 是我們關注的關鍵方法,我們忽略其中的次要邏輯:

@Override
public void doGlobalCommit(GlobalSession globalSession, boolean retrying) throws TransactionException {
    for (BranchSession branchSession : globalSession.getSortedBranches()) {
        
        // …… …… 省略

        try {
            // 呼叫 DefaultCoordinator 的 branchCommit 方法做分支提交
            // 引數有分支事務 id,resourceId 用來尋找對應的 TCCResource 和補償方法引數資訊
            BranchStatus branchStatus = resourceManagerInbound.branchCommit(branchSession.getBranchType(),
                XID.generateXID(branchSession.getTransactionId()), branchSession.getBranchId(),
                branchSession.getResourceId(), branchSession.getApplicationData());
        }
    }

    // …… …… 省略
}
複製程式碼

服務端的 DefaultCoordinator 類中的 branchCommit 方法發出 RPC 請求,呼叫對應 TCCResource 提供方:

@Override
public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId,
                                    String applicationData)
    throws TransactionException {
    
    // …… …… 省略
    // 獲取全域性事務和分支事務
    GlobalSession globalSession = SessionHolder.findGlobalSession(XID.getTransactionId(xid));
        BranchSession branchSession = globalSession.getBranch(branchId);
    // 根據 resourceId 找到對應的 channel 和 RpcContext 
    BranchCommitResponse response = (BranchCommitResponse)messageSender.sendSyncRequest(resourceId,
        branchSession.getClientId(), request);
    // 返回分支事務提交狀態
    return response.getBranchStatus();

    // …… …… 省略
}
複製程式碼

客戶端自然是接收到分支提交的 RPC 請求,然後本地找出之前解析並保持下來的 TCCResource 進行補償方法的反射呼叫,下面我們擷取其中的關鍵步驟進行分析。

@Override
public BranchStatus branchCommit(BranchType branchType, String xid, long branchId, String resourceId, String applicationData) throws TransactionException {
    // 根據 resourceId 找出記憶體中保留的 TCCResource 物件
    TCCResource tccResource = (TCCResource) tccResourceCache.get(resourceId);
    if(tccResource == null){
        throw new ShouldNeverHappenException("TCC resource is not exist, resourceId:" + resourceId);
    }
    // 獲取 targetBean 和相應的 method 物件
    Object targetTCCBean = tccResource.getTargetBean();
    Method commitMethod = tccResource.getCommitMethod();
    try {
        boolean result = false;
        // 取出補償方法引數資訊
        BusinessActionContext businessActionContext = getBusinessActionContext(xid, branchId, resourceId, applicationData);
        // 反射呼叫補償方法
        Object ret = commitMethod.invoke(targetTCCBean, businessActionContext);
        // 返回狀態
        return result ? BranchStatus.PhaseTwo_Committed:BranchStatus.PhaseTwo_CommitFailed_Retryable;
    }
    // …… …… 省略
}
複製程式碼

事務儲存

關於 Seata TC 模組如何進行事務儲存,網上有的文章已經講得很詳細,例如 深度剖析一站式分散式事務方案 Seata-Server,因此這裡不再贅述。

需要提及的一點是,TC 有可能成為整個分散式事務服務的效能瓶頸,因此如何做到高效能高可用很重要,目前的儲存方式是 File,程式碼中也有關於 DB Store ModeTODO 項,檔案相比於 DB 效能肯定好一些但是可用性會差一點,這塊怎麼保證要等到後續 HA Cluster 釋出之後再看。

總結

整個 Seata 框架中關於 TCC 部分的原始碼並不複雜,本文只選取了部分類中的關鍵程式碼進行展示,忽略了一些判斷邏輯和異常處理,筆者認為 Seata TCC 中關於 TCC 異常的封裝和自定義處理、還有各種使用者擴充套件埋點的設計也值得一看。

螞蟻 SOFA Channel 之前做過一個關於 Seata TCC Seata TCC 分享 的講解裡也提到,TCC 框架的難點不在於本身,而在於如何寫好一個 TCC 介面,如果對這部分內容感興趣,可以點選連結進行詳細瞭解。

寫在最後

這是一個不定時更新的、披著程式設計師外衣的文青小號。既分享極客技術,也記錄人間煙火。

Seata 分散式事務框架 TCC 模式原始碼分析

相關文章