天天看點

我悟了!Mysql事務隔離級别其實是這樣

作者:會寫Java的阿偉

問題描述

最近幾天在忙項目,有個項目是将業務收集到的資料變動,異步同步到一張資料表中。在測試的過程時,收到QA的回報,說有訂單的資料同步時好時壞。我懷着疑惑的表情打開了那段代碼,它的邏輯大概是這樣的:

我悟了!Mysql事務隔離級别其實是這樣

如果用簡單的代碼實作的話,會是這樣的:

public void updateAndQuery(Example example, int diff){
        List<ProductPO> productPOS = productMapper.selectByExample(example);
        ProductPO productPO =  productPOS.get(0);
        System.out.println("一次查詢内容" + JSONObject.toJSONString(productPO));

        //二次插入并查詢
        Integer oldNumber = productPO.getNumber();
        productPO.setNumber(oldNumber + diff);

        //首先先更新,再進行查詢
        System.out.println("更新内容:"+ JSONObject.toJSONString(productPO));
        productMapper.updateByPrimaryKey(productPO);

        //異步執行
        CompletableFuture.runAsync(() -> {
            Example example1 = new Example(ProductPO.class);
            example1.createCriteria().andEqualTo("skuId", productPO.getSkuId());
            List<ProductPO> select = productMapper.selectByExample(example1);
            System.out.println("二次查詢結果:"+JSONObject.toJSONString(select));
            if (oldNumber != select.get(0).getNumber() - diff) {
                throw new NrsBusinessException("查詢出錯");
            }
        });
    }
           

起初我左看看,右看看,也沒有想到這個是什麼原因造成的。直到我看到了這個........

@Transactional(rollbackFor = Exception.class)
           

事務執行原理

​ 在了解的問題原因前,我們需要了解事務是如何實作的。首先假設現在我們要設計一個mysql事務,最簡單的方案其實是這樣:每執行一次SQL,寫一次資料庫。大緻流程圖如下所示:

我悟了!Mysql事務隔離級别其實是這樣

​ 但是這個方案好麼?顯然不是。因為我們如果采用這樣的方式,存在兩個顯著的問題:

  • 資料庫寫入為磁盤讀寫,速度很慢。
  • 資料庫存在鎖機制,難支援高并發。

對于問題一,了解到存儲器讀寫速度如下所示(圖源網絡):

我悟了!Mysql事務隔離級别其實是這樣

​ 可以看到記憶體的存儲速度是納秒級别(10-9次方),而硬碟的存儲速度是毫秒級别(10-3次方)。由此,為加快讀寫速度,可以将修改的内容寫入記憶體,而後再異步寫入磁盤。

​ 同時,由于記憶體本身并沒對不同線程做鎖控制機制,可以支援多個線程同時通路。對于高并發的問題也能更好支援。由此,上述的實作方案就改為了下面的流程:

我悟了!Mysql事務隔離級别其實是這樣

事務隔離級别

​ 在修改為優先寫記憶體後續再異步同步的情況後,又帶來了新的問題:在一個事務尚未确認送出時,新事務從緩存中應該讀取什麼資料呢?

我悟了!Mysql事務隔離級别其實是這樣

​ 對于這種不同僚務間資料讀取的政策就被稱為事務隔離級别。根據讀取政策的不同,事務隔離級别被劃分為四種:讀未送出、讀已經送出、可重複讀、序列化。

讀未送出(Read Uncommitted)

​ 讀未送出的政策比較簡單,即預設讀取記憶體中的内容,而不必管這個資料是否已經寫入到了資料。但是這個政策會帶來一些問題:

我悟了!Mysql事務隔離級别其實是這樣

存在問題:

​ 如圖所示,若設定為讀未送出,那麼此時事務可能讀到尚未送出的資料,即髒讀。是以會造成資料A在前一時刻尚且可以讀取到,但想二次更新的時候,mysql資料庫卻因為復原導緻資料A被回退了。這種錯誤會導緻系統的無法正常運作,是不可容忍的。

讀已送出(Read Committed)

​ 既然讀未送出的事務帶來的錯誤是不可容忍的,那麼我隻讀已送出的資料就可以避免讀到髒資料了呀!那麼應如何實作隻讀已送出資料呢?對問題進行分析,要擷取到最新已送出的資料,必然要将資料的版本關系展現出來。為此,InnoDB設計了一個版本鍊的概念。對每行記錄會新增兩個隐藏列:trx_id、roll_pointer。

我悟了!Mysql事務隔離級别其實是這樣
  1. trx_id:用于儲存每次對該記錄進行修改的事務id。
  2. roll_pointer:存儲一個指針,指向這條資料記錄上一個版本的位址,可以通過它擷取到該記錄上一個版本的資料資訊。

由此一來,就可以通過最新記錄(可能未送出)進行回溯,直到找到已送出的記錄。

​ 當然,僅有版本鍊的概念明顯不夠,我們還無法判斷哪個資料是已送出的。為此InnoDB又新增了一個ReadView的解決方案,ReadView儲存了一個寫入了但未送出的事務ID清單。依據這個清單,我們就可以判斷哪些事務還未寫入。

我悟了!Mysql事務隔離級别其實是這樣

