天天看點

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

作者:Civen

鎖!這個詞彙在程式設計中出現的次數尤為頻繁,幾乎主流的程式設計語言都會具備完善的鎖機制,在資料庫中也并不例外,為什麼呢?這裡牽扯到一個關鍵詞:高并發,由于現在的計算機領域幾乎都是多核機器,是以再編寫單線程的應用自然無法将機器性能發揮到最大,想要讓程式的并發性越高,多線程技術自然就呼之欲出,多線程技術一方面能充分壓榨CPU資源,另一方面也能提升程式的并發支援性。

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

多線程技術雖然能帶來一系列的優勢,但也是以引發了一個緻命問題:線程安全問題,為了解決多線程并發執行造成的這個問題,進而又引出了鎖機制,通過加鎖執行的方式解決這類問題。

多線程、線程安全問題、鎖機制,這都是咱們的老朋友了,相信之前曾認真讀過《并發程式設計系列》相關文章的小夥伴都并不陌生,而本章則主要講解MySQL中提供的鎖機制。

一、MySQL鎖的由來與分類

用戶端發往MySQL的一條條SQL語句,實際上都可以了解成一個個單獨的事務,而在前面的《MySQL事務篇》中提到過:事務是基于資料庫連接配接的,而每個資料庫連接配接在MySQL中,又會用一條工作線程來維護,也意味着一個事務的執行,本質上就是一條工作線程在執行,當出現多個事務同時執行時,這種情況則被稱之為并發事務,所謂的并發事務也就是指多條線程并發執行。

多線程并發執行自然就會出問題,也就是《事務篇-并發事務問題》中聊到的髒寫、髒讀、不可重複讀以及幻讀問題,而對于這些問題又可以通過調整事務的隔離級别來避免,那為什麼調整事務的隔離級别後能避免這些問題産生呢?這是因為不同的隔離級别中,工作線程執行SQL語句時,用的鎖粒度、類型不同。

也就是說,資料庫的鎖機制本身是為了解決并發事務帶來的問題而誕生的,主要是確定資料庫中,多條工作線程并行執行時的資料安全性。

但要先弄明白一點,所謂的并發事務肯定是基于同一個資料而言的,例如事務A目前在操作X表,事務B在操作Y表,這是一個并發事務嗎?答案顯然并不是,因為兩者操作的都不是同一個資料,沒有共享資源自然也不會造成并發問題。多個事務共同操作一張表、多個事務一起操作同一行資料等這類情景,這才是所謂的并發事務。

1.1、MySQL鎖機制的分類

MySQL的鎖機制與索引機制類似,都是由存儲引擎負責實作的,這也就意味着不同的存儲引擎,支援的鎖也并不同,這裡是指不同的引擎實作的鎖粒度不同。但除開從鎖粒度來劃分鎖之外,其實鎖也可以從其他的次元來劃分,是以也會造出很多關于鎖的名詞,下面先簡單梳理一下MySQL的鎖體系:

  • 以鎖粒度的次元劃分: ①表鎖: 全局鎖:加上全局鎖之後,整個資料庫隻能允許讀,不允許做任何寫操作。 中繼資料鎖 / MDL鎖:基于表的中繼資料加鎖,加鎖後整張表不允許其他事務操作。 意向鎖:這個是InnoDB中為了支援多粒度的鎖,為了相容行鎖、表鎖而設計的。 自增鎖 / AUTO-INC鎖:這個是為了提升自增ID的并發插入性能而設計的。 ②頁面鎖 ③行鎖: 記錄鎖 / Record鎖:也就是行鎖,一條記錄和一行資料是同一個意思。 間隙鎖 / Gap鎖:InnoDB中解決幻讀問題的一種鎖機制。 臨建鎖 / Next-Key鎖:間隙鎖的更新版,同時具備記錄鎖+間隙鎖的功能。
  • 以互斥性的次元劃分: 共享鎖 / S鎖:不同僚務之間不會互相排斥、可以同時擷取的鎖。 排他鎖 / X鎖:不同僚務之間會互相排斥、同時隻能允許一個事務擷取的鎖。 共享排他鎖 / SX鎖:MySQL5.7版本中新引入的鎖,主要是解決SMO帶來的問題。
  • 以操作類型的次元劃分: 讀鎖:查詢資料時使用的鎖。 寫鎖:執行插入、删除、修改、DDL語句時使用的鎖。
  • 以加鎖方式的次元劃分: 顯示鎖:編寫SQL語句時,手動指定加鎖的粒度。 隐式鎖:執行SQL語句時,根據隔離級别自動為SQL操作加鎖。
  • 以思想的次元劃分: 樂觀鎖:每次執行前認為自己會成功,是以先嘗試執行,失敗時再擷取鎖。 悲觀鎖:每次執行前都認為自己無法成功,是以會先擷取鎖,然後再執行。

放眼望下來,是不是看着還蠻多的,但總歸說來說去其實就共享鎖、排他鎖兩種,隻是加的方式不同,加的地方不同,是以就演化出了這麼多鎖的稱呼。

二、共享鎖與排他鎖

共享鎖又被稱之為S鎖,它是Shared Lock的簡稱,這點很容易了解,而排他鎖又被稱之為X鎖,對于這點我則不太了解,因為排他鎖的英文是Exclusive Lock,竟然不叫E鎖,反而叫X鎖,到底是紅杏出了牆還是.....,打住,回歸話題本身來聊一聊這兩種鎖。

其實有些地方也将共享鎖稱之為讀鎖,排他鎖稱之為寫鎖,這乍一聽并沒啥問題,畢竟對同一資料做讀操作是可以共享的,寫則是不允許。但這個說法并不完全正确,因為讀操作也可以是排他鎖,即讀操作發生時也不允許其他線程操作,而MySQL中也的的确确有這類場景,比如:

一條線程在讀資料時加了一把鎖(讀鎖),此時當另外一條線程來嘗試對相同資料做寫操作時,這條線程會陷入阻塞,因為MySQL中一條線程在讀時不允許其他線程改。

