分布式事務那些事兒

分布式事務概述

說起分布式事務,是個讓人又愛又恨的話題。恨他,因為這個世界性難題始終沒有一個完美的解決方案。而愛他,因為他引出了一系列解巧妙決方案,不得不感嘆一代又一代計算機人的智慧。這篇文章我們就來談談分布式事務的一些解決方案,其中互聯網公司中最常用哪種?MQ又如何和事務聯系起來呢?

模型

在正式開始介紹之前,我們先通過兩張圖例來看看什么是分布式事務的情況。

本地事務

上圖我們再熟悉不過,就是傳統的本地事務,我們操作的數據庫是一個單數據源,通過數據庫提供的事務就可以輕松完成,業務上通過spring的聲明式事務注解@Transitional就可以,可是如果多數據源的情況呢?
分布式事務-跨庫事務

像這樣其實就是分布式事務的一種情況了,跨庫事務。很明顯本地事務那套方式已經玩不起來了,要怎么辦呢?當然是兩階段提交嘍,別急,先賣個關子,后面我們詳細來解釋,下面再來看一種情況。
分布式事務-微服務

以上大概是互聯網最常見的一種模型了,微服務間的分布式事務。這種情況甚至服務間存在于不同的jvm進程。這里要使用的方式就是大名鼎鼎的TCC了,這個我們后面再詳細探討具體的實現方式。

好了,了解了分布式事務產生的模型,我們就可以開始正式開始吹牛逼之旅了,等等,貌似還需要交代幾個小小的理論。

BASE理論

為什么說分布式事務是個難題?因為要達到強一致性(數據更新后立即達到一致狀態)是非常困難的,所以有了中間狀態(軟狀態),即允許數據在某一狀態下是不一致的,但是要盡可能保證最終一致性。這其實也就是BASE理論了,他的定義如下:
base理論

  1. 基本可用(Basically Availability)
    指分布式系統出現不可預知錯誤的時候,允許損失部分可用性。
  2. 軟狀態(Soft State)
    也就是中間狀態,允許存在這種狀態并認為不會影響系統的整體可用性,即允許不同節點在的數據傳輸之間存在延遲。
  3. 最終一致(Eventual Consistency)
    在數據更新操作之后,在經過一定時間的同步之后,最終都能達到一個一致的狀態。不需要保證系統的強一致性。

兩階段提交(Two Phase Commitment)

所謂兩階段提交,顧名思義,就是把事務的提交分成兩個階段,但是注意,這兩個階段是在一組操作中的,不要誤以為是兩組操作。可能這么說不是很明白,我們再來看張圖例


兩階段提交

還不明白也沒關系,等介紹完了XA和TCC再回過頭看看就GET了。好了,下面我們就從XA開始,正式開始來了解分布式事務的那些解決方案。

XA/JTA方案

XA是業界關于分布式管理的一個規范,而JTA是JAVA的一個實現。
在XA中,我們引入了一個中間協調者的角色。在第一階段中,所有的參與者需要鎖住要操作的資源,進行操作,然后通知協調者已經準備就緒,可以提交事務。
第二階段時協調者收到了某個參與者發送的請求,得知了他們都已經達到了可以提交事務的狀態,接著像所有參與者發送commit命令,事務提交。如果有一方參與者執行失敗,那協調器就會發送rollback命令,各個參與者都回滾。
都說talk is cheap,show me the code,下面我們就來看看上述過程使用JTA實現的一組代碼

