天天看點

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析

作者:搬山道猿

一、MySQL中的死鎖現象

所謂的并發事務,本質上就是MySQL内部多條工作線程并行執行的情況,也正由于MySQL是多線程應用,是以需要具備完善的鎖機制來避免線程不安全問題的問題産生,但熟悉多線程程式設計的小夥伴應該都清楚一點,對于多線程與鎖而言,存在一個100%會出現的偶發問題,即死鎖問題。

1.1、死鎖問題概述(Dead Lock)

一句話來概述死鎖:死鎖是指兩個或兩個以上的線程(或程序)在運作過程中,因為資源競争而造成互相等待、互相僵持的現象,一般當程式中出現死鎖問題後,若無外力介入,則不會解除“僵持”狀态,它們之間會一直互相等待下去,直到天荒地老、海枯石爛~

當然,為了照顧一些不想看并發程式設計文章的小夥伴,這裡也把之前的死鎖栗子搬過來~

某一天竹子和熊貓在森林裡撿到一把玩具弓箭,竹子和熊貓都想玩,原本說好一人玩一次的來,但是後面竹子耍賴,想再玩一次,是以就把弓一直拿在自己手上,而本應該輪到熊貓玩的,是以熊貓跑去撿起了竹子前面剛剛射出去的箭,然後跑回來之後便發生了如下狀況:

熊貓道:竹子,快把你手裡的弓給我,該輪到我玩了....

竹子說:不,你先把你手裡的箭給我,我再玩一次就給你....

最終導緻熊貓等着竹子的弓,竹子等着熊貓的箭,雙方都不肯退步,結果陷入僵局場面.....

比如上述這個栗子中,「竹子、熊貓」可以了解成兩條線程,而「弓、箭」則可以了解成運作時所需的資源,由于雙方各自占據對方所需的資源,是以就造就了死鎖現象發生,此時想要解決這個問題,就必須第三者外力介入,把“違反約定”的竹子手中的弓拿過去給熊貓......,然後等熊貓玩了之後,再給竹子,恢複之前原有的“執行順序”。

1.2、MySQL中的死鎖現象

而MySQL與Redis、Nginx這類單線程工作的程式不同,它屬于一種内部采用多線程工作的應用,因而不可避免的就會産生死鎖問題,比如舉個例子:

SELECT * FROM `zz_account`;
+-----------+---------+
| user_name | balance |
+-----------+---------+
|    熊貓   | 6666666 |
|    竹子   | 8888888 |
+-----------+---------+

-- T1事務:竹子向熊貓轉賬
UPDATE `zz_account` SET balance = balance - 888 WHERE user_name = "竹子";
UPDATE `zz_account` SET balance = balance + 888 WHERE user_name = "熊貓";

-- T2事務:熊貓向竹子轉賬
UPDATE `zz_account` SET balance = balance - 666 WHERE user_name = "熊貓";
UPDATE `zz_account` SET balance = balance + 666 WHERE user_name = "竹子";
複制代碼           

上面有一張很簡單的賬戶表,因為隻是為了示範效果,是以其中僅設計了使用者名和餘額兩個字段,緊接着有T1、T2兩個事務,T1中竹子向熊貓轉賬,而T2中則是熊貓向竹子轉賬,也就是一個互相轉賬的過程,此時來分析一下:

  • ①T1事務會先扣減竹子的賬戶餘額,是以會修改資料,此時會預設加上排他鎖。
  • ②T2事務也會先扣減熊貓的賬戶餘額,是以同樣會對熊貓這條資料加上排他鎖。
  • ③T1減完了竹子的餘額後,準備擷取鎖把熊貓的餘額加888,但由于此時熊貓的鎖被T2事務持有,T1會陷入阻塞等待。
  • ④T2減完熊貓的餘額後,也準備擷取鎖把竹子的餘額加666,但此時竹子的鎖被T1持有。

此時就會出現問題,T1等待T2釋放鎖、T2等待T1釋放鎖,雙方各自等待對方釋放鎖,一直如此僵持下去,最終就引發了死鎖問題,那先來看看具體的SQL執行情況是什麼樣的呢?如下:

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析

如上圖所示,一步步的跟着标出的序号去看,最終會發現:當死鎖問題出現時,MySQL會自動檢測并介入,強制復原結束一個“死鎖的參與者(事務)”,進而打破死鎖的僵局,讓另一個事務能繼續執行。