在上述這個案例中,讀鎖明顯也會存在排斥寫操作,是以前面說法并不正确,共享鎖就是共享鎖,排他鎖就是排他鎖,不能與讀鎖、寫鎖混為一談。

2.1、共享鎖

共享鎖的意思很簡單,也就是不同僚務之間不會排斥,可以同時擷取鎖并執行,這就類似于之前聊過的《AQS-共享模式》,但這裡所謂的不會排斥,僅僅隻是指不會排斥其他事務來讀資料,但其他事務嘗試寫資料時,就會出現排斥性,舉個例子了解:

事務T1對ID=88的資料加了一個共享鎖,此時事務T2、T3也來讀取ID=88的這條資料,這時T2、T3是可以擷取共享鎖執行的,但此刻又來了一個事務T4,它則是想對ID=88的這條資料執行修改操作,此時共享鎖會出現排斥行為,不允許T4擷取鎖執行。

在MySQL中,我們可以在SQL語句後加上相關的關鍵字來使用共享鎖,文法如下:

SELECT ... LOCK IN SHARE MODE;
-- MySQL8.0之後也優化了寫法,如下:
SELECT ... FOR SHARE;
複制代碼           

這種通過在SQL後添加關鍵字的加鎖形式,被稱為顯式鎖,而實際上為資料庫設定了不同的事務隔離級别後,MySQL也會對SQL自動加鎖,這種形式則被稱之為隐式鎖。

此時來做個關于共享鎖的小實驗,先打開兩個cmd視窗并與MySQL建立連接配接:

-- 視窗1:
-- 開啟一個事務
begin;
-- 擷取共享鎖并查詢 ID=1 的資料
select * from `zz_users` where user_id = 1 lock in share mode;
複制代碼           
-- 視窗2:
-- 開啟一個事務
begin;
-- 擷取共享鎖并查詢 ID=1 的資料
select * from `zz_users` where user_id = 1 lock in share mode;
複制代碼           

此時兩個事務都是執行查詢的操作,是以可以正常執行,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

緊接着再在視窗2中,嘗試修改ID=1的資料:

-- 修改 ID=1 的姓名為 貓熊
update `zz_users` set `user_name` = "貓熊" where `user_id` = 1;
複制代碼           

此時執行後會發現,視窗2沒了反應,這條寫SQL顯然并未執行成功,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

顯然當另一個事務嘗試對具備共享鎖的資料進行寫操作時,會被共享鎖排斥,是以從這個實驗中可以得知:共享鎖也具備排他性,會排斥其他嘗試寫的線程,當有線程嘗試修改同一資料時會陷入阻塞,直至持有共享鎖的事務結束才能繼續執行,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

當第一個持有共享鎖的事務送出後,此時第二個事務的寫操作才能繼續往下執行,從上述截圖中可明顯得知:第二個事務/線程被阻塞24.74s後才執行成功,這是由于第一個事務遲遲未結束導緻的。

2.2、排他鎖

上面簡單的了解了共享鎖之後,緊着來看看排他鎖,排他鎖也被稱之為獨占鎖,也就是類似于之前所講到的《AQS-獨占模式》,當一個線程擷取到獨占鎖後,會排斥其他線程,如若其他線程也想對共享資源/同一資料進行操作,必須等到目前線程釋放鎖并競争到鎖資源才行。

值得注意的一點是:排他鎖并不是隻能用于寫操作,對于一個讀操作,咱們也可以手動的指定為擷取排他鎖,當一個事務在讀資料時,擷取了排他鎖,那當其他事務來讀、寫同一資料時,都會被排斥,比如事務T1對ID=88的這條資料加了一個排他鎖,此時T2來加排他鎖讀取這條資料,T3來修改這條資料,都會被T1排斥。

在MySQL中,可以通過如下方式顯式擷取獨占鎖:

SELECT ... FOR UPTATE;
複制代碼           

也簡單的做個小實驗,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

當兩個事務同時擷取排他鎖,嘗試讀取一條相同的資料時,其中一個事務就會陷入阻塞,直至另一個事務結束才能繼續往下執行,但是下述這種情況則不會被阻塞:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

也就是另一個事務不擷取排他鎖讀資料,而是以普通的方式讀資料,這種方式則可以立刻執行,Why?是因為讀操作預設加共享鎖嗎?也并不是,因為你嘗試加共享鎖讀這條資料時依舊會被排斥,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

可以明顯看到,第二個事務中嘗試通過加共享鎖的方式讀取這條資料,依舊會陷入阻塞狀态,那前面究竟是因為啥原因才導緻的能讀到資料呢?其實這跟另一種并發控制技術有關,即MVCC機制(下篇再深入剖析)。

2.3、MySQL鎖的釋放

等等,似乎在咱們前面的實驗中,每次都僅擷取了鎖,但好像從未釋放過鎖呀?其實MySQL中釋放鎖的動作都是隐式的,畢竟如果交給咱們來釋放,很容易由于操作不當造成死鎖問題發生。是以對于鎖的釋放工作,MySQL自己來幹,就類似于JVM中的GC機制一樣,把記憶體釋放的工作留給了自己完成。

但對于鎖的釋放時機,在不同的隔離級别中也并不相同,比如在“讀未送出”級别中,是SQL執行完成後就立馬釋放鎖,而在“可重複讀”級别中,是在事務結束後才會釋放。

OK~,接下來一起來聊一聊MySQL中不同粒度的鎖,即表鎖、行鎖、頁鎖等。

三、MySQL表鎖

表鎖應該是聽的最多的一種鎖,因為實作起來比較簡單,同時應用範圍也比較廣泛,幾乎所有的存儲引擎都會支援這個粒度的鎖,比如常用的MyISAM、InnoDB、Memory等各大引擎都實作了表鎖。

但要注意,不同引擎的表鎖也在實作上以及加鎖方式上有些許不同,但歸根結底,表鎖的意思也就以表作為鎖的基礎,将鎖加在表上,一張表隻能存在一個同一類型的表鎖。

