來啦!跟你聊透事務、隔離級別、悲觀鎖和樂觀鎖

大家好,我是濤哥。

早晨空氣不錯,時間過得挺快的,馬上就是三月底了,人間四月天就要到了,唯有踏實地進步纔可讓人心安。

今天,我來聊數據庫事務 ACID、隔離級別、悲觀鎖和樂觀鎖。無論是在工作中,還是在筆試面試中,數據庫相關的問題,總是繞不開,不會的話,很容易歇菜,你懂的。

數據庫事務場景

在銀行系統中,數據庫事務是必須的。在電商系統中,也是如此。

來看下 A 給 B 匯款 100 元的例子,可以看到,A 賬戶扣款 100 元,此時如果進程崩潰或者機器掉電,那麼這 100 元就沒有加到 B 的賬戶中,自然會導致用戶的強烈投訴:

如果先給 B 賬戶加錢,然後給 A 賬戶扣錢,會怎樣呢?可以看到,此時如果進程崩潰或者機器掉電,銀行白白給 B 加了 100 元,而沒有扣減 A 的 100 元,只怕銀行會虧得沒褲子穿:

墨菲定律說:凡是會出錯的事,一定會出錯。 而且,一旦發生,將造成較大危害。所以,在軟件設計上,有必要考慮這種異常。進程崩潰,機房掉電,網絡抖動,硬件損壞,都應該被視爲常態,都應該被考慮到。

如果要在應用層處理這些異常問題,將極爲困難,甚至幾乎不可能。做過軟件開發的朋友應該知道,很多時候,如果異常問題處理得不妥當,將要投入大量時間分析和補救,且不一定能補救回來。

所以,有必要引入數據庫事務。所謂事務,就是一組 SQL 操作,它們不可分割,不能被打斷,要麼都成功,要麼都失敗。具體地說,就是要滿足 ACID 性質。

引入事務之後,應用層再也不用擔心上述異常了,因爲數據庫已經爲我們處理得很好了。很多書籍把 ACID 放在一起敘述,我認爲有點扯,因爲他們並不正交。在我看來,C 是 AID 的最終目的。下面,我們來看下 ACID 性質。

Atomicity(原子性)

古希臘哲學家德謨克利特認爲,原子是構成世界萬物的單元,且不可分割:

所以,原子性這個詞的含義就是不可分割。以上述的步驟一和步驟二爲例,它們是一個整體,不可分割,要麼同時成功,要麼同時失敗。

那麼具體怎樣去實現原子性呢?有興趣的朋友可以瞭解下 undo log, 在此不展開敘述。我們不是 DBA, 不需要精通數據庫的衆多具體細節,但是,至少要知道大概的原理和可行性,這可以爲我們解決類似問題提供思路和參考。

Consistency(一致性)

一致性是我們最終的目的,籠統地說,一致性就是要確保數據是正確無誤的。所謂 valid data, 其實就是正確無誤的 data:

原子性沒法完全保證一致性,因爲在多個事務操作數據庫時,還需要涉及到隔離性。

Isolation(隔離性)

隔離性,就是要隔離不同事務,隔離性是本文的重點,我們會針對不同的隔離級別進行介紹,先來看一眼:

需要強調的是,每種存儲引擎的實現不盡一致,在可重複讀隔離級別下,有的朋友在進行驗證時,並未出現所謂的幻讀,這是因爲:

關於 InnoDB 是否存在幻讀問題,我們將在本文的實驗部分進行驗證。

Durability(持久性)

持久性的意思是,一旦事務提交,它對數據庫的變更是永久性的。實際上,事務提交後,最後不一定會落地到數據庫中 (比如落地時機器斷電了),那怎麼保證一定要落地成功呢?

這就涉及到 redo log 了,我們也不需要具體知道 redo log 的細節,但是,我們從邏輯上可以縷清:redo log 要記錄什麼?redo log 爲什麼能保證持久性?

很多時候,就是這樣,對於不太相關的東西,可以不精通,但至少要了解大概邏輯和思路。這樣才能說服自己,纔不會有一種玄乎其玄的感覺。

接下來,我們看這個問題:客戶端 A 的事務,是否應該看到客戶端 B 的事務所作的修改?這就涉及到數據庫事務的隔離級別。

在本文中,如下圖示都是基於我的實際驗證。建議有興趣的朋友一起動手,感受一下。

說明:事務 A 和事務 B 位於兩個不同的終端窗口,對應兩個不同的進程,在改變隔離級別時,僅改 A 的隔離級別來進行驗證。

  1. 讀未提交

我們來看看讀未提交的場景:

可見,設置讀未提交後,事務 B 在未提交時,事務 A 讀出了 a=10,  這是髒數據 (B 事務被回滾了),這就是所謂的 “髒讀”。

  1. 讀已提交

我們來看看讀已提交的場景:

可見,設置讀已提交後,事務 B 在未提交時,事務 A 讀出了 a=0, 在事務 B 提交後,又讀出了 a=10,  出現了 “不可重複讀”。

  1. 可重複讀

我們來看看可重複讀的場景:

可以看到,看事務 A 內,讀取的值具有前後不變的特點,這就是 “可重複讀”。只有當事務 A 提交後,才能讀出 a=10. 在 MySql 中,默認的隔離級別就是可重複讀。

接下來,我們看一個魔幻現象:

在 B 事務提交後,A 事務執行 select ... where a = 100 時,發現還是無記錄,可見此時並未產生 “幻讀”。但是,如果用 select for update, 則出現了 “幻讀” 現象。

可見,在 InnoDB 可重複讀的隔離級別中,並未完全解決 “幻讀” 問題,而是解決了讀數據情況下的 “幻讀” 問題,而對於修改的操作依然存在 “幻讀” 問題。

  1. 串行化

我們來看看串行化的場景:

可以看到,即使對於讀操作,也會加鎖,一個事務要等待另一個事務完成。串行化是完全的隔離級別,會導致大量超時和鎖競爭問題,在高併發場景中,較少用到串行化。在 SQLite 中,默認的隔離級別就是串行化。

丟失更新問題

有了這些隔離級別,就萬事大吉了嗎? 當然不是。以 MySql 爲例,在默認隔離級別下,會有丟失更新的問題。

領導 A 給你加了 30 元的雞腿,領導 B 給你加了 40 元的雞腿,最終結果發現,只有 40 元雞腿,顯然,這是不合理的:

怎麼解決這種問題呢?可以考慮引入悲觀鎖或樂觀鎖。

悲觀鎖

所謂悲觀鎖,就是持悲觀態度,認爲一定會有衝突,所以提前加強保護。悲觀鎖可以用 select for update 來實現,之前項目中就經常這樣玩,但後來重構了代碼,統一優化成了分佈式鎖。

使用分佈式鎖, 代碼示意如下 (如下使用方法有問題):

func proc() {
money := queryMoneyFromDb()
  begin lock
     begin transaction
         money += req.Money
         setToDb(money)
     end transaction
  end lock
}

上述代碼的使用是有問題的,想一下爲什麼?

當兩個進程都讀取 money=0 後,進程 A 獲取鎖,並且執行完畢後,money=30,然後進程 B 獲取鎖,執行完畢後,顯然可知,最後的結果是 money=40,仍然存在丟失更新的問題。

曾經在項目中,就出現過這種錯誤,導致了低概率的金額不匹配,比較難發現問題,最後還是通過對賬發現了,然後查出上述錯誤的用法。

正確使用悲觀鎖代碼示意如下:

func proc() {
    begin lock
      begin transaction
        money := queryMoneyFromDb()
        money += req.Money
        setToDb(money)
      end transaction
    end lock
}

樂觀鎖

所謂樂觀鎖,就是抱有很樂觀的態度,也就是假定不會存在數據衝突 (即使有衝突也不怕,樂觀得很)。具體實現時,可以在數據上打一個 version 標記,基於 version 進行控制,代碼示意如下:

func proc() {
   begin transaction
      select * from T where user_id = 123456  // 假設查到的version爲100
      update T set money = xxx, version = version + 1 where user_id = 123456 and version = 100;
   end transaction
}

分析一下:進程 A 和進程 B 都讀到了 version=100 的數據,進程 A 在加完 30 元后,同時讓 version 變成了 101;此時進程 B 去執行,突然發現不滿足 where version=100 這個條件,所以更新失敗,這是合理的,符合預期,寧可執行失敗,也不能產生數據錯誤。

這裏有一個極爲微妙的問題:在 MySql 可重複讀隔離級別下,當進程 A 的 update 執行成功並且提交事務後,version 變爲了 101, 但是在進程 B 看來,version 還是 100(可重複讀),  爲什麼 B 在執行 update 的時候,在 where version=100 條件下又無法真正執行 update 呢?

要注意,可重複讀是針對 select 而言的,而不是 select for update 或者 update 之類的操作,當 A 進程事務提交後,B 進程事務看到的情況如下:

mysql> select * from user;
+----+-------+---------+
| id | money | version |
+----+-------+---------+
|  1 |     0 |     100 |
+----+-------+---------+
1 row in set (0.00 sec)
mysql> select * from user for update;
+----+-------+---------+
| id | money | version |
+----+-------+---------+
|  1 |    30 |     101 |
+----+-------+---------+
1 row in set (0.25 sec)
mysql> select * from user;
+----+-------+---------+
| id | money | version |
+----+-------+---------+
|  1 |     0 |     100 |
+----+-------+---------+
1 row in set (0.00 sec)

可見,對 B 事務而言,用 select 看,看不到 B 事務的更新,這滿足事務的可重複讀。但是,當使用 select for update 時,能看到 B 事務的更新。

所以,當 B 事務使用 update 嘗試更新 where  version=100 的記錄時,發現更新失敗,這是我們期望的結果,寧可執行失敗,也不能產生數據錯誤。針對這種失敗,可以採用多次重試。

至於悲觀鎖和樂觀鎖的選擇,還是要依賴於具體業務。數據的一致性如此重要,可千萬別把用戶的錢給算錯了。

對於頻繁寫衝突的業務,用樂觀鎖肯定是不太好的,重試操作會增加各種開銷,此時可以考慮使用悲觀鎖。對於寫衝突較少發生的場景,那樂觀鎖就非常適合了。

你好,我是濤哥,CSDN 排名第一

自學計算機,畢業後就職華爲騰訊

從事軟件開發,期待與你一起成長

濤歌依舊 濤哥 CSDN 排名第一,曾就職於華爲和鵝廠。公衆號內容:編程之路、面試刷題、職場進階、雜文薈萃。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/9aeI1_PG_eBxjz-NAcFLiw