看到這裡有小夥伴會問了,為啥MySQL能自動檢測死鎖呀?其實這跟死鎖檢測機制有關,後續再細說。

但是要牢記一點,如果你也想自己做上述實驗,那麼千萬不要忘了在建立了表後,基于user_name建立一個主鍵索引:

ALTER TABLE `zz_account` ADD PRIMARY KEY p_index(user_name);
複制代碼           

如果你不為user_name字段加上主鍵索引,那是無法模拟出死鎖問題的,這是為什麼呢?還記得之前在《MySQL鎖機制-記錄鎖》中聊到的一點嘛?在InnoDB中,如果一條SQL語句能命中索引執行,那就會加行鎖,但如果無法命中索引加的就是表鎖。

在上述給出的案例中,因為表中沒有顯示指定主鍵,同時也不存在一個唯一非空的索引,是以InnoDB會隐式定義一個row_id來維護聚簇索引的結構,但因為update語句中無法使用這個隐藏列,是以是走全表方式執行,是以就将整個表資料鎖起來了。

而這裡的四條update語句都是基于zz_account賬戶表在操作,是以兩個事務競争的是同一個鎖資源,是以自然無法複現死鎖現象,也就是T1修改時,T2的第一條SQL也不能執行,會阻塞等待表鎖的釋放。

而當咱們顯示的定義了主鍵索引後,InnoDB會基于該主鍵字段去建構聚簇索引,是以後續的update語句可以命中索引,執行時自然擷取的也是行級别的排他鎖。

1.3、MySQL中死鎖如何解決呢?

在之前關于死鎖的并發文章中聊到過,對于解決死鎖問題可以從多個次元出發,比如預防死鎖、避免死鎖、解除死鎖等,而當死鎖問題出現後該如何解決呢?一般隻有兩種方案:

  • 鎖逾時機制:事務/線程在等待鎖時,超出一定時間後自動放棄等待并傳回。
  • 外力介入打破僵局:第三者介入,将死鎖情況中的某個事務/線程強制結束,讓其他事務繼續執行。

1.3.1、MySQL的鎖逾時機制

在InnoDB中其實提供了鎖的逾時機制,也就是一個事務在長時間内無法擷取到鎖時,就會主動放棄等待,抛出相關的錯誤碼及資訊,然後傳回給用戶端。但這裡的時間限制到底是多久呢?可以通過如下指令查詢:

show variables like 'innodb_lock_wait_timeout';
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_lock_wait_timeout | 50    |
+--------------------------+-------+
複制代碼           

預設的鎖逾時時間是50s,也就是在50s内未擷取到鎖的事務,會自動結束并傳回。那也就意味着當死鎖情況出現時,這個死鎖過程最多持續50s,然後其中就會有一個事務主動退出競争,釋放持有的鎖資源,這似乎聽起來蠻不錯呀,但實際業務中,僅依賴逾時機制去解除死鎖是不夠的,畢竟高并發情況下,50s時間太長了,會導緻越來越多的事務阻塞。

那麼咱們能不能把這個參數調小一點呢?比如調到1s,可以嗎?當然可以,确實也能確定死鎖發生後,在很短的時間内可以自動解除,但改掉了這個參數之後,也會影響正常事務等待鎖的時間,也就是大部分未發生死鎖,但需要等待鎖資源的事務,在等待1s之後,就會立馬報錯并傳回,這顯然并不合理,畢竟容易誤傷“友軍”。

也正是由于依靠鎖逾時機制,略微有些不靠譜,是以InnoDB也專門針對于死鎖問題,研發了一種檢測算法,名為wait-for graph算法。

1.3.2、死鎖檢測算法 - wait-for graph

這種算法是專門用于檢測死鎖問題的,在該算法中會對于目前庫中所有活躍的事務生成等待圖,啥意思呢?以上述的死鎖案例來看,在MySQL内部會生成一張這樣的等待圖:

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析

也就是T1持有着「竹子」這條資料的鎖,正在等待擷取「熊貓」這條資料的鎖,而T2事務持有「熊貓」這條資料的鎖,正在等待擷取「竹子」這條資料的鎖,最終T1、T2兩個事務之間就出現了等待閉環,是以當MySQL發現了這種等待閉環時,就會強制介入,復原結束其中一個事務,強制打破該閉環,進而解除死鎖問題。