上面這段話中提到過,不同的存儲引擎的表鎖在使用方式上也有些不同,比如InnoDB是一個支援多粒度鎖的存儲引擎,它的鎖機制是基于聚簇索引實作的,當SQL執行時,如果能在聚簇索引命中資料,則加的是行鎖,如無法命中聚簇索引的資料則加的是表鎖,比如:

select * from `zz_users` for update;
複制代碼           

這條SQL就無法命中聚簇索引,此時自然加的就是表級别的排他鎖,但是這個表級鎖,并不是真正意義上的表鎖,是一個“僞表鎖”,但作用是相同的,鎖了整張表。

而反觀MyISAM引擎,由于它并不支援聚簇索引,是以無法再以InnoDB的這種形式去對表上鎖,是以如若要在MyISAM引擎中使用表鎖,又需要使用額外的文法,如下:

-- MyISAM引擎中擷取讀鎖(具備讀-讀可共享特性)
LOCK TABLES `table_name` READ;

-- MyISAM引擎中擷取寫鎖(具備寫-讀、寫-寫排他特性)
LOCK TABLES `table_name` WRITE;

-- 檢視目前庫中建立過的表鎖(in_use>0表示目前正在使用的表鎖)
SHOW OPEN TABLES WHERE in_use > 0;

-- 釋放已擷取到的鎖
UNLOCK TABLES;
複制代碼           

如上便是MyISAM引擎中,擷取表級别的共享鎖和排他鎖的方式,但這裡的關鍵詞其實叫做READ、WEITE,翻譯過來也就是讀、寫的意思,是以關于共享鎖就是讀鎖、排他鎖就是寫鎖的說法,估計就是是以而來的。

不過MyISAM引擎中,擷取了鎖還需要自己手動釋放鎖,否則會造成死鎖現象出現,因為如果不手動釋放鎖,就算事務結束也不會自動釋放,除非目前的資料庫連接配接中斷時才會釋放。

此時來觀察一個小實驗,代碼和步驟就不貼了,重點看圖,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

如若你自己有興趣,也可以按照上圖中的序号一步步實驗,從這個實驗結果中,顯然能佐證咱們前面抛出的觀點,MyISAM表鎖顯式擷取後,必須要自己主動釋放,否則結合資料庫連接配接池,由于資料庫連接配接是長存的,就會導緻表鎖一直被占用。

這裡還漏了一個小實驗,也就是當你加了read讀鎖後,再嘗試加write寫鎖,就會發現無法擷取鎖,目前線程會陷入阻塞,反過來也是同理,但我就不再重新再弄了,畢竟這個圖再搞一次就有點累~

OK~,到這裡就對InnoDB、MyISAM引擎中的表鎖做了簡單介紹,但實際上除開最基本的表鎖外,還有其他幾種表鎖,即中繼資料鎖、意向鎖、自增鎖、全局鎖,接下來一起來聊一聊這些特殊的鎖。

3.1、中繼資料鎖(Meta Data Lock)

Meta Data Lock中繼資料鎖,也被簡稱為MDL鎖,這是基于表的中繼資料加鎖,什麼意思呢?咱們到目前為止已經模模糊糊懂得一個概念:表鎖是基于整張表加鎖,行鎖是基于一條資料加鎖,那這個表的中繼資料是什麼東東呢?在《索引原理篇》中聊索引的實作時,曾提到過一點:所有存儲引擎的表都會存在一個.frm檔案,這個檔案中主要存儲表的結構(DDL語句),而DML鎖就是基于.frm檔案中的中繼資料加鎖的。

對于這種鎖是在MySQL5.5版本後再開始支援的,一般來說咱們用不上,是以也無需手動擷取鎖,這個鎖主要是用于:更改表結構時使用,比如你要向一張表建立/删除一個索引、修改一個字段的名稱/資料類型、增加/删除一個表字段等這類情況。

因為畢竟當你的表結構正在發生更改,假設此時有其他事務來對表做CRUD操作,自然就會出現問題,比如我剛删了一個表字段,結果另一個事務中又按原本的表結構插入了一條資料,這顯然會存在風險,是以DML鎖在加鎖後,整張表不允許其他事務做任何操作。

3.2、意向鎖(Intention Lock)

前面提到過,InnoDB引擎是一種支援多粒度鎖的引擎,而意向鎖則是InnoDB中為了支援多粒度的鎖,為了相容行鎖、表鎖而設計的,怎麼了解這句話呢?先來看一個例子:

假設一張表中有一千萬條資料,現在事務T1對ID=8888888的這條資料加了一個行鎖,此時來了一個事務T2,想要擷取這張表的表級别寫鎖,經過前面的一系列講解,大家應該知道寫鎖必須為排他鎖,也就是在同一時刻内,隻允許目前事務操作,如果表中存在其他事務已經擷取了鎖,目前事務就無法滿足“獨占性”,是以不能擷取鎖。

那思考一下,由于T1是對ID=8888888的資料加了行鎖,那T2擷取表鎖時,是不是得先判斷一下表中是否存在其他事務在操作?但因為InnoDB中有行鎖的概念,是以表中任何一行資料上都有可能存在事務加鎖操作,為了能精準的知道答案,MySQL就得将整張表的1000W條資料全部周遊一次,然後逐條檢視是否有鎖存在,那這個效率自然會非常的低。

有人可能會說,慢就慢點怎麼了,能接受!但實際上不僅僅存在這個問題,還有另外一個緻命問題,比如現在MySQL已經判斷到了第567W行資料,發現前面的資料上都沒有鎖存在,正在繼續往下周遊。

要記住MySQL是支援并發事務的,也就是MySQL正在掃描後面的每行資料是否存在鎖時,萬一又來了一個事務在掃描過的資料行上加了個鎖怎麼辦?比如在第123W條資料上加了一個行鎖。那難道又重新掃描一遍嘛?這就陷入了死循環,行鎖和表鎖之間出現了相容問題。

