MySQL中的事務原理和鎖機制

萌新J發表於2020-11-30

本文主要總結 MySQL 事務幾種隔離級別的實現和其中鎖的使用情況。

在開始前先簡單回顧事務幾種隔離級別以及帶來的問題。

四種隔離級別:讀未提交、讀已提交、可重複讀、可序列化。

帶來的問題:髒讀、不可重複讀、幻讀。分別是由讀未提交、讀已提交、可重複讀引起的。

髒讀:一個事務讀取到在另一個事務還未提交時的修改。

不可重複讀:一個事務在另一個事務提交前後讀取到了不同資料。(側重於某一條資料,這條資料內容發生了變化)。

幻讀:一個事務在另一個事務提交前後讀取到了不同資料。(側重於多了或是少了一條資料)。

在 Mysql 中,預設隔離級別是可重複讀,在預設時卻一定程度上解決了幻讀,為什麼這麼說呢?請看下面這個例子。

同時我們檢視資料庫中的資料:

 可以看到並沒有發生 “幻讀”,這是為什麼?難道可重複讀級別已經解決了“幻讀”?後面會詳細解釋。

 

Mysql 中的鎖

對於儲存引擎 MyISAM ,只支援表級鎖,對於 InnoDB 來說,既支援表級鎖、也支援行級鎖。所以 InnoDB 可以用於高併發的場景下而 MyISAM 不行。

按顆粒度劃分

1、行級鎖

只對一行資料加鎖,當一個事務操作某一行事務時,只對該行資料加排他鎖時,其他事務對其他行資料操作時不會影響,併發性好。缺點是在加多條資料時加鎖會比較耗時。

使用場景:可序列化隔離級別

2、表級鎖

對整張表進行加鎖。加鎖快但是可承受的併發量低。

3、頁級鎖

對一頁資料進行加鎖,介於行級鎖與表級鎖之間。

 

按種類劃分

1、共享鎖(讀鎖)

共享鎖是對於MySQL中的讀操作的,所以共享鎖也叫讀鎖,一個事務進行讀操作時,會對讀取的資料新增讀鎖(可序列化下的讀操作是自動加鎖的,其他隔離級別需要在查詢語句後面新增 lock in share mode),加鎖後其他事務也可以對加鎖的資料進行讀取。

 

2、排他鎖(寫鎖)

排它鎖是對於 MySQL 中的寫操作的,所以排它鎖也叫寫鎖。新增排它鎖的資料其他事務就不能進行操作,同時共享鎖與排它鎖也是互斥的,也就是一個事務對某資料新增了共享鎖,那麼其他事務就不能對其再新增排它鎖。在所有隔離級別級別中的修改操作(insert、update、delete)都會新增排他鎖,而讀操作可以通過在語句後面新增 for update 來對讀取的資料新增排它鎖

 

其他種類

1、Record Lock

記錄鎖。record lock 是加在具體記錄對應聚簇索引上的鎖,它是鎖住的是索引本身而不是記錄,如果該表沒有聚簇索引,也會建立一個聚簇索引來代替。換句話說 record lock 屬於行級鎖。它既可以是共享鎖也可以是排它鎖(究竟是共享鎖還是排他鎖上面已經分析了)。任何級別都會存在。

2、Gap Lock

間隙鎖,就是加在兩條資料索引之間的鎖,比如資料表student(id,name),id 是主鍵,有資料(5,"aa"),(7,"bb"),隔離級別是可序列化。此時事務1執行select * from student where id>5 and id<7,那麼就會對 (4,7) 新增間隙鎖,鎖住中間的間隙。比如說事務2執行insert into(6,"cc"),那麼次操作就會被阻塞。在可重複讀及以上級別才會有。

3、Next-Key Lock

指的是 Record Lock 與 Gap Lock 的結合。針對 Gap Lock 中的例子,如果事務1執行的是 select * from dept where id>4 and id<8,那麼對資料(5,"aa")、(7,"bb")對應的聚簇索引上也會新增 Record Lock。同時(4,5),(5,7),(7,8)也會加上間隙鎖。同 Gap Lock 一樣,只有可重複讀以以上級別才會出現。

 

 

四種隔離級別的實現

在說明原理前,先了解一下什麼是快照讀和當前讀。

快照讀Mysql 預設的隔離級別是“可重複讀”。通過文章開頭的例子可以看出左邊事務在右邊事務執行修改提交前後查詢的資料都一樣,左邊事務的查詢就是一個快照讀。快照讀的資料可以看作一個快照,其他事務的修改不會改變這個快照值。也就是說快照讀的資料不一定是最新值,可重複讀級別也因此才保證了 “可重複讀”。快照讀的優勢是不用加鎖,併發效率高。

使用場景:在 Mysql 的隔離級別中,除了可序列化級別的讀外,其他隔離級別中事務的讀都是快照讀。

 

當前讀當前讀指的就是讀的是最新值。既然是要求是最新值,那麼就需要進行加鎖限制,所以當前讀是需要加鎖的,同時因為當前讀一定是最新的資料,所以就無法保證 “可重複讀”。