但這個“等待圖”隻是為了友善了解畫出來的,内部的實作其實存在些許差異,一起來聊一聊。

wait-for graph算法被啟用後,會要求MySQL收集兩個資訊:

  • 鎖的資訊連結清單:目前持有每個鎖的事務是誰。
  • 事務等待連結清單:阻塞的事務要等待的鎖是誰。

每當一個事務需要阻塞等待某個鎖時,就會觸發一次wait-for graph算法,該算法會以目前事務作為起點,然後從「鎖的資訊連結清單」中找到對應中鎖資訊,再去根據鎖的持有者(事務),在「事務等待連結清單」中進行查找,看看持有鎖的事務是否在等待擷取其他鎖,如果是,則再去看看另一個持有鎖的事務,是否在等待其他鎖.....,經過一系列的判斷後,再看看是否會出現閉環,出現的話則介入破壞。

上面這個算法的過程,聽起來似乎有些暈乎乎的,但實際上并不難,套個例子來了解,好比目前庫中有T1、T2、T3三個事務、有X1、X2、X3三個鎖,事務與鎖的關系如下:

此時當T3事務需要阻塞等待擷取X1鎖時,就會觸發一次wait-for graph算法,流程如下:

  • ①先根據T3要擷取的X1鎖,在「鎖的資訊連結清單」中找到X1鎖的持有者T1。
  • ②再在「事務等待連結清單」中查找,看看T1是否在等待擷取其他鎖,此時會得知T1等待X2。
  • ③再去「鎖的資訊連結清單」中找到X2鎖的持有者T2,再看看T2是否在阻塞等待擷取其他鎖。
  • ④再在「事務等待連結清單」中查找T2,發現T2正在等待擷取X3鎖,再找X3鎖的持有者。

經過上述一系列算法過程後,最終會發現X3鎖的持有者為T3,而本次算法又正是T3事務觸發的,此時又回到了T3事務,也就代表着産生了“閉環”,是以也可以證明這裡出現了死鎖現象,是以MySQL會強制復原其中的一個事務,來抵達解除死鎖的目的。

但出現死鎖問題時,MySQL會選擇哪個事務復原呢?之前分析過,當一個事務在執行SQL更改資料時,都會記錄在Undo-log日志中,Undo量越小的事務,代表它對資料的更改越少,同時復原的代價最低,是以會選擇Undo量最小的事務復原(如若兩個事務的Undo量相同,會選擇復原觸發死鎖的事務)。

同時,可以通過innodb_deadlock_detect=on|off這個參數,來控制是否開啟死鎖檢測機制。

死鎖檢測機制在MySQL後續的高版本中是預設開啟的,但實際上死鎖檢測的開銷不小,上面三個并發事務阻塞時,會對「事務等待連結清單、鎖的資訊連結清單」共計檢索六次,那當阻塞的并發事務越來越多時,檢測的效率也會呈線性增長。

1.3.3、如何避免死鎖産生?

因為死鎖的檢測過程較為耗時,是以盡量不要等死鎖出現後再去解除,而是盡量調整業務避免死鎖的産生,一般來說可以從如下方面考慮:

  • 合理的設計索引結構,使業務SQL在執行時能通過索引定位到具體的幾行資料,減小鎖的粒度。
  • 業務允許的情況下,也可以将隔離級别調低,因為級别越低,鎖的限制會越小。
  • 調整業務SQL的邏輯順序,較大、耗時較長的事務盡量放在特定時間去執行(如淩晨對賬...)。
  • 盡可能的拆分業務的粒度,一個業務組成的大事務,盡量拆成多個小事務,縮短一個事務持有鎖的時間。
  • 如果沒有強制性要求,就盡量不要手動在事務中擷取排他鎖,否則會造成一些不必要的鎖出現,增大産生死鎖的幾率。
  • ........

其實簡單來說,也就是在業務允許的情況下,盡量縮短一個事務持有鎖的時間、減小鎖的粒度以及鎖的數量。

同時也要記住:當MySQL運作過程中産生了死鎖問題,那這個死鎖問題以後絕對會再次出現,當死鎖被MySQL自己解除後,一定要記住去排除業務SQL的執行邏輯,找到産生死鎖的業務,然後調整業務SQL的執行順序,這樣才能從根源上避免死鎖産生。

二、鎖機制的底層實作原理