也正是由于行鎖和表鎖之間存在相容性問題,是以意向鎖它來了!意向鎖實際上也是一種特殊的表鎖,意向鎖其實是一種“挂牌告知”的思想,好比日常生活中的計程車,一般都會有一個牌子,表示它目前是“空車”還是“載客”狀态,而意向鎖也是這個思想。

比如當事務T1打算對ID=8888888這條資料加一個行鎖之前,就會先加一個表級别的意向鎖,比如目前T1要加一個行級别的讀鎖,就會先添加一個表級别的意向共享鎖,如果T1要加行級别的寫鎖,亦是同理。

此時當事務T2嘗試擷取一個表級鎖時,就會先看一下表上是否有意向鎖,如果有的話再判斷一下與自身是否沖突,比如表上存在一個意向共享鎖,目前T2要擷取的是表級别的讀鎖,那自然不沖突可以擷取。但反之,如果T2要擷取一個表記的寫鎖時,就會出現沖突,T2事務則會陷入阻塞,直至T1釋放了鎖(事務結束)為止。

3.3、自增鎖(AUTO-INC Lock)

自增鎖,這個是專門為了提升自增ID的并發插入性能而設計的,通常情況下咱們在建表時,都會對一張表的主鍵設定自增特性,如下:

CREATE TABLE `table_name` (
    `xx_id` NOT NULL AUTO_INCREMENT,
    .....
) ENGINE = InnoDB;
複制代碼           

當對一個字段設定AUTO_INCREMENT自增後,意味着後續插入資料時無需為其指派,系統會自動賦上順序自增的值。但想一想,比如目前表中最大的ID=88,如果兩個并發事務一起對表執行插入語句,由于是并發執行的原因,是以有可能會導緻插入兩條ID=89的資料。是以這裡必須要加上一個排他鎖,確定并發插入時的安全性,但也由于鎖的原因,插入的效率也就是以降低了,畢竟将所有寫操作串行化了。

為了改善插入資料時的性能,自增鎖誕生了,自增鎖也是一種特殊的表鎖,但它僅為具備AUTO_INCREMENT自增字段的表服務,同時自增鎖也分成了不同的級别,可以通過innodb_autoinc_lock_mode參數控制。
  • innodb_autoinc_lock_mode = 0:傳統模式。
  • innodb_autoinc_lock_mode = 1:連續模式(MySQL8.0以前的預設模式)。
  • innodb_autoinc_lock_mode = 2:交錯模式(MySQL8.0之後的預設模式)。

當然,這三種模式又是什麼含義呢?想要徹底搞清楚,那就得先弄明白MySQL中可能出現的三種插入類型:

  • 普通插入:指通過INSERT INTO table_name(...) VALUES(...)這種方式插入。
  • 批量插入:指通過INSERT ... SELECT ...這種方式批量插入查詢出的資料。
  • 混合插入:指通過INSERT INTO table_name(id,...) VALUES(1,...),(NULL,...),(3,...)這種方式插入,其中一部分指定ID,一部分不指定。

簡單了解上述三種插入模式後,再用一句話來概述自增鎖的作用:自增鎖主要負責維護并發事務下自增列的順序,也就是說,每當一個事務想向表中插入資料時,都要先擷取自增鎖先配置設定一個自增的順序值,但不同模式下的自增鎖也會有些許不同。

傳統模式:事務T1擷取自增鎖插入資料,事務T2也要插入資料,此時事務T2隻能阻塞等待,也就是傳統模式下的自增鎖,同時隻允許一條線程執行,這種形式顯然性能較低。

連續模式:這個模式主要是由于傳統模式存在性能短闆而研發的,在這種模式中,對于能夠提前确定數量的插入語句,則不會再擷取自增鎖,啥意思呢?也就是對于“普通插入類型”的語句,因為在插入之前就已經确定了要插入多少條資料,因為會直接配置設定範圍自增值。

好比目前事務T1要通過INSERT INTO...語句插入十條資料,目前表中存在的最大ID=88,那在連續模式下,MySQL會直接将89~98這十個自增值配置設定給T1,是以T1無需再擷取自增鎖,但不擷取自增鎖不代表不擷取鎖了,而是改為使用一種輕量級鎖Mutex-Lock來防止自增值重複配置設定。

對于普通插入類型的操作,由于可以提前确定插入的資料量,是以可以采用“預配置設定”思想,但如若對于批量插入類型的操作,因為批量插入的資料是基于SELECT語句查詢出來的,是以在執行之前也無法确定究竟要插入多少條,是以依舊會擷取自增鎖執行。也包括對于混合插入類型的操作,有一部分指定了自增值,但有一部分需要MySQL配置設定,是以“預配置設定”的思想也用不上,是以也要擷取自增鎖執行。

交錯模式:在交錯插入模式中,對于INSERT、REPLACE、INSERT…SELECT、REPLACE…SELECT、LOAD DATA等一系列插入語句,都不會再使用表級别的自增鎖,而是全都使用Mutex-Lock來確定安全性,為什麼在這個模式中,批量插入也可以不擷取自增鎖呢?這跟它的名字有關,目前這個模式叫做交錯插入模式,也就是不同僚務之間插入資料時,自增列的值是交錯插入的,舉個例子了解。

好比事務T1、T2都要執行批量插入的操作,因為不确定各自要插入多少條資料,是以之前那種“連續預配置設定”的思想用不了了,但雖然無法做“連續的預配置設定”,那能不能交錯預配置設定呢?好比給T1配置設定{1、3、5、7、9....},給T2配置設定{2、4、6、8、10.....},然後兩個事務交錯插入,這樣豈不是做到了自增值即不重複,也能支援并發批量插入?答案是Yes,但由于兩個事務執行的都是批量插入的操作,是以事先不确定插入行數,是以有可能導緻“交錯預配置設定”的順序值,有可能不會使用,比如T1隻插入了四條資料,隻用了1、3、5、7,T2插入了五條資料,是以表中的自增值有可能出現空隙,即{1、2、3、4、5、6、8、10},其中9就并未使用。