使用場景:首先是可序列化中事務的讀操作是當前讀,而四種隔離級別中的所有修改(insert、update、delete)操作都屬於當前讀。可能你覺得讀操作和修改操作沒有關係,但是事實是這些修改操作是先 “讀” 找到資料具體的位置才能進行 “修改”。

 

讀已提交和可重複讀的實現

這兩種隔離級別的實現歸功於 MVCC 機制。

MVCC機制

MVCC機制也叫多版本併發控制,用於控制資料庫的併發訪問。在 Mysql 的 InnoDB 儲存引擎中主要作用於實現讀已提交和可重複讀隔離級別。實現原理是通過 undo日誌版本鏈和 Read View 。

1、undo日誌版本鏈。在 InnoDB 聚簇索引記錄的行資料中有兩個隱藏列,trx_id 和 roll_pointer,trx_id 表示當前行資料上次被修改的事務 id (事務 ID 是自增的,越新的事務 ID 越大),roll_pointer 是每次在修改完資料前,都會將修改前的資料存入undo log(專門用於記錄事務修改前資料的日誌系統,用於進行事務的回滾和生成資料快照),roll_pointer 就是當前行資料修改前在 undo 日誌中的儲存位置。

2、Read View。內部主要有四個部分組成,第一個是建立 Read View 的事務 id creator_trx_id,第二個是建立 Read View 時還未提交的事務 id 集合trx_ids,第三個是未提交事務 id 集合中的最大值up_limit_id,第四個是未提交事務 id 集合中的最小值low_limit_id。

當執行查詢操作時會先找磁碟上的資料,然後根據 Read View 裡的各個值進行判斷,

1)如果該資料的 trx_id 等於 creator_trx_id,那麼就說明這條資料是建立 Read View的事務修改的,那麼就直接返回;

2)如果大於 up_limit_id,說明是新事務修改的,那麼會根據 roll_pointer 找到上一個版本的資料重新比較;

3)如果小於 low_limit_id,那麼說明是之前的事務修改的資料,那麼就直接返回;

4)如果是在 low_limit_id 與 up_limit_id 中間,那麼需要去 trx_ids 中逐個查詢,如果存在,就根據 roll_pointer找打上一個版本的資料,然後再判斷;如果不存在就說明該資料是建立 Read View 時就已經修改好的了,可以返回。

 

而讀已提交和可重複讀之所以不同就是它們 Read View 生成機制不同,讀已提交是每次 select 都會重新生成一次,而可重複讀是一次事務只會在第一次查詢時生成一個 Read View。

舉個借鑑於網上的例子,比如事務1先修改 name 為小明1,假設此時事務id 是60,那麼就會在修改前將之前的50寫入 undo log,同時在修改時將生成的undo log 行資料地址寫入 roll_pointer,然後暫不提交事務1。開一個事務2,事務 id 為 55,進行查詢操作,此時生成的 Read View 的trx_ids是[60],creator_trx_id 為 55,對應的資料狀態就是下圖,首先先得到磁碟資料的 trx_id ,為60,然後判斷,不等於 creator_trx_id,然後檢查,最大值和最小值都是 60,所以通過 roll_pointer 從 undo log 中找到 “小明” 那條資料,再次判斷,發現 50 是小於 60的,所以滿足,返回資料。

然後提交事務1,再開一個事務3,將name改成小明2,假設此時的事務 id 是100,那麼在修改前又會將 trx_id 為 60 拷貝進 undo log,同時修改時將 trx_id 改為100,然後事務3暫不提交,此時事務1再進行select。如果隔離級別是讀已提交,那麼就會重新生成 Read View,trx_ids是[100],creator_trx_id 為55,判斷過程和上面相似,最終返回的是小明1那條資料;而如果是可重複讀,那麼還是一開始的 Read View,trx_ids 還是[60],creator_trx_id 還是 55,那麼還是從小明2 的 trx_id 進行判斷,發現不等於 55,且大於60,跳到 小明1 ,對 trx_id判斷,還是大於,最終還是返回 “小明” 那條資料。下面是這個例子最終的示意圖

 

 

讀未提交和可序列化實現

這兩個實現比較簡單。讀未提交就是每次事務執行的修改都更新到對應的資料上,然後讀取直接讀取這個資料就可以了。而可序列化則是使用了讀鎖和寫鎖以及間隙鎖來實現的,對會造成“幻讀”、“髒讀”、“不可重複讀” 的操作會進行阻塞,也正因為這樣,極易任意造成阻塞,所以不建議使用可序列化級別。

 

 

不同隔離級別下加鎖情況

對於不同的隔離級別,不同的列情況,加鎖情況都各不不同,下面會列舉各個場景下加鎖的情況。

1、讀未提交級別

讀操作不會加鎖,寫操作會新增排它鎖。因為會發生髒讀,所以 MVCC並不會發生效果。可以手動新增 for update 、lock in share mode 來加鎖,不會產生間隙鎖,只有記錄鎖。

無論是否使用索引,是否是手動新增鎖,只會對最終操作的資料加 Record Lock。

 

2、讀已提交級別