對于MySQL的鎖機制究竟是如何實作的呢?對于這點其實很少有資料去講到,一般都是停留在鎖機制的表層闡述,比如鎖粒度、鎖類型的劃分,但既然咱們講了鎖機制,那也就順便聊一下它的底層實作。

2.1、鎖的記憶體結構

在Java中,Synchronized鎖是基于Monitor實作的,而ReetrantLock又是基于AQS實作的,那MySQL的鎖是基于啥實作的呢?想要搞清楚這點,得先弄明白鎖的記憶體結構,先看圖:

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析

InnoDB引擎中,每個鎖對象在記憶體中的結構如上,其中記錄的資訊也比較多,先全部理清楚後再聊聊鎖的實作。

2.1.1、鎖的事務資訊

其中記錄着目前的鎖結構是由哪個事務生成的,記錄的是指針,指向一個具體的事務。

2.1.2、索引的資訊

這個是行鎖的特有資訊,對于行鎖來說,需要記錄一下加鎖的行資料屬于哪個索引、哪個節點,記錄的也是指針。

2.1.3、鎖粒度資訊

這個略微有些複雜,對于不同粒度的鎖,其中存儲的資訊也并不同,如果是表鎖,其中就記錄了一下是對哪張表加的鎖,以及表的一些其他資訊。

但如果鎖粒度是行鎖,其中記錄的資訊更多,有三個較為重要的:

  • Space ID:加鎖的行資料,所在的表空間ID。
  • Page Number:加鎖的行資料,所在的頁号。
  • n_bits:使用的比特位,對于一頁資料中,加了多少個鎖(後續結合講)。

2.1.4、鎖類型資訊

對于鎖結構的類型,在内部實作了複用,采用一個32bit的type_mode來表示,這個32bit的值可以拆為lock_mode、lock_type、rec_lock_type三部分,如下:

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析
  • lock_mode:表示鎖的模式,使用低四位。 0000/0:表示目前鎖結構是共享意向鎖,即IS鎖。 0001/1:表示目前鎖結構是排他意向鎖,即IX鎖。 0010/2:表示目前鎖結構是共享鎖,即S鎖。 0011/3:表示目前鎖結構是排他鎖,即X鎖。 0100/4:表示目前鎖結構是自增鎖,即AUTO-INC鎖。
  • lock_type:表示鎖的類型,使用低位中的5~8位。 LOCK_TABLE:當第5個比特位是1時,表示目前是表級鎖。 LOCK_REC:當第6個比特位是1時,表示目前是行級鎖。
  • rec_lock_type:表示行鎖的具體類型,使用其餘位。 LOCK_ORDINARY:當高23位全零時,表示目前是臨鍵鎖。 LOCK_GAP:當第10位是1時,表示目前是間隙鎖。 LOCK_REC_NOT_GAP:當第11位是1時,表示目前是記錄鎖。 LOCK_INSERT_INTENTION:當第12位是1時,表示目前是插入意向鎖。 .....:内部還有一些其他的鎖類型,會使用其他位。
  • is_waiting:表示目前鎖處于等待狀态還是持有狀态,使用低位中的第9位。 0:表示is_waiting=false,即目前鎖無需阻塞等待,是持有狀态。 1:表示is_waiting=true,即目前鎖需要阻塞,是等待狀态。

OK~,上面分析了這一堆之後,看起來難免有些暈乎乎的,上個例子來了解一下:

00000000000000000000000100100011

比如上面給出的這組bit,鎖粒度、鎖類型、鎖狀态是什麼情況呢?如下:

全解MySQL之死鎖問題分析、事務隔離與鎖機制的底層原理剖析

從上圖中可得知,目前這組bit代表一個阻塞等待的行級排他臨鍵鎖結構。

2.1.5、其他資訊

這個所謂的其他資訊,也就是指一些用于輔助鎖機制的資訊,比如之前死鎖檢測機制中的「事務等待連結清單、鎖的資訊連結清單」,每一個事務和鎖的持有、等待關系,都會在這裡存儲,将所有的事務、鎖連接配接起來,就形成了上述的兩個連結清單。

2.1.6、鎖的比特位

與其說是鎖的比特位,不如說是資料的比特位,好比舉個例子:

SELECT * FROM `zz_student`;
+------------+--------+------+--------+
| student_id | name   | sex  | height |
+------------+--------+------+--------+
|          1 | 竹子   | 男   | 185cm  |
|          2 | 熊貓   | 女   | 170cm  |
|          3 | 子竹   | 男   | 182cm  |
|          4 | 棕熊   | 男   | 187cm  |
|          5 | 黑豹   | 男   | 177cm  |
|          6 | 腦斧   | 男   | 178cm  |
|          7 | 兔紙   | 女   | 165cm  |
+------------+--------+------+--------+
複制代碼           

學生表中有七條資料,此時就會形成一個比特數組:000000000,等等,似乎不對!明明隻有七條資料,為啥會有9個比特位呢?因為行鎖中,間隙鎖可以鎖定無窮小、無窮大這兩個間隙,是以這組比特中,首位和末位即表示無窮小、無窮大兩個間隙。

好比此時T1事務,對ID=2、3、6這三條資料加鎖了,此時這個比特數組就會變為001100100,表示T1事務同時鎖定了三條資料。而之前聊到的n_bits,它就會記錄一下在這組比特中,多少條記錄被上鎖了。

2.2、InnoDB的鎖實作

上面已經分析了MySQL的鎖對象結構,接着來設想一個問題:

如果一個事務同時需要對表中的1000條資料加鎖,會生成1000個鎖結構嗎?

如果這裡是SQL Server資料庫,那絕對會生成1000個鎖結構,因為它的行鎖是加在行記錄上的,但MySQL鎖機制并不相同,因為MySQL是基于事務實作的鎖,啥意思呢?來看看:

  • ①目前對表中不同行記錄加鎖的事務是同一個。
  • ②需要加鎖的記錄在同一個頁面中。
  • ③目前事務加鎖的類型都是相同的。
  • ④鎖的等待狀态也是相同的。

當上述四點條件被滿足時,符合條件的行記錄會被放入到同一個鎖結構中,好比以上面的問題為例:

假設加鎖的1000條資料分布在3個頁面中,同時表中沒有其他事務在操作,加的都是同一類型的鎖。

此時依據上述的前提條件,那在記憶體中僅會生成三個鎖結構,能夠很大程度上減少鎖結構的數量。總之情況再複雜,也不會像SQL Server般生成1000個鎖對象,那樣開銷太大了!

2.3、MySQL擷取鎖的過程

當一個事務需要擷取某個行鎖時,首先會看一下記憶體中是否存在這條資料的鎖結構,如果存在則生成一個鎖結構,将其is_waiting對應的比特位改為1,表示目前事務在阻塞等待擷取該鎖,當其他事務釋放鎖後,會喚醒目前阻塞的事務,然後會将其is_waiting改為0,接着執行SQL。

實際上會發現這個過程并不複雜,唯一有些難了解的點就在于:事務擷取鎖時,是如何在記憶體中,判斷是否已經存在相同記錄的鎖結構呢?還記得鎖結構中會記錄的一個資訊嘛?也就是「鎖粒度資訊」,如果是表鎖,會記錄表資訊,如果是行鎖,會記錄表空間、頁号等資訊。在事務擷取鎖時,就是去看記憶體中,已存在的鎖結構的這個資訊,來判斷是否存在其他事務擷取了鎖。

拿表鎖來說,當事務要擷取一張表的鎖時,就會根據表名看一下其他鎖結構,有沒有擷取目前這張表的鎖,如果已經擷取,看一下已經存在的表鎖和目前要加的表鎖,是否會存在沖突,沖突的話is_waiting=1,反之is_waiting=0,而行鎖也是差不多的過程。

釋放鎖的過程也比較簡單,這個工作一般是由MySQL自己完成的,當事務結束後會自動釋放,釋放的時候會去看一下,記憶體中是否有鎖結構,正在等待擷取目前釋放的鎖,如果有則喚醒對應的線程/事務。

其實看下來之後大家會發現,MySQL的鎖機制實作,與正常的鎖實作有些不一樣,一般的鎖機制都是基于持有辨別+等待隊列實作的,而MySQL則是略微有些不一樣。

三、事務隔離機制的底層實作

對于事務隔離機制的底層實作,其實在前面的章節中簡單聊到過,對于并發事務造成的各類問題,在不同的隔離級别實際上,是通過不同粒度、類型的鎖以及MVCC機制來解決的,也就是調整了并發事務的執行順序,進而避免了這些問題産生,具體是如何做的呢?先來看看DBMS中對各隔離級别的要求。

  • RU/讀未送出級别:要求該隔離級别下解決髒寫問題。
  • RC/讀已送出級别:要求該隔離級别下解決髒讀問題。
  • RR/可重複讀級别:要求該隔離級别下解決不可重複讀問題。
  • Serializable/序列化級别:要求在該隔離級别下解決幻讀問題。