雖然我沒看過自增鎖這塊的源碼,但交錯插入模式底層應該是我推測的這種方式實作的,也就是利用自增列的步長機制實作,不過由于插入可能會出現空隙,是以對後續的主從複制也有一定影響(以後再細聊)。

不過相對來說影響也不大,雖然無法保證自增值的連續性,但至少能確定遞增性,是以對索引的維護不會造成額外開銷。

3.4、全局鎖

全局鎖其實是一種尤為特殊的表鎖,其實将它稱之為庫鎖也許更合适,因為全局鎖是基于整個資料庫來加鎖的,加上全局鎖之後,整個資料庫隻能允許讀,不允許做任何寫操作,一般全局鎖是在對整庫做資料備份時使用。

-- 擷取全局鎖的指令
FLUSH TABLES WITH READ LOCK;

-- 釋放全局鎖的指令
UNLOCK TABLES;
複制代碼           

從上述的指令也可以看出,為何将其歸納到表鎖範圍,因為擷取鎖以及釋放鎖的指令都是表鎖的指令。

四、MySQL行鎖

通常而言,為了盡可能提升資料庫的整體性能,是以每次在加鎖時,鎖的範圍自然是越小越好,舉個例子:

假設此時有1000個請求,要操作zz_users表中的資料,如果以表粒度來加鎖,假設第一個請求擷取到的是排他鎖,也就意味着其他999個請求都需要阻塞等待,其效率可想而知....

仔細一思考:雖然此時有1000個請求操作zz_users表,但這些請求中至少90%以上,要操作的都是表中不同的行資料,是以如若每個請求都擷取表級鎖,顯然太影響效率了,而InnoDB引擎中也考慮到了這個問題,是以實作了更細粒度的鎖,即行鎖。

4.1、表鎖與行鎖之間的關系

表鎖與行鎖之間的關系,舉個生活中的例子來快速了解一下,一張表就類似于一個生活中的酒店,每個事務/請求就類似于一個個旅客,旅客住宿為了確定夜晚安全,通常都會鎖門保護自己。而表鎖呢,就類似于一個旅客住進酒店之後,直接把酒店大門給鎖了,其他旅客就隻能等第一位旅客住完出來之後才能一個個進去,每個旅客進酒店之後的第一件事情就是鎖大門,防止其他旅客威脅自己的安全問題。

但假設酒店門口來了一百位旅客,其中大部分旅客都是不同的房間(情侶除外),是以直接鎖酒店大門顯然并不合理。

而行鎖呢,就類似于房間的鎖,門口的100位旅客可以一起進酒店,每位旅客住進自己的房間之後,将房門反鎖,這顯然也能保障各自的人身安全問題,同時也能讓一個酒店在同一時間内接納更多的旅客,“性能”更高。

4.2、InnoDB的行鎖實作

放眼望去,在MySQL諸多的存儲引擎中,僅有InnoDB引擎支援行鎖(不考慮那些閉源自研的),這是由于什麼原因導緻的呢?因為InnoDB支援聚簇索引,在之前簡單聊到過,InnoDB中如果能夠命中索引資料,就會加行鎖,無法命中則會加表鎖。

在《索引原理篇-InnoDB索引實作》中提到過,InnoDB會将表資料存儲在聚簇索引中,每條行資料都會存儲在樹中的葉子節點上,是以行資料是“分開的”,是以可以對每一條資料上鎖,但其他引擎大部分都不支援聚簇索引,表資料都是一起存儲在一塊的,是以隻能基于整個表資料上鎖,這也是為什麼其他引擎不支援行鎖的原因。

4.3、記錄鎖(Record Lock)

Record Lock記錄鎖,實際上就是行鎖,一行表資料、一條表記錄本身就是同一個含義,是以行鎖也被稱為記錄鎖,兩個稱呼最終指向的是同一類型的鎖,那如何使用行鎖呢?

-- 擷取行級别的 共享鎖
select * from `zz_users` where user_id = 1 lock in share mode;

-- 擷取行級别的 排他鎖
select * from `zz_users` where user_id = 1 for update;
複制代碼           

是的,你沒看錯,想要使用InnoDB的行鎖就是這樣寫的,如果你的SQL能命中索引資料,那也就自然加的就是行鎖,反之則是表鎖。但網上很多資料都流傳着一個說法:InnoDB引擎的表鎖沒啥用,其實這句話會存在些許誤導性,因為意向鎖、自增鎖、DML鎖都是表鎖,也包括InnoDB的行鎖是基于索引實作的,例如在update語句修改資料時,假設where後面的條件無法命中索引,那咋加行鎖呢?此時沒辦法就必須得加表鎖了,是以InnoDB的表鎖是有用的。

4.4、間隙鎖(Gap Lock)

間隙鎖是對行鎖的一種補充,主要是用來解決幻讀問題的,但想要了解它,咱們首先來了解啥叫間隙:

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 |
+---------+-----------+----------+----------+---------------------+
複制代碼           

上述這張表最後兩條資料,ID字段之間從4跳到了9,那麼4~9兩者之間的範圍則被稱為“間隙”,而間隙鎖則主要鎖定的是這塊範圍。

那為何又說間隙鎖是用來解決幻讀問題的呢?因為幻讀的概念是:一個事務在執行時,另一個事務插入了一條資料,進而導緻第一個事務操作完成之後發現結果與預想的不一緻,跟産生了幻覺一樣。

好比拿上述表舉例子,現在要将ID>3的使用者密碼重置為1234,是以事務T1先查到了ID>3的4、9兩條資料并上鎖了,然後開始更改使用者密碼,但此時事務T2過來又插入了一條ID=6、password=7777的資料并送出,等T1修改完了4、9兩條資料後,此時再次查詢ID>3的資料時,結果發現了ID=6的這條資料并未被重置密碼。

