1. 分布式事務(wù),阿里為什么鐘愛TCC

        共 8821字,需瀏覽 18分鐘

         ·

        2021-06-23 21:29

        分布式事務(wù)的實現(xiàn)方式中,TCC是比較知名的模式。但是我一直不喜歡這種模式,原因是這種模式有很多問題要考慮。

        之前寫過一篇文章說了TCC的很多缺點,后來我把文章刪了,原因是一位阿里大佬加我好友并指正了我的觀點。

        太感謝了!

        1 TCC概要

        簡單來講,TCC模式就是將整個事務(wù)分成兩個階段來提交,try階段進(jìn)行預(yù)留資源,如果所有分支都預(yù)留成功,則進(jìn)入commit階段提交所有分支事務(wù),否則執(zhí)行cancel取消所有分支事務(wù)。

        以電商系統(tǒng)為例,假如有訂單、庫存和賬戶3個服務(wù),客戶購買一件商品,訂單服務(wù)增加訂單,庫存服務(wù)扣減庫存,賬戶服務(wù)扣減金額,這三個操作必須是原子性的,要么全部成功,要么全部失敗。

        try階段

        如下圖:

        訂單服務(wù)增加一個訂單,庫存服務(wù)凍結(jié)訂單上的庫存,賬戶服務(wù)凍結(jié)訂單上的金額。

        訂單、庫存和賬戶這三個服務(wù)作為整個分布式事務(wù)的分支事務(wù),在try階段都是要提交本地事務(wù)的。上面庫存和賬戶說的凍結(jié),就是說這個訂單對應(yīng)的庫存和金額已經(jīng)不能再被其他事務(wù)使用了,所以必須提交本地事務(wù)。

        但這個提交并不是真正的提交全局事務(wù),而是把資源轉(zhuǎn)到中間態(tài),這個中間態(tài)需要在try方法的業(yè)務(wù)代碼中實現(xiàn),比如賬戶扣除的金額可以先存放到一個中間賬戶。

        如果try階段不提交本地事務(wù)會有什么問題呢?有可能其他事務(wù)在try階段發(fā)現(xiàn)用戶賬戶里面的金額還夠,但是commit的時候發(fā)現(xiàn)金額不夠了,commit階段扣款只能失敗,這時其他兩個分支事務(wù)提交成功而賬戶服務(wù)的分支事務(wù)提交失敗,最終數(shù)據(jù)就不一致了。

        commit階段

        如下圖:

        commit階段,數(shù)據(jù)從中間態(tài)轉(zhuǎn)入終態(tài),比如訂單金額從中間賬戶轉(zhuǎn)到最終賬戶。

        cancel階段跟commit階段類似,比如訂單金額從中間賬戶退回到客戶賬戶。

        2 問題代碼

        下面這段代碼也可以理解為TCC,是在try階段hold住了connection,不提交分支事務(wù),到commit階段再提交分支事務(wù)。代碼如下:我們以扣減賬戶為例,首先定義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 {
                //嘗試扣減賬戶金額,事務(wù)不提交
                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("------->嘗試扣減賬戶結(jié)束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("扣減賬戶提交事務(wù)后關(guān)閉連接池失敗:", 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("扣減賬戶回滾事務(wù)后關(guān)閉連接池失敗:", e);
                }
            }
            return true;
        }

        這段代碼是問題代碼,不能用,不能用,不能用

        這個代碼存在兩個問題:

        2.1 阻塞等待

        如果當(dāng)前事務(wù)不提交,比如賬戶服務(wù),那就相當(dāng)于是鎖定了資源,后面的事務(wù)只能等待資源釋放。

        2.2 服務(wù)集群

        以訂單服務(wù)為例,假如訂單服務(wù)是一個3個機器的集群,如下圖:

        協(xié)調(diào)節(jié)點使用注冊中心客戶端來調(diào)用訂單服務(wù),如果try請求發(fā)送到了訂單服務(wù)1,而commit請求發(fā)送到了訂單服務(wù)2,那訂單服務(wù)2上的connectionMap里不會有xid=123這個connection,只能提交失敗。

        3 TCC存在的問題

        上面的問題代碼就是給大家一個思路,如果真要hold住connection,也算是實現(xiàn)了TCC的思想,但是在系統(tǒng)中,我們是不可能這樣做的,所以把它叫做問題代碼。

        3.1 空回滾

        如下圖,訂單服務(wù)1節(jié)點故障,如果不考慮重試,try方法失?。?/p>

        try雖然失敗了,但是全局事務(wù)已經(jīng)開啟,框架必須要把這個全局事務(wù)推向結(jié)束狀態(tài),這就不得不調(diào)用訂單服務(wù)cancel方法進(jìn)行回滾,結(jié)果訂單服務(wù)空跑了一次cancel方法。

        解決這個問題,可以記錄一張事務(wù)控制表,保存全局事務(wù)xid和分支事務(wù)branchId,try階段會插入一條記錄,表示try階段執(zhí)行了。cancel方法讀取該記錄,如果記錄存在,正常回滾;如果該記錄不存在,那就是空回滾。

        3.2 冪等

        冪等是指在commit/cancel階段,因為TC沒有收到分支事務(wù)的響應(yīng),需要進(jìn)行重試,這就要分支事務(wù)支持冪等。以訂單服務(wù)為例。如下圖:

        要支持冪等,可以記錄一張事務(wù)控制表,保存全局事務(wù)xid和分支事務(wù)branchId,以及分支事務(wù)狀態(tài),在第二階段commit/cancel之前先檢查分支事務(wù)狀態(tài)是否已經(jīng)是終態(tài),如果不是,再執(zhí)行第二階段的邏輯。

        3.3 懸掛

        懸掛是指事務(wù)的cancel方法比try方法先執(zhí)行。上面講了seata的使用過程中會發(fā)生空回滾,如果發(fā)生了空回滾,執(zhí)行了cancel方法后全局事務(wù)結(jié)束了,但是因為網(wǎng)絡(luò)問題,訂單服務(wù)又收到了try請求,執(zhí)行try方法后預(yù)留資源成功,這些資源最終不能釋放了。

        解決這個問題的方法就是在cancel方法中記錄xid對應(yīng)的分支事務(wù)回滾記錄,try階段執(zhí)行的時候先判斷分支事務(wù)是否已經(jīng)回滾,如果存在回滾記錄,則直接退出。

        3.4 業(yè)務(wù)代碼侵入

        TCC的try/commit/cancel,對業(yè)務(wù)代碼都有侵入,而且每個方法都是一個本地事務(wù)。再加上需要考慮冪等、空回滾、懸掛等,代碼侵入會更高。

        4.TCC優(yōu)勢

        這里以seata實現(xiàn)的四種模式來比較,包括XA、SAGA、TCC、AT。

        效率

        使用TCC模式時,在try階段就提交了本地事務(wù),并不會鎖定資源,所以沒有其他額外的性能開銷。相比之下,來看其他幾種模式:

        • AT模式,需要記錄undolog,性能損耗很大。
        • XA模式,執(zhí)行xa start | sql | xa end之后,執(zhí)行commit/rollback之前,會鎖定資源,后面的事務(wù)需要等待。

        saga模式

        更適合長流程的業(yè)務(wù)場景。

        5.性能優(yōu)化

        參考[1]

        5.1 異步提交

        優(yōu)化思路是try階段成功后,不立即執(zhí)行confirm/cancel階段,而是等系統(tǒng)空閑的時候異步執(zhí)行。如下圖:

        這樣在try階段結(jié)束后,就認(rèn)為全局事務(wù)結(jié)束了,可以定時(比如10分鐘)來異步執(zhí)行第二階段,性能大幅提升。

        當(dāng)然,帶來的一點問題就是如果全局事務(wù)回滾,會有短暫的數(shù)據(jù)不一致。比如扣款的場景,定時10分鐘執(zhí)行一次異步任務(wù),如果第二階段是cancel,那客戶會在這10分鐘內(nèi)不能使用這筆金額。

        這個異步執(zhí)行的時間也可以根據(jù)業(yè)務(wù)來決定,比如不需要及時從中間賬戶轉(zhuǎn)移到最終賬戶的場景可以設(shè)置更長。

        5.2 同庫模式

        首先回顧一下TCC中各個角色:

        • TM管理全局事務(wù),包括開啟全局事務(wù),提交/回滾全局事務(wù)
        • RM管理分支事務(wù)
        • TC管理全局事務(wù)和分支事務(wù)的狀態(tài)

        先看一下優(yōu)化之前的通信模型,如下圖:

        在優(yōu)化之前,TM開啟全局事務(wù)時,RM需要向TC發(fā)送RPC消息進(jìn)行注冊,TC保存分支事務(wù)的狀態(tài)。TM請求提交或回滾時,TC需要向RM發(fā)送RPC消息進(jìn)行提交或回滾。這樣包含兩個個分支事務(wù)的分布式事務(wù)中,TC和RM之間有四次RPC。

        優(yōu)化之后的模型如下圖:

        TM開啟全局事務(wù)時,不再需要向TC注冊分支事務(wù),而是把分支事務(wù)狀態(tài)保存在了本地。TM向TC發(fā)送提交或回滾消息時,TC保存全局事務(wù)的狀態(tài)。而RM則啟動異步線程檢測本地記錄的未提交分支事務(wù),向TC發(fā)送RPC消息獲取整體事務(wù)狀態(tài),以決定是提交還是回滾本地事務(wù)??梢?,優(yōu)化后的模型,RPC次數(shù)減少了50%,性能大幅提升。

        6.總結(jié)

        TCC的問題確實不少,但是除了侵入業(yè)務(wù)代碼這一個問題,其他問題都有對應(yīng)的解決方案。

        阿里針對TCC做了一些優(yōu)化,包括第二階段異步提交和同庫模式,性能提升很明顯。

        有道無術(shù),術(shù)可成;有術(shù)無道,止于術(shù)

        歡迎大家關(guān)注Java之道公眾號


        好文章,我在看??

        瀏覽 102
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
        評論
        圖片
        表情
        推薦
        點贊
        評論
        收藏
        分享

        手機掃一掃分享

        分享
        舉報
          
          

            1. 西西4444WWW大胆无视频 | 国产亲子私乱av 全肉乱妇淑芬全文阅读 | 天日天天日 | 五月无码在线 | 久久三级网 |