逸仙電商 Seata 企業級落地實踐

你可能沒有聽說過逸仙電商,但是你的女朋友不可能沒有聽說過它。逸仙電商旗下有完美日記、小奧汀、完子心選等品牌。完美日記作爲國貨美妝界的黑馬用了不到三年時間,達到了行業龍頭企業通常需要十年以上才能達到的營收規模。2020 年正式登陸紐約證券交易所,成爲第一家在美國上市的“國貨美妝品牌”。在快速增長的業務下,系統流量增長速度越來越快,服務數量不斷增多,調用鏈路錯綜複雜,數據不一致的問題日漸顯現,爲了降低人力成本和系統資源,我們選擇了 Seata。

本文將會以逸仙電商的業務作爲背景, 先介紹一下seata的原理, 並給大家進行線上演示, 由淺入深去介紹這款中間件, 以便讀者更加容易去理解 Seata 這個中間件。

1. 問題背景

在微服務的架構下,數據不一致的產生原因

2. 業務介紹

挑選了逸仙電商一些比較簡單易懂的業務作爲開展背景

3. 原理分析

Seata的實現原理和故障解決以及部署方案

4. Demo演示

如何在線體驗這款中間件,無需整合和下載任何代碼

數據不一致的原因

在微服務的環境下,由於調用鏈路跨越多個應用,甚至跨越多個數據源,數據的一致性在普通情況下難以保證,導致數據不一致的原因非常多,這裏列舉了三個最常見的原因

  1. 業務異常一個服務鏈路調用中,如果調用的過程出現業務異常,產生異常的應用獨立回滾,非異常的應用數據已經持久化到數據庫。
  2. 網絡異常調用的過程中,由於網絡不穩定,導致鏈路中斷,部分應用業務執行完成,部分應用業務未被執行。
  3. 服務不可用若服務不可用,無法被正常調用,也會導致問題的產生

這裏挑選了逸仙電商業務體系裏面一個非常通俗容易理解的調用方式,並且去掉了多餘複雜的鏈路,方便在閱讀過程中更加關注重點。

在以往如果出現數據不一致的問題,相信大多數的解決方案是這樣的

  • 人工補償數據
  • 定時任務檢查和補償數據

但是這兩種方式的缺點也是顯然意見的,一種是浪費大量的人力成本和時間,另外一種是浪費大量的系統資源去檢查數據是否一致和額外的人力成本。

接下來我會根據逸仙在生產上穩定運行將近一年總結的經驗並且儘可能簡單的去描述Seata是如何保證數據一致的。

原理

在接觸一項新技術之前,我們應該先從宏觀的角度去理解它大概包含些什麼。在Seata中,它大概分爲以下三個角色。

  • 黃色,Transaction Manager(TM),client端
  • 藍色,Resource Manager(RM),client端
  • 綠色,Transaction Coordinator(TC),server端

你可以根據顏色,名字,縮寫甚至客戶端/服務端去區分這三者的關係,同時簡單去理解它們每一個自身的職責大概是要幹些什麼事情,後面的講解我也會保持一樣的顏色和名字來區分它們。

Seata其中只一個核心是數據源代理,意味着在你執行一句Sql語句時,Seata會幫你在執行之前和之後做一些額外的操作,從而保證數據的一致性,並且儘可能做到無感知,讓你使用起來感覺非常方便和神奇。這裏首先要去理解兩個知識點。

  • 前置鏡像(Before Image):保存數據變更前的樣子
  • 後置鏡像(After Image):保存數據變更後的樣子
  • Undo Log:保存鏡像

有時候新項目接入的時候,有同事會問,爲什麼事務不生效,如果你也遇到過同樣的問題,那首先要檢查一下自己的數據源是否已經代理成功。

當執行一句Sql時,Seata會嘗試去獲取這條/批數據變更前的內容,並保存到前置鏡像中(Insert語句沒有前置鏡像),然後執行業務Sql,執行完後會嘗試去獲取這條/批數據變更後的內容,並保存到後置鏡像中(Delete語句沒有後置鏡像),之後會進行分支事務註冊,TC在收到分支事務註冊請求時,會持久化這些分支事務信息和根據操作數據的主鍵爲維度作爲全局鎖並持久化,可選持久化方式有

  • file
  • db
  • redis

在收到TC返回的分支註冊成功響應後,會把鏡像持久化到應用所在的數據源的Undo Log表中,最後提交本地事務。

以上所有操作都會保證在同一個本地事務中,保證業務操作和Undo Log操作的原子性

一階段