在上述這個例子中,T2因為新增并送出了事務,是以T1再次查詢時也能看到ID=6的這條資料,就跟産生了幻覺似的,對于這種新增資料,專業的叫法稱之為幻影資料。

為了防止出現安全問題,是以T1在操作之前會對目标資料加鎖,但在T1事務執行時,這條幻影資料還不存在,是以就會出現一個新的問題:不知道把鎖加在哪兒,畢竟想要對ID=6的資料加鎖,就是加了個寂寞。

那難道不加鎖了嗎?肯定得加鎖,但怎麼加呢?普通的行鎖就已經無法解決這個問題了,總不能加表鎖吧,那也太影響性能了,是以間隙鎖應運而生!間隙鎖的功能與它的名字一樣,主要是對間隙區域加鎖,舉個例子:

select * from `zz_users` where user_id = 6 lock in share mode;
複制代碼           

這條加鎖的SQL看起來似乎不是那麼合理對吧?畢竟ID=6的資料在表中還沒有呀,咋加鎖呢?其實這個就是間隙鎖,此時會鎖定{4~9}之間、但不包含4、9的區域,因為間隙鎖是遵循左右開區間的原則,簡單示範一下案例:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

上述案例的過程參考圖中注釋即可,不再反複贅述,簡單說一下結論:當對一個不存在的資料加鎖後,預設就是鎖定前後兩條資料之間的區間,當其他事務再嘗試向該區間插入資料時,就會陷入阻塞,隻有當持有間隙鎖的事務結束後,才能繼續執行插入操作。

不過間隙鎖加在不同的位置,鎖定的範圍也并不相同,如果加在兩條資料之間,那麼鎖定的區域就是兩條資料之間的間隙。如果加在上表ID=1的資料上,鎖定的區域就是{-∞ ~ 1},即無窮小到1之間的區域。如果加在ID=9之後,鎖定的區域就是{9 ~ +∞},即9之後到無窮大的區域。

4.5、臨鍵鎖(Next-Key Lock)

臨鍵鎖是間隙鎖的Plus版本,或者可以說成是一種由記錄鎖+間隙鎖組成的鎖:

  • 記錄鎖:鎖定的範圍是表中具體的一條行資料。
  • 間隙鎖:鎖定的範圍是左閉右開的區間,并不包含最後一條真實資料。

而臨鍵鎖則是兩者的結合體,加鎖後,即鎖定左閉右開的區間,也會鎖定目前行資料,還是以上述表為例,做個簡單的小實驗,如下:

MySQL鎖機制:高并發場景下該如何保證資料讀寫的安全性?

這回和間隙鎖的實驗類似,但也并不相同,這回是基于表中ID=9的這條資料加鎖的,此時來看結果,除開鎖定了4~9這個區間外,對于ID=9這條資料也鎖定了,因為在事務T2中嘗試對ID=9的資料修改時,也會讓事務陷入阻塞。

臨鍵鎖的注意點:當原本持有鎖的T1事務結束後,T2會執行插入操作,這時鎖會被T2擷取,當你再嘗試開啟一個新的事務T3,再次擷取相同的臨鍵鎖時,是無法擷取的,隻能等T2結束後才能擷取(因為臨建鎖包含了記錄鎖,雖然間隙鎖可以同時由多個事務持有,但排他類型的記錄鎖隻允許一個事務持有)。
實際上在InnoDB中,除開一些特殊情況外,當嘗試對一條資料加鎖時,預設加的是臨鍵鎖,而并非記錄鎖、間隙鎖。

也就是說,在前面舉例幻讀問題中,當T1要對ID>3的使用者做密碼重置,鎖定4、9這兩條行資料時,預設會加的是臨鍵鎖,也就是當事務T2嘗試插入ID=6的資料時,因為有臨建鎖存在,是以無法再插入這條“幻影資料”,也就至少保障了T1事務執行過程中,不會碰到幻讀問題。

4.6、插入意向鎖(Insert Intention Lock)

插入意向鎖,聽起來似乎跟前面的表級别意向鎖有些類似,但實際上插入意向鎖是一種間隙鎖,這種鎖是一種隐式鎖,也就是咱們無法手動的擷取這種鎖。通常在MySQL中插入資料時,是并不會産生鎖的,但在插入前會先簡單的判斷一下,目前事務要插入的位置有沒有存在間隙鎖或臨鍵鎖,如果存在的話,目前插入資料的事務則需阻塞等待,直到擁有臨鍵鎖的事務送出。

當事務執行插入語句阻塞時,就會生成一個插入意向鎖,表示目前事務想對一個區間插入資料(目前的事務處于等待插入意向鎖的狀态)。

當持有原本持有臨建鎖的事務送出後,目前事務即可以擷取插入意向鎖,然後執行插入操作,當此時如若又來一個新的事務,也要在該區間中插入資料,那新的事務會阻塞嗎?答案是不會,可以直接執行插入操作,Why?

因為在之前的《SQL執行篇-寫入SQL執行流程》中曾說到過,對于寫入SQL都會做一次唯一性檢測,如果要插入的資料,與表中已有的資料,存在唯一性沖突時會直接抛出異常并傳回。這也就意味着:如果沒抛出異常,就代表着目前要插入的資料與表中資料不存在唯一性沖突,或表中壓根不存在唯一性字段,可以允許插入重複的資料。

簡單來說就是:能夠真正執行的插入語句,絕對是通過了唯一檢測的,是以插入時可以讓多事務并發執行,同時如果設定了自增ID,也會擷取自增鎖確定安全性,是以當多個事務要向一個區間插入資料時,插入意向鎖是不會排斥其他事務的,從這種角度而言,插入意向鎖也是一種共享鎖。

4.7、行鎖的粒度粗化

有一點要值得注意:行鎖并不是一成不變的,行鎖會在某些特殊情況下發生粗化,主要有兩種情況:

  • 在記憶體中專門配置設定了一塊空間存儲鎖對象,當該區域滿了後,就會将行鎖粗化為表鎖。
  • 當做範圍性寫操作時,由于要加的行鎖較多,此時行鎖開銷會較大,也會粗化成表鎖。