boolean logXaCommands = true;
        // 獲得資源管理器操作接口實例 RM1
        Connection conn1 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test", "root", "root");
        XAConnection xaConn1 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn1, logXaCommands);
        XAResource rm1 = xaConn1.getXAResource();
        // 獲得資源管理器操作接口實例 RM2
        Connection conn2 = DriverManager.getConnection("jdbc:mysql://localhost:3306/test1", "root", "root");
        XAConnection xaConn2 = new MysqlXAConnection((com.mysql.jdbc.Connection) conn2, logXaCommands);
        XAResource rm2 = xaConn2.getXAResource();
        // AP請求TM執行一個分布式事務,TM生成全局事務id
        byte[] gtrid = "g12345".getBytes();
        int formatId = 1;
        try {
            // ==============分別執行RM1和RM2上的事務分支====================
            // TM生成rm1上的事務分支id
            byte[] bqual1 = "b00001".getBytes();
            Xid xid1 = new MysqlXid(gtrid, bqual1, formatId);
            // 執行rm1上的事務分支
            rm1.start(xid1, XAResource.TMNOFLAGS);// One of TMNOFLAGS, TMJOIN,
                                                  // or TMRESUME.
            PreparedStatement ps1 = conn1.prepareStatement("INSERT into user(name) VALUES ('tianshouzhi')");
            ps1.execute();
            rm1.end(xid1, XAResource.TMSUCCESS);
            // TM生成rm2上的事務分支id
            byte[] bqual2 = "b00002".getBytes();
            Xid xid2 = new MysqlXid(gtrid, bqual2, formatId);
            // 執行rm2上的事務分支
            rm2.start(xid2, XAResource.TMNOFLAGS);
            PreparedStatement ps2 = conn2.prepareStatement("INSERT into user(name) VALUES ('wangxiaoxiao')");
            ps2.execute();
            rm2.end(xid2, XAResource.TMSUCCESS);
            // ===================兩階段提交================================
            // phase1:詢問所有的RM 準備提交事務分支
            int rm1_prepare = rm1.prepare(xid1);
            int rm2_prepare = rm2.prepare(xid2);
            // phase2:提交所有事務分支
            boolean onePhase = false; // TM判斷有2個事務分支,所以不能優化為一階段提交
                       // 所有事務分支都prepare成功,提交所有事務分支
            if (rm1_prepare == XAResource.XA_OK && rm2_prepare == XAResource.XA_OK) {
                rm1.commit(xid1, onePhase);
                rm2.commit(xid2, onePhase);
            } else {// 如果有事務分支沒有成功,則回滾
                rm1.rollback(xid1);
                rm1.rollback(xid2);
            }
        } catch (XAException e) {
            // 如果出現異常,也要進行回滾
            e.printStackTrace();
        }

再來看看atomikos的實現方式,atomikos的免費開源版也實現了XA

private static AtomikosDataSourceBean createAtomikosDataSourceBean(String dbName) {
        // 連接池基本屬性
        Properties p = new Properties();
        p.setProperty("url", "jdbc:mysql://localhost:3306/" + dbName);
        p.setProperty("user", "root");
        p.setProperty("password", "root");

        // 使用AtomikosDataSourceBean封裝com.mysql.jdbc.jdbc2.optional.MysqlXADataSource
        AtomikosDataSourceBean ds = new AtomikosDataSourceBean();
        // atomikos要求為每個AtomikosDataSourceBean名稱,為了方便記憶,這里設置為和dbName相同
        ds.setUniqueResourceName(dbName);
        ds.setXaDataSourceClassName("com.mysql.jdbc.jdbc2.optional.MysqlXADataSource");
        ds.setXaProperties(p);
        return ds;
    }

    public static void main(String[] args) throws Exception {

        AtomikosDataSourceBean ds1 = createAtomikosDataSourceBean("db_user");
        AtomikosDataSourceBean ds2 = createAtomikosDataSourceBean("db_account");

        Connection conn1 = null;
        Connection conn2 = null;
        PreparedStatement ps1 = null;
        PreparedStatement ps2 = null;

        UserTransaction userTransaction = new UserTransactionImp();
        try {
            // 開啟事務
            userTransaction.begin();

            // 執行db1上的sql
            conn1 = ds1.getConnection();
            ps1 = conn1.prepareStatement("INSERT into user(name) VALUES (?)", Statement.RETURN_GENERATED_KEYS);
            ps1.setString(1, "tianshouzhi");
            ps1.executeUpdate();
            ResultSet generatedKeys = ps1.getGeneratedKeys();
            int userId = -1;
            while (generatedKeys.next()) {
                userId = generatedKeys.getInt(1);// 獲得自動生成的userId
            }

            // 模擬異常 ,直接進入catch代碼塊,2個都不會提交
            // int i=1/0;

            // 執行db2上的sql
            conn2 = ds2.getConnection();
            ps2 = conn2.prepareStatement("INSERT into account(user_id,money) VALUES (?,?)");
            ps2.setInt(1, userId);
            ps2.setDouble(2, 10000000);
            ps2.executeUpdate();

            // 兩階段提交
            userTransaction.commit();
        } catch (Exception e) {
            try {
                e.printStackTrace();
                userTransaction.rollback();
            } catch (SystemException e1) {
                e1.printStackTrace();
            }
        } finally {
            try {
                ps1.close();
                ps2.close();
                conn1.close();
                conn2.close();
                ds1.close();
                ds2.close();
            } catch (Exception ignore) {
            }
        }
    }