​ 以上圖為例,由于此時trx_id=20、trx_id=40的事務均未送出,InnoDB會生成一個ReadView:{20,40}。由此可能出現三種情況的事務通路:

  • 若預期通路事務ID=10的記錄,由于其小于最小的事務Id20,證明事務已送出,允許通路。
  • 若預期通路事務ID=30的記錄,由于其介于最大最小的事務ID之間,就需要逐一判斷ReadView中是否包含事務ID=30的記錄
  • 若預期通路事務ID=50的記錄,由于其大于ReadView最大的事務Id,必然是在生成ReadView後生成的,也必然沒有送出,不允許通路。

結合版本鍊和ReadView,基本就可以實作隻讀取已經送出的内容。

存在問題:

​ 由于ReadView是每次查詢才新生成的,是以不免存在以下情況:

我悟了!Mysql事務隔離級别其實是這樣

​ 在事務中首先讀了一次資料A,期間事務發生了送出,導緻二次查詢出來的資料A同第一次出現了差異。由此難免讓人發問:“兩次相同的條件,查詢到的結果卻不一緻,我是出現了幻覺了嘛?” 是以,這種情況也被形象稱做:幻讀。

​ 幻讀同髒讀不同,幻讀造成的問題是會破壞資料一緻性。假設我們有一張表 user(id, name, age),已經有兩條資料 (1, "Jack", 20), (2, "Tom", 18),同時我們執行以下流程:

我悟了!Mysql事務隔離級别其實是這樣

​ 三個事務執行完成後,主庫資料庫内的資料應該是:(1, "Jack", 10), (2, "Jack", 18),(3, "Jack", 18)。然而,此時binlog内的寫入的SQL語句卻是:

//事務二
update user set name = "Jack" where id = 2
update user set age = "40" where id = 2

//事務三
insert into user values(3, "Jack", 30) /*(3, Jack, 30)*/

//事務一
update user set name = "Tom" where name = "Jack"
複制代碼
           

​ 那麼此時,從庫收到了主庫同步的binLog資料,并按照順序執行。得到的結果卻是:(1, "Jack", 10), (2, "Jack", 10),(3, "Jack", 10)。不難發現,資料行2和3發生了主從不一緻,這個是無法容忍的。

可重複讀(Repeatable Read)

​ 要解決幻讀,主要是解決兩個問題:1、確定一次事務内看到的資料一緻;2、確定生成的binLog資料順序正确。

​ 對于問題1,其實相對比較簡單。同一次事務内看到的資料不一緻是由于每次ReadView都實時生成(也被稱為實時讀)。是以,隻要確定同一次事務内隻生成一次ReadView(也被稱為快照讀),就可以避免多次查詢會出現不一緻資料的情況。

​ 然而,僅保持自己看不到是不夠的,如果無法解決binLog的SQL寫入順序問題,資料不一緻的問題就無法得到解決。那其實對上述現象進行分析,導緻SQL寫入順序混亂的原因,其實是因為違背了事務一對于"where name = "Jack" 的原子性。即事務操作期間還有别的符合條件資料能被修改。

​ 那麼,很樸素的一個思想就是,隻要對這些都符合條件的資料都加鎖不就可以了嘛?為此,mysql提出了間隙鎖的概念。假設目前我們的資料對name字段配置了一個索引,那麼此時事務一運作的時候,我們需要将其索引臨近的一行及其間隙都鎖上,不允許其餘事務進行更新插入的操作。由此一來,索引被鎖上,沒法插入新的資料,也就不會出現SQL語句混亂的情況了。

我悟了!Mysql事務隔離級别其實是這樣

​ 那麼這個時候肯定有人會說:“你沒索引的字段咋辦啊?”,對于沒有索引的字段,mysql會做全表的掃描。由此一來,相當于會把整張表的資料都給鎖上。進而避免無索引的情況出現資料不一緻的問題。

序列化(Serializable)

​ 對于可序列化來說,實作就相對粗暴些。本着“爺才不考慮那麼多,直接将表鎖了,肯定不會有問題”的思想出發進行設計:

1、首先針對每次事務都操作的時候加表級共享鎖,確定多個事務可以讀。

2、事務操作的時候則加表級别的排它鎖,隻允許自己事務操作。

這些鎖都維持到事務結束再釋放,進而完美避免了上述問題的出現。然而,粗暴的方法一般性能都不太好,在高并發的情況下,常常隻有一個線程可以操作資料,是以不建議使用。

總結

​ 介紹了這麼多有關事務隔離的内容,我們終于可以回歸到我們的問題上來了。那麼其實對于開頭提到的問題,原因就是在異步線程中,會新開一個事務,這兩個事務是并行的。由于mysql預設的事務隔離級别是可重複讀,會導緻事務A異步的情況下,資料可能未送出,事務B執行較快而擷取到了舊資料,造成了同步資料錯誤的問題。

我悟了!Mysql事務隔離級别其實是這樣

​ 知道了問題,那麼解決方案就比較簡單了,可以不通過異步的方式發送,而是采用kafka消息的機制。這樣就給事務A留足了事務送出的時間,進而確定資料的準确同步。

繼續閱讀