當然,這兩種情況其實很少見,是以隻需要知道有鎖粗化這回事即可,這種鎖粗化的現象其實在SQLServer資料庫中更常見,因為SQLServer中的鎖機制是基于行記錄實作的,而MySQL中的鎖機制則是基于事務實作的(後續《事務與鎖原理篇》詳細剖析)。

五、頁面鎖、樂觀鎖與悲觀鎖

上述對MySQL兩種較為常見的鎖粒度進行了闡述,接着再來看看頁面鎖、樂觀鎖與悲觀鎖。

5.1、頁面鎖

頁面鎖是Berkeley DB存儲引擎支援的一種鎖粒度,當然,由于BDB引擎被Oracle收購的原因,是以MySQL5.1以後不再直接性的支援該引擎(需自己整合),是以頁鎖見的也比較少,大家稍微了解即可。

  • 表鎖:以表為粒度,鎖住的是整個表資料。
  • 行鎖:以行為粒度,鎖住的是一條資料。
  • 頁鎖:以頁為粒度,鎖住的是一頁資料。

唯一有些許疑惑的地方,就是一頁資料到底是多少呢?其實我也不大清楚,畢竟沒用過BDB引擎,但我估計就是隻一個索引頁的大小,即16KB左右。

簡單了解後頁鎖後,接着來看一看從思想次元劃分的兩種鎖,即樂觀鎖與悲觀鎖。

5.2、樂觀鎖

樂觀鎖即是無鎖思想,對于這點在之前聊《并發程式設計系列-Unsafe與原子包》時曾詳細講到過,但悲觀鎖也好,樂觀鎖也罷,實際上僅是一種鎖的思想,如下:

  • 樂觀鎖:每次執行都認為隻會有自身一條線程操作,是以無需拿鎖直接執行。
  • 悲觀鎖:每次執行都認為會有其他線程一起來操作,是以每次都需要先拿鎖再執行。

樂觀鎖與悲觀鎖也對應着咱們日常生活中,處理一件事情的态度,一個人性格很樂觀時,做一件事情時都會把結果往好處想,而一個人性格很悲觀時,處理一件事情都會做好最壞的打算。

OK~,程式設計中的無鎖技術,或者說樂觀鎖機制,一般都是基于CAS思想實作的,而在MySQL中則可以通過version版本号+CAS的形式實作樂觀鎖,也就是在表中多設計一個version字段,然後在SQL修改時以如下形式操作:

UPDATE ... SET version = version + 1 ... WHERE ... AND version = version;
複制代碼           

也就是每條修改的SQL都在修改後,對version字段加一,比如T1、T2兩個事務一起并發執行時,當T2事務執行成功送出後,就會對version+1,是以事務T1的version=version這個條件就無法成立,最終會放棄執行,因為已經被其他事務修改過了。

當然,一般的樂觀鎖都會配合輪詢重試機制,比如上述T1執行失敗後,再次執行相同語句,直到成功為止。

從上述過程中不難看出,這個過程中确實未曾添加鎖,是以也做到了樂觀鎖/無鎖的概念落地,但這種形式卻并不适合所有情況,比如寫操作的并發較高時,就容易導緻一個事務長時間一直在重試執行,進而導緻用戶端的響應尤為緩慢。

是以樂觀鎖更加适用于讀大于寫的業務場景,頻繁寫庫的業務則并不适合加樂觀鎖。

5.3、悲觀鎖

悲觀鎖的思想咱們上面已經提到了,即每次執行時都會加鎖再執行,咱們之前分析的《synchronized關鍵字》、《AQS-ReetrantLock》都屬于悲觀鎖類型,也就是在每次執行前必須擷取到鎖,然後才能繼續往下執行,而資料庫中的排他鎖,就是一種典型的悲觀鎖類型。

在資料庫中想要使用悲觀鎖,那也就是對一個事務加排他鎖for update即可,不再重複贅述。

五、MySQL鎖機制總結

看到這裡,相信大家對MySQL中提供的鎖機制有了全面的認識,但以目前情況而言,雖對每種鎖類型有了基本認知,但本篇的内容更像一個個的點,很難和《MySQL事務篇》連成線,而對于這塊的具體内容,則會放在後續的《事務與鎖機制的實作原理篇》中詳細講解,在後續的原理篇中再将這一個個知識點串聯起來,因為想要真正弄懂MySQL事務隔離機制的實作,還缺少了一塊至關重要的點沒講到:即MVCC機制。

是以會先講明白資料庫的MVCC多版本并發控制技術的實作後,再去剖析事務隔離機制的實作。

最後再來簡單的總結一下本篇所聊到的不同鎖,它們之間的沖突與相容關系:

PS:表中橫向(行)表示已經持有鎖的事務,縱向(列)表示正在請求鎖的事務。
行級鎖對比 共享臨鍵鎖 排他臨鍵鎖 間隙鎖 插入意向鎖
共享臨鍵鎖 相容 沖突 相容 沖突
排他臨鍵鎖 沖突 沖突 相容 沖突
間隙鎖 相容 相容 相容 沖突
插入意向鎖 沖突 沖突 沖突 相容

由于臨建鎖也會鎖定相應的行資料,是以上表中也不再重複贅述記錄鎖,臨建鎖相容的 記錄鎖都相容,同理,沖突的記錄鎖也會沖突,再來看看标記别的鎖對比:

表級鎖對比 共享意向鎖 排他意向鎖 中繼資料鎖 自增鎖 全局鎖
共享意向鎖 相容 沖突 沖突 沖突 沖突
排他意向鎖 沖突 沖突 沖突 沖突 沖突
中繼資料鎖 沖突 沖突 沖突 沖突 沖突
自增鎖 沖突 沖突 沖突 沖突 沖突
全局鎖 相容 沖突 沖突 沖突 沖突

