鎖!這個詞彙在程式設計中出現的次數尤為頻繁,幾乎主流的程式設計語言都會具備完善的鎖機制,在資料庫中也并不例外,為什麼呢?這裡牽扯到一個關鍵詞:高并發,由于現在的計算機領域幾乎都是多核機器,是以再編寫單線程的應用自然無法将機器性能發揮到最大,想要讓程式的并發性越高,多線程技術自然就呼之欲出,多線程技術一方面能充分壓榨CPU資源,另一方面也能提升程式的并發支援性。
多線程技術雖然能帶來一系列的優勢,但也是以引發了一個緻命問題:線程安全問題,為了解決多線程并發執行造成的這個問題,進而又引出了鎖機制,通過加鎖執行的方式解決這類問題。
多線程、線程安全問題、鎖機制,這都是咱們的老朋友了,相信之前曾認真讀過《并發程式設計系列》相關文章的小夥伴都并不陌生,而本章則主要講解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;
複制代碼
此時兩個事務都是執行查詢的操作,是以可以正常執行,如下:
緊接着再在視窗2中,嘗試修改ID=1的資料:
-- 修改 ID=1 的姓名為 貓熊
update `zz_users` set `user_name` = "貓熊" where `user_id` = 1;
複制代碼
此時執行後會發現,視窗2沒了反應,這條寫SQL顯然并未執行成功,如下:
顯然當另一個事務嘗試對具備共享鎖的資料進行寫操作時,會被共享鎖排斥,是以從這個實驗中可以得知:共享鎖也具備排他性,會排斥其他嘗試寫的線程,當有線程嘗試修改同一資料時會陷入阻塞,直至持有共享鎖的事務結束才能繼續執行,如下:
當第一個持有共享鎖的事務送出後,此時第二個事務的寫操作才能繼續往下執行,從上述截圖中可明顯得知:第二個事務/線程被阻塞24.74s後才執行成功,這是由于第一個事務遲遲未結束導緻的。
2.2、排他鎖
上面簡單的了解了共享鎖之後,緊着來看看排他鎖,排他鎖也被稱之為獨占鎖,也就是類似于之前所講到的《AQS-獨占模式》,當一個線程擷取到獨占鎖後,會排斥其他線程,如若其他線程也想對共享資源/同一資料進行操作,必須等到目前線程釋放鎖并競争到鎖資源才行。
值得注意的一點是:排他鎖并不是隻能用于寫操作,對于一個讀操作,咱們也可以手動的指定為擷取排他鎖,當一個事務在讀資料時,擷取了排他鎖,那當其他事務來讀、寫同一資料時,都會被排斥,比如事務T1對ID=88的這條資料加了一個排他鎖,此時T2來加排他鎖讀取這條資料,T3來修改這條資料,都會被T1排斥。
在MySQL中,可以通過如下方式顯式擷取獨占鎖:
SELECT ... FOR UPTATE;
複制代碼
也簡單的做個小實驗,如下:
當兩個事務同時擷取排他鎖,嘗試讀取一條相同的資料時,其中一個事務就會陷入阻塞,直至另一個事務結束才能繼續往下執行,但是下述這種情況則不會被阻塞:
也就是另一個事務不擷取排他鎖讀資料,而是以普通的方式讀資料,這種方式則可以立刻執行,Why?是因為讀操作預設加共享鎖嗎?也并不是,因為你嘗試加共享鎖讀這條資料時依舊會被排斥,如下:
可以明顯看到,第二個事務中嘗試通過加共享鎖的方式讀取這條資料,依舊會陷入阻塞狀态,那前面究竟是因為啥原因才導緻的能讀到資料呢?其實這跟另一種并發控制技術有關,即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引擎中,擷取了鎖還需要自己手動釋放鎖,否則會造成死鎖現象出現,因為如果不手動釋放鎖,就算事務結束也不會自動釋放,除非目前的資料庫連接配接中斷時才會釋放。
此時來觀察一個小實驗,代碼和步驟就不貼了,重點看圖,如下:
如若你自己有興趣,也可以按照上圖中的序号一步步實驗,從這個實驗結果中,顯然能佐證咱們前面抛出的觀點,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的區域,因為間隙鎖是遵循左右開區間的原則,簡單示範一下案例:
上述案例的過程參考圖中注釋即可,不再反複贅述,簡單說一下結論:當對一個不存在的資料加鎖後,預設就是鎖定前後兩條資料之間的區間,當其他事務再嘗試向該區間插入資料時,就會陷入阻塞,隻有當持有間隙鎖的事務結束後,才能繼續執行插入操作。
不過間隙鎖加在不同的位置,鎖定的範圍也并不相同,如果加在兩條資料之間,那麼鎖定的區域就是兩條資料之間的間隙。如果加在上表ID=1的資料上,鎖定的區域就是{-∞ ~ 1},即無窮小到1之間的區域。如果加在ID=9之後,鎖定的區域就是{9 ~ +∞},即9之後到無窮大的區域。
4.5、臨鍵鎖(Next-Key Lock)
臨鍵鎖是間隙鎖的Plus版本,或者可以說成是一種由記錄鎖+間隙鎖組成的鎖:
- 記錄鎖:鎖定的範圍是表中具體的一條行資料。
- 間隙鎖:鎖定的範圍是左閉右開的區間,并不包含最後一條真實資料。
而臨鍵鎖則是兩者的結合體,加鎖後,即鎖定左閉右開的區間,也會鎖定目前行資料,還是以上述表為例,做個簡單的小實驗,如下:
這回和間隙鎖的實驗類似,但也并不相同,這回是基于表中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操作的葉分支中時,依舊會被阻塞