理解了單個應用的處理流程,再從一個完全的調用鏈路,去看Seata的處理過程,相信理解起來會簡單很多,

  1. 首先一個使用了@GlobalTransactional的接口被調用,Seata會對其進行攔截,攔截的角色我們稱之爲TM,這個時候會訪問TC開啓一個新的全局事務,TC收到請求後會生成XID和全局事務信息並持久化,然後返回XID。
  2. 在每一層的調用鏈路中,XID都必須往下傳遞,然後每一層都經過之前說過的處理邏輯,直到執行完成/異常拋出。

直到目前,一階段已經執行完成。

另外一個需要注意的問題是,如果發現事務不生效,需要檢查XID是否成功往下傳遞

二階段提交

如果在整個調用鏈路的過程,沒有發生任何異常,那麼二階段提交的過程是非常簡單而且非常的高效,只有兩步

  • TC清理全局事務對應的信息
  • RM清理對應Undo Log信息

二階段回滾

若調用過程中出現異常,會自動觸發反向回滾

反向回滾表示,如果調用鏈路順序爲 A -> B -> C,那麼回滾順序爲 C -> B -> A。
例:A=Insert,B=Update,如果回滾時不按照反向的順序進行回滾,則有可能出現回滾時先把A刪除了,再更新A,引發錯誤

在回滾的過程中有可能會遇到一種非常極端的情況,回滾到對應的模塊時,找不到對應的Undo Log,這種情況主要發生在

  • 分支事務註冊成功,但是由於網絡原因收不到成功的響應,Undo Log未被持久化
  • 同時全局事務超時(超時時間可自由配置)觸發回滾

這時候RM會持久化一個特殊的Undo Log,狀態爲GlobalFinished。由於這個全局事務已經回滾,需要防止網絡恢復時,未持久化Undo Log的應用收到了分支註冊成功的響應和持久化Undo Log,並提交本地最終引發的數據不一致。

讀已提交

由於在一階段的時候,數據已經保存到數據庫並提交,所以Seata默認的隔離級別爲讀未提交,如果需要把隔離級別提升至讀已提交則需要使用@GlobalLock標籤並且在查詢語句上加上for update

@GlobalLock
@Transactional
public PayMoneyDto detail(ProcessOnEventRequestDto processOnEventRequestDto) {
    return baseMapper.detail(processOnEventRequestDto.getProcessInfoDto().getBusinessKey())
}

@Mapper
public interface PayMoneyMapper extends BaseMapper<PayMoney> {

    @Select("select id, name, amount, account, has_repayment, pay_amount from pay_money m where m.business_key = #{businessKey} for update")
    PayMoneyDto detail(@Param("businessKey") String businessKey);
}

這個時候Seata會對添加了for update的查詢語句進行代理

如果一個全局事務1正在操作,並且未進行二階段提交/回滾的時候,全局鎖是被全局事務1鎖持有的,同時另外一個全局事務2嘗試去查詢相同的數據,由於查詢語句被代理,seata會嘗試去獲取這條數據的全局鎖,直到獲取成功/失敗(重試次數達到配置值)爲止。

問題

在生產上運行接近1年時間,總體來說遇到的問題不算多,解決起來也比較容易,比如以下這個問題

經過排查發現,由於Seata會使用jdbc標準接口嘗試獲取業務操作所對應的表結構,由於表結構改動頻率較少,並且考慮到表結構變更後應用會進行重啓,所以會對錶結構進行緩存,如果表結構改動後不對應用進行重啓,有可能引發構建鏡像時出現NullPointerException。下面貼出關鍵代碼

@Override
public TableMeta getTableMeta(final Connection connection, final String tableName, String resourceId) {
    if (StringUtils.isNullOrEmpty(tableName)) {
        throw new IllegalArgumentException("TableMeta cannot be fetched without tableName");
    }

    TableMeta tmeta;
    final String key = getCacheKey(connection, tableName, resourceId);
    //錯誤關鍵處,嘗試從緩存獲取表結構
    tmeta = TABLE_META_CACHE.get(key, mappingFunction -> {
        try {
            return fetchSchema(connection, tableName);
        } catch (SQLException e) {
            LOGGER.error("get table meta of the table `{}` error: {}", tableName, e.getMessage(), e);
            return null;
        }
    });

    if (tmeta == null) {
        throw new ShouldNeverHappenException(String.format("[xid:%s]get table meta failed," +
                                                           " please check whether the table `%s` exists.", RootContext.getXID(), tableName));
    }
    return tmeta;
}