放眼望下來,其實會發現表級别的鎖,會有很多很多沖突,因為鎖的粒度比較大,是以很多時候都會出現沖突,但對于表級鎖,咱們隻需要關注共享意向鎖和共享排他鎖即可,其他的大多數為MySQL的隐式鎖(在這裡,共享意向鎖和排他意向鎖,也可以了解為MyISAM中的表讀鎖和表寫鎖)。

最後再簡單的說一下,表中的沖突和相容究竟是啥意思?沖突的意思是當一個事務T1持有某個鎖時,另一個事務T2來請求相同的鎖,T2會由于鎖排斥會陷入阻塞等待狀态。反之同理,相容的意思是指允許多個事務一同擷取同一個鎖。

MySQL5.7版本中新增的共享排他鎖

對于這條是最後補齊的,之前漏寫了這種鎖類型,在MySQL5.7之前的版本中,資料庫中僅存在兩種類型的鎖,即共享鎖與排他鎖,但是在MySQL5.7.2版本中引入了一種新的鎖,被稱之為(SX)共享排他鎖,這種鎖是共享鎖與排他鎖的雜交類型,至于為何引入這種鎖呢?聊它之前需要先了解SMO問題:

在SQL執行期間一旦更新操作觸發B+Tree葉子節點分裂,那麼就會對整棵B+Tree加排它鎖,這不但阻塞了後續這張表上的所有的更新操作,同時也阻止了所有試圖在B+Tree上的讀操作,也就是會導緻所有的讀寫操作都被阻塞,其影響巨大。是以,這種大粒度的排它鎖成為了InnoDB支援高并發通路的主要瓶頸,而這也是MySQL 5.7版本中引入SX鎖要解決的問題。

那想一下該如何解決這個問題呢?最簡單的方式就是減小SMO問題發生時,鎖定的B+Tree粒度,當發生SMO問題時,就隻鎖定B+Tree的某個分支,而并不是鎖定整顆B+樹,進而做到不影響其他分支上的讀寫操作。

那MySQL5.7中引入共享排他鎖後,究竟是如何實作的這點呢?首先要弄清楚SX鎖的特性,它不會阻塞S鎖,但是會阻塞X、SX鎖,下面展開來聊一聊。

在聊之前首先得搞清楚SQL執行時的幾個概念:

  • 讀取操作:基于B+Tree去讀取某條或多條行記錄。
  • 樂觀寫入:不會改變B+Tree的索引鍵,僅會更改索引值,比如主鍵索引樹中不修改主鍵字段,隻修改其他字段的資料,不會引起節點分裂。
  • 悲觀寫入:會改變B+Tree的結構,也就是會造成節點分裂,比如無序插入、修改索引鍵的字段值。

在MySQL5.6版本中,一旦有操作導緻了樹結構發生變化,就會對整棵樹加上排他鎖,阻塞所有的讀寫操作,而MySQL5.7版本中,為了解決該問題,對于不同的SQL執行,流程就做了調整。

MySQL5.7中讀操作的執行流程

  • ①讀取資料之前首先會對B+Tree加一個共享鎖。
  • ②在基于樹檢索資料的過程中,對于所有走過的葉節點會加一個共享鎖。
  • ③找到需要讀取的目标葉子節點後,先加一個共享鎖,釋放步驟②上加的所有共享鎖。
  • ④讀取最終的目标葉子節點中的資料,讀取完成後釋放對應葉子節點上的共享鎖。

MySQL5.7中樂觀寫入的執行流程

  • ①樂觀寫入之前首先會對B+Tree加一個共享鎖。
  • ②在基于樹檢索修改位置的過程中,對于所有走過的葉節點會加一個共享鎖。
  • ③找到需要寫入資料的目标葉子節點後,先加一個排他鎖,釋放步驟②上加的所有共享鎖。
  • ④修改目标葉子節點中的資料後,釋放對應葉子節點上的排他鎖。

MySQL5.7中悲觀寫入的執行流程

  • ①悲觀更新之前首先會對B+Tree加一個共享排他鎖。
  • ②由于①上已經加了SX鎖,是以目前事務執行過程中會阻塞其他嘗試更改樹結構的事務。
  • ③周遊查找需要寫入資料的目标葉子節點,找到後對其分支加上排他鎖,釋放①中加的SX鎖。
  • ④執行SMO操作,也就是執行悲觀寫入操作,完成後釋放步驟③中在分支上加的排他鎖。

如果需要修改多個資料時,會在周遊查找的過程中,記錄下所有要修改的目标節點。

MySQL5.7中并發事務沖突分析

觀察上述講到的三種執行情況,對于讀操作、樂觀寫入操作而言,并不會加SX鎖,共享排他鎖僅針對于悲觀寫入操作會加,由于讀操作、樂觀寫入執行前對整顆樹加的是S鎖,是以悲觀寫入時加的SX鎖并不會阻塞樂觀寫入和讀操作,但當另一個事務嘗試執行SMO操作變更樹結構時,也需要先對樹加上一個SX鎖,這時兩個悲觀寫入的并發事務就會出現沖突,新來的事務會被阻塞。

但是要注意:當第一個事務尋找到要修改的節點後,會對其分支加上X鎖,緊接着會釋放B+Tree上的SX鎖,這時另外一個執行SMO操作的事務就能擷取SX鎖啦!

其實從上述中可能得知一點:MySQL5.7版本引入共享排他鎖之後,解決了5.6版本發生SMO操作時阻塞一切讀寫操作的問題,這樣能夠在一定程度上提升了InnoDB表的并發性能。

最後要注意:雖然一個執行悲觀寫入的事務,找到了要更新/插入資料的節點後會釋放SX鎖,但是會對其上級的葉節點(葉分支)加上排他鎖,是以正在發生SMO操作的葉分支,依舊是會阻塞所有的讀寫行為!

上面這句話啥意思呢?也就是當一個要讀取的資料,位于正在執行SMO操作的葉分支中時,依舊會被阻塞

繼續閱讀