分布式事務,阿里為什么鐘愛TCC
本文轉載自微信公眾號「程序員jinjunzhu」,作者jinjunzhu 。轉載本文請聯系程序員jinjunzhu公眾號。
分布式事務的實現方式中,TCC是比較知名的模式。但是我一直不喜歡這種模式,原因是這種模式有很多問題要考慮。
之前寫過一篇文章說了TCC的很多缺點,后來我把文章刪了,原因是一位阿里大佬加我好友并指正了我的觀點。
太感謝了!
1 TCC概要
簡單來講,TCC模式就是將整個事務分成兩個階段來提交,try階段進行預留資源,如果所有分支都預留成功,則進入commit階段提交所有分支事務,否則執行cancel取消所有分支事務。
以電商系統為例,假如有訂單、庫存和賬戶3個服務,客戶購買一件商品,訂單服務增加訂單,庫存服務扣減庫存,賬戶服務扣減金額,這三個操作必須是原子性的,要么全部成功,要么全部失敗。
try階段
如下圖:
訂單服務增加一個訂單,庫存服務凍結訂單上的庫存,賬戶服務凍結訂單上的金額。
訂單、庫存和賬戶這三個服務作為整個分布式事務的分支事務,在try階段都是要提交本地事務的。上面庫存和賬戶說的凍結,就是說這個訂單對應的庫存和金額已經不能再被其他事務使用了,所以必須提交本地事務。
但這個提交并不是真正的提交全局事務,而是把資源轉到中間態,這個中間態需要在try方法的業務代碼中實現,比如賬戶扣除的金額可以先存放到一個中間賬戶。
如果try階段不提交本地事務會有什么問題呢?有可能其他事務在try階段發現用戶賬戶里面的金額還夠,但是commit的時候發現金額不夠了,commit階段扣款只能失敗,這時其他兩個分支事務提交成功而賬戶服務的分支事務提交失敗,最終數據就不一致了。
commit階段
如下圖:
commit階段,數據從中間態轉入終態,比如訂單金額從中間賬戶轉到最終賬戶。
cancel階段跟commit階段類似,比如訂單金額從中間賬戶退回到客戶賬戶。
2 問題代碼
下面這段代碼也可以理解為TCC,是在try階段hold住了connection,不提交分支事務,到commit階段再提交分支事務。代碼如下:我們以扣減賬戶為例,首先定義2個變量來hold住connection:
- private Map<String, Statement> statementMap = new ConcurrentHashMap<>(100);
- private Map<String, Connection> connectionMap = new ConcurrentHashMap<>(100);
try方法代碼如下:
- public boolean try(String xid, Long userId, BigDecimal payAmount) {
- LOGGER.info("decrease, xid:{}", xid);
- LOGGER.info("------->嘗試扣減賬戶開始account");
- try {
- //嘗試扣減賬戶金額,事務不提交
- Connection connection = hikariDataSource.getConnection();
- connection.setAutoCommit(false);
- String sql = "UPDATE account SET balance = balance - ?,used = used + ? where user_id = ?";
- PreparedStatement stmt = connection.prepareStatement(sql);
- stmt.setBigDecimal(1, payAmount);
- stmt.setBigDecimal(2, payAmount);
- stmt.setLong(3, userId);
- stmt.executeUpdate();
- statementMap.put(xid, stmt);
- connectionMap.put(xid, connection);
- } catch (Exception e) {
- LOGGER.error("decrease parepare failure:", e);
- return false;
- }
- LOGGER.info("------->嘗試扣減賬戶結束account");
- return true;
- }
commit方法代碼如下:
- public boolean commit(BusinessActionContext actionContext){
- String xid = actionContext.getXid();
- PreparedStatement statement = (PreparedStatement) statementMap.get(xid);
- Connection connection = connectionMap.get(xid);
- try {
- if (null != connection){
- connection.commit();
- }
- } catch (SQLException e) {
- LOGGER.error("扣減賬戶失敗:", e);
- return false;
- }finally {
- try {
- statementMap.remove(xid);
- connectionMap.remove(xid);
- if (null != statement){
- statement.close();
- }
- if (null != connection){
- connection.close();
- }
- } catch (SQLException e) {
- LOGGER.error("扣減賬戶提交事務后關閉連接池失敗:", e);
- }
- }
- return true;
- }
cancel方法代碼如下:
- public boolean rollback(BusinessActionContext actionContext){
- String xid = actionContext.getXid();
- PreparedStatement statement = (PreparedStatement) statementMap.get(xid);
- Connection connection = connectionMap.get(xid);
- try {
- connection.rollback();
- } catch (SQLException e) {
- return false;
- }finally {
- try {
- if (null != statement){
- statement.close();
- }
- if (null != connection){
- connection.close();
- }
- statementMap.remove(xid);
- connectionMap.remove(xid);
- } catch (SQLException e) {
- LOGGER.error("扣減賬戶回滾事務后關閉連接池失敗:", e);
- }
- }
- return true;
- }
這段代碼是問題代碼,不能用,不能用,不能用
這個代碼存在兩個問題:
2.1 阻塞等待
如果當前事務不提交,比如賬戶服務,那就相當于是鎖定了資源,后面的事務只能等待資源釋放。
2.2 服務集群
以訂單服務為例,假如訂單服務是一個3個機器的集群,如下圖:
協調節點使用注冊中心客戶端來調用訂單服務,如果try請求發送到了訂單服務1,而commit請求發送到了訂單服務2,那訂單服務2上的connectionMap里不會有xid=123這個connection,只能提交失敗。
3 TCC存在的問題
上面的問題代碼就是給大家一個思路,如果真要hold住connection,也算是實現了TCC的思想,但是在系統中,我們是不可能這樣做的,所以把它叫做問題代碼。
3.1 空回滾
如下圖,訂單服務1節點故障,如果不考慮重試,try方法失敗:
try雖然失敗了,但是全局事務已經開啟,框架必須要把這個全局事務推向結束狀態,這就不得不調用訂單服務cancel方法進行回滾,結果訂單服務空跑了一次cancel方法。
解決這個問題,可以記錄一張事務控制表,保存全局事務xid和分支事務branchId,try階段會插入一條記錄,表示try階段執行了。cancel方法讀取該記錄,如果記錄存在,正?;貪L;如果該記錄不存在,那就是空回滾。
3.2 冪等
冪等是指在commit/cancel階段,因為TC沒有收到分支事務的響應,需要進行重試,這就要分支事務支持冪等。以訂單服務為例。如下圖:
要支持冪等,可以記錄一張事務控制表,保存全局事務xid和分支事務branchId,以及分支事務狀態,在第二階段commit/cancel之前先檢查分支事務狀態是否已經是終態,如果不是,再執行第二階段的邏輯。
3.3 懸掛
懸掛是指事務的cancel方法比try方法先執行。上面講了seata的使用過程中會發生空回滾,如果發生了空回滾,執行了cancel方法后全局事務結束了,但是因為網絡問題,訂單服務又收到了try請求,執行try方法后預留資源成功,這些資源最終不能釋放了。
解決這個問題的方法就是在cancel方法中記錄xid對應的分支事務回滾記錄,try階段執行的時候先判斷分支事務是否已經回滾,如果存在回滾記錄,則直接退出。
3.4 業務代碼侵入
TCC的try/commit/cancel,對業務代碼都有侵入,而且每個方法都是一個本地事務。再加上需要考慮冪等、空回滾、懸掛等,代碼侵入會更高。
4.TCC優勢
這里以seata實現的四種模式來比較,包括XA、SAGA、TCC、AT。
效率
使用TCC模式時,在try階段就提交了本地事務,并不會鎖定資源,所以沒有其他額外的性能開銷。相比之下,來看其他幾種模式:
- AT模式,需要記錄undolog,性能損耗很大。
- XA模式,執行xa start | sql | xa end之后,執行commit/rollback之前,會鎖定資源,后面的事務需要等待。
saga模式
更適合長流程的業務場景。
5.性能優化
參考[1]
5.1 異步提交
優化思路是try階段成功后,不立即執行confirm/cancel階段,而是等系統空閑的時候異步執行。如下圖:
這樣在try階段結束后,就認為全局事務結束了,可以定時(比如10分鐘)來異步執行第二階段,性能大幅提升。
當然,帶來的一點問題就是如果全局事務回滾,會有短暫的數據不一致。比如扣款的場景,定時10分鐘執行一次異步任務,如果第二階段是cancel,那客戶會在這10分鐘內不能使用這筆金額。
這個異步執行的時間也可以根據業務來決定,比如不需要及時從中間賬戶轉移到最終賬戶的場景可以設置更長。
5.2 同庫模式
首先回顧一下TCC中各個角色:
- TM管理全局事務,包括開啟全局事務,提交/回滾全局事務
- RM管理分支事務
- TC管理全局事務和分支事務的狀態
先看一下優化之前的通信模型,如下圖:
在優化之前,TM開啟全局事務時,RM需要向TC發送RPC消息進行注冊,TC保存分支事務的狀態。TM請求提交或回滾時,TC需要向RM發送RPC消息進行提交或回滾。這樣包含兩個個分支事務的分布式事務中,TC和RM之間有四次RPC。
優化之后的模型如下圖:
TM開啟全局事務時,不再需要向TC注冊分支事務,而是把分支事務狀態保存在了本地。TM向TC發送提交或回滾消息時,TC保存全局事務的狀態。而RM則啟動異步線程檢測本地記錄的未提交分支事務,向TC發送RPC消息獲取整體事務狀態,以決定是提交還是回滾本地事務。可見,優化后的模型,RPC次數減少了50%,性能大幅提升。
6.總結
TCC的問題確實不少,但是除了侵入業務代碼這一個問題,其他問題都有對應的解決方案。
阿里針對TCC做了一些優化,包括第二階段異步提交和同庫模式,性能提升很明顯。