簡單很多了對吧?其實它們的原理時十分類似的,所以如果有面試問到atomikos的具體實現方式,你懂得!
說了這么多,其實這種方式更多的是提供一種解決問題的思路,實際環境中時不太可能這么玩的,因為這種方式性能太差了,他需要鎖住相應的資源,互聯網項目中有著很高并發吞吐量,這種方式很明顯不適合,所以還是得引入我們今天要討論的第二種方式:TCC

TCC兩階段補償性方案

根據上面的圖例,也可以看出TCC就是 Try-Confirm-Cancel的簡稱,為了讓大家更好的理解這種方案,我舉個例子來說明。

TCC示例圖1

假設我們需要一張從合肥飛往大理的機票,但是沒有直飛,怎么辦?難道一張一張買嗎,更不用說還要留意中轉時間是否合理等等問題,所以我們往往會選擇一個機票預訂平臺,讓他幫我們一次性購買這兩張機票。
這就是一個典型的分布式事務的場景了,機票預訂平臺需要同時向兩家航空公司(不同DB,不同SERVER)發送下單請求,要么同時成功,要么同時失敗。很明顯XA那種方案是完全不適用的,總不能把人家的表資源給鎖了,誰會讓你這么干。所以我們需要的是一種業務上的手段。
TCC示例圖2

好,這種業務手段其實就是TCC,在第一階段中,機票預訂平臺會像兩家航空公司提供的API接口發送請求,預留我們需要的機票。第二階段中, 如果預留操作任意一方不成功,就發送取消請求,訂票不成功。如果成功呢?發送確認請求,完成下單操作。這樣一來,就保證了預定機票這組操作要么同時成功,要么不成功。中間的預留就是中間狀態,但是最終保證了數據的一致性。

你可能有這樣的問題,要是確認階段有一方失敗怎么辦?首先呢,這個失敗幾率不高,但是對于互聯網公司來說,即使很低的幾率對應的訂單量可能也是非常龐大的,所以需要兩方共同提供一定的機制,進一步提高訂票的成功率。比如,機票預訂平臺在收到確認失敗的消息時,可能會有一定的重試機制,若重試若干次時候依然不成功,才會認為是真正的失敗。航空公司則會對接口保證冪等性,對網絡超時失敗的情況(訂單其實已經生成)也要有一定的處置方式。

如果還是失敗呢?BASE定理允許產生一定的不可用,所以我們要對這種情況進行補償。通常使用日志或者MQ的方式進行補償,甚至最后還是需要通過人工對賬的方式。

說到這里已經不難看出,XA是一種資源層面的分布式事務,在兩階段提交的整個過程中,它都會一致持有資源的鎖,是強一致性,而TCC則是業務層面的分布式事務,不會持有資源鎖,保證的是最終一致性。最后再給大家提供一個實現了tcc的框架,有興趣的話可以多看看,我們這里就不提供具體代碼了。

MQ事務方案

終于到了最后一種方案了,其實也很簡單,先看圖


MQ事務方案圖1

服務1先向MQ發送一條中間狀態的prepare消息,此時這條消息不會被消費者收到。接著繼續執行服務1中的業務邏輯,成功后再向MQ發送confirm消息,將這條消息從中間狀態改為可被消費者接受的狀態,消費者收到消息后執行己方業務邏輯,成功后向MQ發送ACK。

這樣同樣保證了分布式事務,且因為存在中間狀態,所以保證的也是最終一致性。如果消費者一方收取消息出現異常或ack請求超時呢?MQ一般都有一定的消息補發重試機制,所以要做好接口的冪等優化。如果confirm請求失敗呢?這時候消息隊列需要像服務1對應的業務發送定時消息來確認當前狀態,如果已經成功,再修改中間狀態即可。

總結

無論哪種方案都不能十全十美的保證分布式事務,所以一定要做好補償。總而言之,業界對于這一難題的解決方案都是柔性事務+補償機制,強調的是最終一致性。要想保證強一致性又不影響性能,這就是一個世界性難題了。不過牛人輩出,說不定哪一天我們就能見到這樣的方案了不是嗎?

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容