讀操作不會加鎖,寫操作會新增排它鎖。MVCC 會在每次查詢時生成 Read View,可以手動新增 for update 、lock in share mode 來加鎖,不會產生間隙鎖,只有記錄鎖。

無論是否使用索引,是否是手動新增鎖,只會對最終操作的資料加 Record Lock。(在未使用到索引時資料庫會對所有資料加鎖,當載入到 Server 層篩選後會將不符合條件的資料進行解鎖,所以我們會認為只對最終操作的資料加鎖,讀未提交級別的未使用索引情況也相同)

 

3、可重複讀級別

可重複讀是一個特殊的隔離級別,為什麼這麼說呢?因為它是 mysql 預設的隔離級別,因為 "可序列化" 級別預設對讀操作加鎖,導致程式的併發性不高,所以不建議使用,而可重複讀因為使用的是快照讀,所以併發性很好,並且解決了不可重複讀、髒讀以及 "快照讀" 幻讀,但同時會有 "當前讀"幻讀的問題產生(下面"MySQL 對幻讀的解決" 會詳細解釋),所以針對這個問題引入了間隙鎖來解決。

讀操作不會加鎖,寫操作會新增排它鎖。MVCC 會在事務開始第一次查詢時生成 Read View,可以手動新增 for update、lock in share mode 來加鎖,可能會產生間隙鎖。

無論是否是手動新增鎖,1)在使用到唯一索引和主鍵索引時,會對對應記錄對應的聚簇索引上新增 Record Lock。

2)在使用非唯一索引時,會對對應資料左右的間隙額外新增間隙鎖,也就是使用 Next-key Lock。以下圖為例

 name 為主鍵,id 為普通索引。當執行 delete from t1 where id = 10 時,由於新增的資料可能在 [ (6,c),(10,b) ] 之間,[ (10,b),(10,d) ] 之間,[ (10,d),(11,f) ] 之間,所以需要對這三個間隙加鎖,來防止在事務1操作時其他事務對這三個位置進行其他修改操作導致操作出錯。比如現在 insert (10,a),那麼就會判定是 [ (6,c),(10,b) ] 之間的,此操作就會被阻塞。

3)如果沒有用到索引,那麼會對所有資料以及他們兩邊的間隙進行加 Next-key Lock鎖。相當於整張表進行加鎖。這也對應著 “索引失效時行級鎖會退化成表級鎖” 的規律。

 

4、可序列化級別

讀操作會加讀鎖,寫操作會加寫鎖,讀寫鎖互斥。也會有間隙鎖。

1)用到主鍵索引和唯一索引,會對運算元據新增 Record Lock。

2)普通索引,會對運算元據以及間隙新增 Next-key Lock。

3)未使用索引,會對所有資料以及兩邊間隙新增 Next-key Lock。

 

 

MySQL 對幻讀的解決

“快照讀” 幻讀

通過上面對 MVCC 原理的解釋,可以知道文章開頭的例子為什麼“解決了” 幻讀。如果假設左邊的事務1 id 是50,右邊事務2 id 是55,其他資料建立時的事務是10,那麼在事務1第一次查詢時生成的 Read View 的 trx_ids 為[55],對應的資料如下

 那麼在判斷其他資料時 trx_id 的10小於 trx_ids 的最小值55,所以通過,而 id 為6的資料發現 trx_id 正好等於 55,所以獲取 roll_point 從 undo log中找到之前的資料快照,但是發現該列值為空,所以放棄跳到下一條資料。沒有出現文章開頭所說的 “幻讀” 情況,開頭所說的讀就叫做 “快照讀” 幻讀。 由此我們可以知道, MVCC 可以解決 “快照度” 幻讀。

這裡可以再說一下題外話,其實對於 MVCC 中可重複讀級別 Read View 建立時機為什麼是第一次查詢時生成而不是事務啟動時就生成,可以通過下面的測試來證明。

 可以在事務2提交後再查詢就會查出提交後的資料。

 

“當前讀” 幻讀

這樣看來 MVCC 已經解決了幻讀問題,而在一開始也說過在預設時在一定程度上解決了幻讀,為什麼這麼說?請看下面這個例子

 如果單看左邊的事務,會發現明明表中沒有id為6的記錄,但是就是無法執行 insert 操作,顯示主鍵已存在。這就是 “當前讀”幻讀,而 MVCC只能解決 “快照讀” 幻讀。由於前面對 “當前讀”、“快照讀” 的解釋可以知道這兩種讀是互斥的,那麼如何解決 “當前讀” 幻讀。第一種方式是直接切換成 “可序列化” 級別,這種因為預設對資料加鎖,不利於專案的併發執行,所以不建議;第二種就是手動新增鎖,在特定的操作後新增 for update 或 lock in share mode。這樣就可以實現 "當前讀"了。

 

 

死鎖

MySQL 的死鎖與多執行緒中的死鎖本質上一樣,其核心思想就是 “兩個及以上的事務互相獲取對方事務新增的鎖記錄(排它鎖)”,

 

 

 

部落格主要靈感來源於

https://blog.csdn.net/cug_jiang126com/article/details/50596729

https://www.cnblogs.com/crazylqy/p/7611069.html,其中一些圖片和加鎖情況來源於第二個

相關文章