雖然DBMS中要求在序列化級别再解決幻讀問題,但在MySQL中,RR級别中就已經解決了幻讀問題,是以MySQL中可以将RR級别視為最進階别,而Serializable級别幾乎用不到,因為序列化級别中解決的問題,在RR級别中基本上已經解決了,再将MySQL調到Serializable級别反而會降低性能。

當然,RR級别下有些極端的情況,依舊會出現幻讀問題,但線上100%不會出現,這點後續聊,先來看看各大隔離級别在MySQL中是如何實作的。

3.1、RU(Read Uncommitted)讀未送出級别的實作

對于RU級别而言,從它名字上就可以看出來,該隔離級别下,一個事務可以讀到其他事務未送出的資料,但同時要求解決髒寫(更新覆寫)問題,那思考一下該怎麼滿足這個需求呢?先來看看不加鎖的情況:

SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

-- ----------- 請按照标出的序号閱讀代碼!!! --------------

-- ①開啟一個事務T1
begin;

-- ③修改 ID=1 的姓名為 竹子
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;

-- ⑥送出T1
commit;
複制代碼           
-- ②開啟另一個事務T2
begin;

-- ④這裡可以讀取到T1中還未送出的 竹子 記錄
SELECT * FROM `zz_users` WHERE user_id = 1;

-- ⑤T2中再次修改姓名為 黑熊
UPDATE `zz_users` SET user_name = "黑熊" WHERE user_id = 1;

-- ⑦送出T2
commit;
複制代碼           

假設上述兩個事務并發執行時,都不加鎖,T2自然可以讀取到T1修改後但還未送出的資料,但當T2再次修改ID=1的資料後,兩個事務一起送出,此時就會出現T2覆寫T1的問題,這也就是髒寫問題,而這個問題是不允許存在的,是以需要解決,咋解決呢?

寫操作加排他鎖,讀操作不加鎖!

還是上述的例子,當寫操作加上排他鎖後,T1在修改資料時,當T2再次嘗試修改相同的資料,也要擷取排他鎖,是以T1、T2兩個事務的寫操作會互相排斥,T2就需要阻塞等待。但因為讀操作不會加鎖,是以當T2嘗試讀取這條資料時,自然可以讀到資料。

來分析一下,因為寫-寫會排斥,但寫-讀不會排斥,是以也滿足了RU級别的要求,即可以讀到未送出的資料,但是不允許出現髒寫問題。

最終經過這一系列的講解後,能夠得知MySQL-RU級别的實作原理,即寫操作加排他鎖,讀操作不加鎖!

3.2、RC(Read Committed)讀已送出級别的實作

了解了RU級别的實作後,再來看看RC,RC級别要求解決髒讀問題,也就是一個事務中,不允許讀另一個事務還未送出的資料,咋實作呢?

寫操作加排他鎖,讀操作加共享鎖!

這樣一想,似乎好像沒問題,還是以之前的例子來說,因為T1在修改資料,是以會對ID=1的資料加上排他鎖,此時T2想要擷取共享鎖讀資料時,T1的排他鎖就會排斥T2,是以T2需要等到T1事務結束後才能讀資料。

因為T2需要等待T1結束後才能讀,既然T1都結束了,那也就代表着T1事務要麼復原了,T2讀上一個事務送出的資料;要麼T1送出了,T2讀T1送出的資料,總之T2讀到的資料絕對是送出過的資料。

這種方式的确能解決髒讀問題,但似乎也會将所有并發事務串行化,會導緻MySQL整體性能下降,是以MySQL引入了一種技術,也就是上篇聊到的《MVCC機制》,在每次select查詢資料時,都會生成一個ReadView快照,然後依據這個快照去選擇一個可讀的資料版本。

是以對于RC級别的底層實作,對于寫操作會加排他鎖,而讀操作會使用MVCC機制。

但由于每次select時都會生成ReadView快照,此時就會出現下述問題:

-- ①T1事務中先讀取一次 ID=1 的資料
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

-- ②T2事務中修改 ID=1 的姓名為 竹子 并送出
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;

-- ③T1事務中再讀取一次 ID=1 的資料
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 竹子      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
複制代碼           