修改表結構,需要對應用進行重啓,即可解決此問題,非常簡單

第二個遇到的問題就是在生產運行一段時間後,發現branch_table和lock_table存在數據殘留,並且根據xid查詢global_table沒有對應的數據,導致後續操作相同的數據行會出現獲取全局鎖失敗,並且會每隔一段時間小量出現。這個異常隱藏的比較深,而且在開發環境和測試環境無法復現,通過跟蹤源碼和總結原因發現,是由於開啓了Mysql主從,導致提交/回滾時,Seata通過xid查詢分支事務時,數據未同步到從庫,導致遺漏了一部分分支事務數據。

源碼部分

@Override
public GlobalStatus commit(String xid) throws TransactionException {
    //根據xid查詢信息,如果開啓主從,會有可能導致查詢信息不完整
    GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
    if (globalSession == null) {
        return GlobalStatus.Finished;
    }
    globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
    // just lock changeStatus

    boolean shouldCommit = SessionHolder.lockAndExecute(globalSession, () -> {
        // Highlight: Firstly, close the session, then no more branch can be registered.
        globalSession.closeAndClean();
        if (globalSession.getStatus() == GlobalStatus.Begin) {
            if (globalSession.canBeCommittedAsync()) {
                globalSession.asyncCommit();
                return false;
            } else {
                globalSession.changeStatus(GlobalStatus.Committing);
                return true;
            }
        }
        return false;
    });

    if (shouldCommit) {
        boolean success = doGlobalCommit(globalSession, false);
        //If successful and all remaining branches can be committed asynchronously, do async commit.
        if (success && globalSession.hasBranch() && globalSession.canBeCommittedAsync()) {
            globalSession.asyncCommit();
            return GlobalStatus.Committed;
        } else {
            return globalSession.getStatus();
        }
    } else {
        return globalSession.getStatus() == GlobalStatus.AsyncCommitting ? GlobalStatus.Committed : globalSession.getStatus();
    }
}
@Override
public GlobalStatus rollback(String xid) throws TransactionException {
    //根據xid查詢信息,如果開啓主從,會有可能導致查詢信息不完整
    GlobalSession globalSession = SessionHolder.findGlobalSession(xid);
    if (globalSession == null) {
        return GlobalStatus.Finished;
    }
    globalSession.addSessionLifecycleListener(SessionHolder.getRootSessionManager());
    // just lock changeStatus
    boolean shouldRollBack = SessionHolder.lockAndExecute(globalSession, () -> {
        globalSession.close(); // Highlight: Firstly, close the session, then no more branch can be registered.
        if (globalSession.getStatus() == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
            return true;
        }
        return false;
    });
    if (!shouldRollBack) {
        return globalSession.getStatus();
    }

    doGlobalRollback(globalSession, false);
    return globalSession.getStatus();
}

部署-高可用

Seata和其他中間件的高可用部署方式差別不大,如圖片所示,確保應用服務和TC訪問相同的註冊中心和配置中心,同時只需要啓動多臺TC,並將store.mode改爲db模式即可完成高可用部署,並選擇合適的註冊中心和配置中心即可,目前支持的配置中心有

  • nacos
  • consul
  • etcd3
  • eureka
  • redis
  • sofa
  • zookeeper

可選的配置中心有

  • nacos
  • etcd3
  • consul
  • apollo
  • zk

部署-單節點多應用

當然也有更加靈活的部署方式,通過vgoup-mapping(事務集羣),可以做到單節點多應用的隔離,比如A應用和B應用訪問A-Group的兩個TC,C應用和D應用訪問B-Group的兩個TC,E應用和F應用訪問C-Group的兩個TC。

部署-異地容災

通過vgoup-mapping也可以做到異地容災,當原有集羣出現不可用時,可以通過變更配置立刻轉移到備用的集羣上。此處以Nacos作爲註冊中心舉例,TC配置方式如下:

# 廣州機房
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  loadBalance = "RandomLoadBalance"
  loadBalanceVirtualNodes = 10

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "Guangzhou"
    username = ""
    password = ""
  }
}
# 上海機房
registry {
  # file 、nacos 、eureka、redis、zk、consul、etcd3、sofa
  type = "nacos"
  loadBalance = "RandomLoadBalance"
  loadBalanceVirtualNodes = 10

  nacos {
    application = "seata-server"
    serverAddr = "127.0.0.1:8848"
    group = "SEATA_GROUP"
    namespace = ""
    cluster = "Shanghai"
    username = ""
    password = ""
  }
}

作者 | 張嘉偉

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章