逸仙電商 Seata 企業級落地實踐
作者 | 張嘉偉(GitHub ID:l81893521)
就職於逸仙電商交易中心;Seata Committer,加入 Seata 社區已有一年半,見證了從 Fescar 到 Seata 的變更,GA 等。
你可能沒有聽說過逸仙電商,但是你的女朋友不可能沒有聽說過它。逸仙電商旗下有完美日記、小奧汀、完子心選等品牌。完美日記作爲國貨美妝界的黑馬用了不到三年時間,達到了行業龍頭企業通常需要十年以上才能達到的營收規模。2020 年正式登陸紐約證券交易所,成爲第一家在美國上市的 “國貨美妝品牌”。在快速增長的業務下,系統流量增長速度越來越快,服務數量不斷增多,調用鏈路錯綜複雜,數據不一致的問題日漸顯現,爲了降低人力成本和系統資源,我們選擇了 Seata。
本文將會以逸仙電商的業務作爲背景, 先介紹一下 seata 的原理, 並給大家進行線上演示, 由淺入深去介紹這款中間件, 以便讀者更加容易去理解 Seata 這個中間件。
- 問題背景
在微服務的架構下,數據不一致的產生原因
- 業務介紹
挑選了逸仙電商一些比較簡單易懂的業務作爲開展背景
- 原理分析
Seata 的實現原理和故障解決以及部署方案
- Demo 演示
如何在線體驗這款中間件,無需整合和下載任何代碼
數據不一致的原因
在微服務的環境下,由於調用鏈路跨越多個應用,甚至跨越多個數據源,數據的一致性在普通情況下難以保證,導致數據不一致的原因非常多,這裏列舉了三個最常見的原因
- 業務異常一個服務鏈路調用中,如果調用的過程出現業務異常,產生異常的應用獨立回滾,非異常的應用數據已經持久化到數據庫。
- 網絡異常調用的過程中,由於網絡不穩定,導致鏈路中斷,部分應用業務執行完成,部分應用業務未被執行。
- 服務不可用若服務不可用,無法被正常調用,也會導致問題的產生
這裏挑選了逸仙電商業務體系裏面一個非常通俗容易理解的調用方式,並且去掉了多餘複雜的鏈路,方便在閱讀過程中更加關注重點。
在以往如果出現數據不一致的問題,相信大多數的解決方案是這樣的
- 人工補償數據
- 定時任務檢查和補償數據
但是這兩種方式的缺點也是顯然意見的,一種是浪費大量的人力成本和時間,另外一種是浪費大量的系統資源去檢查數據是否一致和額外的人力成本。
接下來我會根據逸仙在生產上穩定運行將近一年總結的經驗並且儘可能簡單的去描述 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 的處理過程,相信理解起來會簡單很多,
- 首先一個使用了 @GlobalTransactional 的接口被調用,Seata 會對其進行攔截,攔截的角色我們稱之爲 TM,這個時候會訪問 TC 開啓一個新的全局事務,TC 收到請求後會生成 XID 和全局事務信息並持久化,然後返回 XID。
- 在每一層的調用鏈路中,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();
}
相信此問題會在支持 Raft 之後得到完美的解決
pr: https://github.com/seata/seata/pull/3086
有興趣的朋友也可以嘗試去 review 一下代碼
部署 - 高可用
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 = ""
}
}
Demo
最後通過訪問阿里雲知行動手首頁,即可在線快速體驗各種各樣的中間件:
Seata 直達傳送門,無需下載代碼,在線編譯和部署:
https://start.aliyun.com/handson/isnEO76f/distributedtransaction
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://blog.csdn.net/weixin_39860915/article/details/115869121