此時觀察這個案例,明明是在一個事務中查詢同一條資料,結果兩次查詢的結果并不一緻,這也是所謂的不可重複讀的問題。

3.3、RR(Repeatable Read)可重複讀級别的實作

在RC級别中,雖然解決了髒讀問題,但依舊存在不可重複讀問題,而RR級别中,就是要確定一個事務中的多次讀取結果一緻,即解決不可重複讀問題,咋解決呢?兩種方案:

  • ①查詢時,對目标資料加上臨鍵鎖,即讀操作執行時,不允許其他事務改動資料。
  • ②MVCC機制的優化版:一個事務中隻生成一次ReadView快照。

相較于第一種方案,第二種方案顯然性能會更好,因為第一種方案不允許讀-寫、寫-讀事務共存,而第二種方案則支援讀寫事務并行執行,咋做到的呢?其實也比較簡單:

寫操作加排他鎖,對讀操作依舊采用MVCC機制,但RR級别中,一個事務中隻有首次select會生成ReadView快照。
-- ①T1事務中先讀取一次 ID=1 的資料
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+

-- ②T2事務中修改 ID=1 的姓名為 竹子 并送出
UPDATE `zz_users` SET user_name = "竹子" WHERE user_id = 1;
commit;

-- ③T1事務中再讀取一次 ID=1 的資料
SELECT * FROM `zz_users` WHERE user_id = 1;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 竹子      | 女       | 6666     | 2022-08-14 15:22:01 |
+---------+-----------+----------+----------+---------------------+
複制代碼           

還是以這個場景為例,在RC級别中,會對于T1事務的每次SELECT都生成快照,是以當T1第二次查詢時,生成的快照中就能看到T2修改後送出的資料。但在RR級别中,隻有首次SELECT會生成快照,當第二次SELECT操作出現時,依舊會基于第一次生成的快照查詢,是以就能確定同一個事務中,每次看到的資料都是相同的。

也正是由于RR級别中,一個事務僅有首次select會生成快照,是以不僅僅解決了不可重複讀問題,還解決了幻讀問題,舉個例子:
-- 先查詢一次使用者表,看看整張表的資料
SELECT * FROM `zz_users`;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       1 | 熊貓      | 女       | 6666     | 2022-08-14 15:22:01 |
|       2 | 竹子      | 男       | 1234     | 2022-09-14 16:17:44 |
|       3 | 子竹      | 男       | 4321     | 2022-09-16 07:42:21 |
|       4 | 貓熊      | 女       | 8888     | 2022-09-27 17:22:59 |
|       9 | 黑竹      | 男       | 9999     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+

-- ①T1事務中,先查詢所有 ID>=4 的使用者資訊
SELECT * FROM `zz_users` WHERE user_id >= 4;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       4 | 貓熊      | 女       | 8888     | 2022-09-27 17:22:59 |
|       9 | 黑竹      | 男       | 9999     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
-- ②T1事務中,再将所有 ID>=4 的使用者密碼重置為 1111
UPDATE `zz_users` SET password = "1111" WHERE user_id >= 4;

-- ③T2事務中,插入一條 ID=6 的使用者資料
INSERT INTO `zz_users` VALUES(6,"棕熊","男","7777","2022-10-02 16:21:33");
-- ④送出事務T2
commit;

-- ⑤T1事務中,再次查詢所有 ID>=4 的使用者資訊
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|       4 | 貓熊      | 女       | 1111     | 2022-09-27 17:22:59 |
|       6 | 棕熊      | 男       | 7777     | 2022-10-02 16:21:33 |
|       9 | 黑竹      | 男       | 1111     | 2022-09-28 22:31:44 |
+---------+-----------+----------+----------+---------------------+
複制代碼           

此時會發現,明明T1中已經将所有ID>=4的使用者密碼重置為1111了,結果改完再次查詢會發現,表中依舊存在一條ID>=4的資料:棕熊,而且密碼未被重置,這似乎産生了幻覺一樣。

如果是RC級别,因為每次select都會生成快照,是以會出現這個幻讀問題,但RR級别中因為隻有首次查詢會生成ReadView快照,是以上述案例放在RR級别的MySQL中,T1看不到T2新增的資料,是以MySQL-RR級别也解決了幻讀問題。

小争議:MVCC機制是否徹底解決了幻讀問題呢?

先上定論,MVCC并沒有徹底解決幻讀問題,在一種奇葩的情況下依舊會出現問題,先來看例子:

-- 開啟一個事務T1
begin;
-- 查詢表中 ID>10 的資料
SELECT * FROM `zz_users` where user_id > 10;
Empty set (0.01 sec)
複制代碼           

因為使用者表中不存在ID>10的資料,是以T1查詢時沒有結果,再繼續往下看。

-- 再開啟一個事務T2
begin;
-- 向表中插入一條 ID=11 的資料
INSERT INTO `zz_users` VALUES(11,"墨竹","男","2222","2022-10-07 23:24:36");
-- 送出事務T2
commit;
複制代碼           

此時T2事務插入一條ID=11的資料并送出,此時再回到T1事務中:

-- 在T1事務中,再次查詢表中 ID>10 的資料
SELECT * FROM `zz_users` where user_id > 10;
Empty set (0.01 sec)
複制代碼           

結果很明顯,依舊未查詢到ID>10的資料,因為這裡是通過第一次生成的快照檔案在讀,是以讀不到T2新增的“幻影資料”,似乎沒問題對嘛?接着往下看:

-- 在T1事務中,對 ID=11 的資料進行修改
UPDATE `zz_users` SET `password` = "1111" where `user_id` = 11;

-- 在T1事務中,再次查詢表中 ID>10 的資料
SELECT * FROM `zz_users` where user_id > 10;
+---------+-----------+----------+----------+---------------------+
| user_id | user_name | user_sex | password | register_time       |
+---------+-----------+----------+----------+---------------------+
|      11 | 墨竹      | 男       | 1111     | 2022-10-07 23:24:36 |
+---------+-----------+----------+----------+---------------------+
複制代碼           

嗯?!??此時會發現,T1事務中又能查詢到ID=11的這條幻影記錄了,這是啥原因導緻的呢?因為我們在T1中修改了ID=11的資料,在《MVCC機制原理剖析》中曾講過MVCC通過快照檢索資料的過程,這裡T1根據原本的快照檔案檢索資料時,因為發現ID=11這條資料上的隐藏列trx_id是自己,是以就能看到這條幻影資料了。

實際上這個問題有點四不像,可以了解成幻讀問題,也可以了解成是不可重複讀問題,總之不管怎麼說,就是MVCC機制存在些許問題!但這種情況線下一般不會發生,畢竟不同僚務之間都是互不相知的,在一個事務中,不可能會去主動修改一條“不存在”的記錄。

但如若你實在不放心,想要徹底杜絕任何風險的出現,那就直接将事務隔離級别調整到Serializable即可。

3.4、Serializable序列化級别的實作

前面已經将RU、RC、RR三個級别的實作原理弄懂了,最後再來看看最高的Serializable級别,在這個級别中,要求解決所有可能會因并發事務引發的問題,那怎麼做呢?比較簡單:

所有寫操作加臨鍵鎖(具備互斥特性),所有讀操作加共享鎖。

由于所有寫操作在執行時,都會擷取臨鍵鎖,是以寫-寫、讀-寫、寫-讀這類并發場景都會互斥,而由于讀操作加的是共享鎖,是以在Serializable級别中,隻有讀-讀場景可以并發執行。

四、事務與鎖機制原理篇總結

在本章中,實則更多的是對《MySQL事務篇》、《MySQL鎖機制》、《MySQL-MVCC機制》的補充以及彙總,在本篇中補齊了MySQL死鎖分析、鎖實作原理、事務隔離機制原理等内容,也結合事務、鎖、MVCC機制三者的知識點,徹底理清楚了MySQL不同隔離級别下的實作,最後做個簡單的小總結:

  • RU級别:讀操作不加鎖,寫操作加排他鎖。
  • RC級别:讀操作使用MVCC機制,每次SELECT生成快照,寫操作加排他鎖。
  • RR級别:讀操作使用MVCC機制,首次SELECT生成快照,寫操作加臨鍵鎖。
  • 序列化級别:讀操作加共享鎖,寫操作加臨鍵鎖。
級别/場景 讀-讀 讀-寫/寫-讀 寫-寫
RU級别 并行執行 并行執行 串行執行
RC級别 并行執行 并行執行 串行執行
RR級别 并行執行 并行執行 串行執行
序列化級别 并行執行 串行執行 串行執行
到這裡,MySQL事務機制、鎖機制、MVCC機制、隔離機制就徹底剖析完畢啦~

繼續閱讀