本節我們通過一些具體的案例來分析Innodb對錶上鎖的過程。具體場景如下圖所示。
在這裏我們將語句分爲4類:普通select(快照讀)、鎖定讀、半一致性讀 和 insert語句。
普通讀
普通的select在不同隔離級別下有不同的表現。
在 讀未提交 的級別下:不加鎖,直接讀取版本鏈最新版本,可能出現髒讀、不可重複讀和幻讀;
在 讀已提交 的級別下:不加鎖,每次select會生成一個ReadView配合讀取版本鏈中該ReadView可見的版本,避免了髒讀,可能出現不可重複讀和幻讀;
在 可重複讀 的級別下:不加鎖,第一次執行select生成ReadView配合讀取版本鏈中該ReadView可見的版本,避免了髒讀、不可重複讀和幻讀;
在 串行化 的級別下:如果執行 begin 語句進行手動提交事務,則select會被轉爲 select lock in share mode語句變成鎖定讀。
如果使用自動事務提交,則select會通過MVCC快照讀。自動提交意味着一個事務只包含一條語句,因此不可能出現不可重複讀和幻讀(因爲不可重複讀和幻讀的出現需要前讀和後讀兩次讀)。
需要注意:我們知道MVCC可以解決幻讀,但實際上它不能完全解決幻讀。舉個例子:
事務T1查找一條不存在的記錄,在T2插入了這條不存在的記錄並提交之後,T1如果不update,直接執行第二次select,那麼是不會查到這條記錄的,這要歸功於ReadView和版本鏈保證數據讀取時不會從最新的版本讀取(想不明白可以回顧之前ReadView和版本鏈如何查找可見版本的過程)。
因此我們說MVCC可以解決幻讀。
如果T1先update再執行第二次select(如上圖所示的那樣),由於update是一個當前讀,因此肯定可以讀到T2提交的新增記錄,所以update 會成功。並且會更新版本鏈的最新版本,最新版本的trx_id是自己的trx_id,該最新版本對本事務可見,所以T1再select 就能查到這個最新版本記錄。
因此我們說MVCC不能完全解決幻讀。
此時唯有使用臨鍵鎖才能徹底解決幻讀,也就是說要在T1的第一次select使用鎖定讀,而不能用MVCC讀:select * from hero where number = 30 lock in share mode;。
鎖定讀
鎖定讀包括下面4種語句:
a. select ... lock in share mode;
b. select ... for update;
c. update ... ;
d. delete ... ;
修改和刪除也需要先查到指定記錄才能修改和刪除,所以也會涉及到讀,而且修改和刪除的讀是鎖定讀(當前讀)而不是快照讀(其實也不一定百分百是當前讀,有可能是半一致性讀,後面會再說)。
而且需要注意,update 和 delete 會加鎖,是在更改前的當前讀之前就加了鎖,而不是在真的修改和刪除時才加鎖的。
匹配模式
在開始介紹鎖定讀之前先引入幾個概念。
精確匹配:如果掃描的區間是一個單點掃描區間,則稱爲精確匹配。
例子:有一個聯合索引 (a, b)。
where a=1 是精確匹配,掃描區間爲 [1, 1];
where a=1 and b=1 是精確匹配,掃描區間爲 ([1, 1], [1, 1]);
where a=1 and b>1 是非精確匹配,掃描區間爲 ([1, 1], [1, +∞]);
唯一性搜索:如果能確定掃描區間只包含一條記錄,那麼這種搜索是唯一性搜索。
唯一性搜索需要滿足一下幾個條件:使用的是唯一索引,且必須是單點查詢,且索引列條件不能包含null。
加鎖過程
下面重點介紹加鎖的過程。
0、假設有一條查詢語句的掃描區間(可能是二級索引或主鍵索引的掃描區間)爲 (x, y),接下來會發生這些事情。
1、快速在B+樹葉子節點定位到該掃描區間的第一條記錄,作爲當前記錄。
2、爲當前記錄加鎖。
如果是 讀已提交 和 讀未提交 的級別,則會爲當前記錄加一個記錄鎖,如果是 可重複讀 和 串行化 級別,則爲當前記錄加臨鍵鎖(臨鍵鎖解決幻讀)。
3、判斷索引條件下推是否成立。
前面說過 索引條件下推 是在二級索引通過where中可以利用到的條件在二級索引就減少記錄數以減少回表次數的一種機制(從而減少隨機IO次數)。
索引條件下推只在二級索引會用到,所以如果是在聚簇索引中則忽略步驟3。
如果滿足索引條件下推則跳到步驟4,否則就沿着單向鏈表往後找到下一條記錄作爲新的當前記錄,回到步驟2。
需要注意,在二級索引加了鎖的記錄,在回表的過程中不會釋放鎖。
4、執行回表操作。
在聚簇索引找到對應記錄,對聚簇索引上的這些記錄加記錄鎖。
5、判斷當前記錄是否滿足where中主鍵的區間邊界條件和其他字段的條件,不滿足則根據隔離級別選擇是否釋放該記錄上的鎖(讀未提交和讀已提交可以釋放,可重複讀和串行化不可釋放),滿足則將該行返回給客戶端(但不釋放鎖),並在二級索引(如果沒用到二級索引,那就沿主鍵索引的鏈表找下一條記錄)獲取記錄單向鏈表的下一條記錄作爲新的當前記錄,跳回第2步。
上述過程需要執行 y-x 次。對於每條記錄,innodb是先加鎖再判斷區間條件和其他條件是否滿足,然後再決定是否釋放鎖。
下面是一些例子。
例子1:
下面以讀已提交級別爲準描述加鎖過程。
0、訪問方式爲range,生成的主鍵掃描區間是 (1, 15]。number=3是該區間內第一條記錄。
1、爲number=3的主鍵索引記錄加一個S記錄鎖。
2、由於number=3 滿足主鍵條件,但不滿足其他條件,因此釋放鎖。
3、尋找下一個記錄8,爲其加S記錄鎖,由於number=8 滿足其他條件因此返回給客戶端,在找到 下一條 number = 15的記錄,操作同上。
4、再找到下一條記錄 number=20,對其加鎖,由於number=20不滿足條件,因此釋放鎖。
查詢完成。
需要注意,對於每條記錄,innodb是先加鎖再判斷區間條件和其他條件,所以 number=20和number=3也會被上鎖,然後再解鎖。
下面以可重複讀級別爲準描述加鎖過程。
1、主鍵掃描區間是 (1, 15]。number=3是該區間內第一條記錄,爲number=3的主鍵索引記錄加一個S臨鍵鎖。
2、number=3 滿足主鍵條件,但不滿足其他條件,不過不會釋放鎖。
3、尋找下一個記錄8,爲其加S臨鍵鎖,由於number=8 滿足其他條件因此返回給客戶端,在找到 下一條 number = 15的記錄,操作同上。
4、再找到下一條記錄 number=20,對其加臨鍵鎖,number=20不滿足條件,但不會釋放鎖。
查詢完成。
例子2:
該sql強制使用 name 字段索引(idx_name),區間範圍是 ('c曹操' , 'x荀彧']。explain的 Using index condition表示使用索引條件下推。
下面以讀已提交級別爲準描述加鎖過程。
1、二級索引中找到第一條滿足區間範圍的記錄“l劉備”,對該二級索引記錄上記錄鎖,並判斷“l劉備”是否滿足區間範圍和能在二級索引判斷的所有條件,滿足;
回表,在主鍵索引記錄上記錄鎖,判斷其他條件,發現滿足所有其他條件,將該記錄返回客戶端。在二級索引中沿鏈表找到下一條"s孫權"。
2、對該二級索引記錄“s孫權”上鎖,“s孫權”滿足區間範圍和能在二級索引判斷的所有條件;回表,在主鍵索引記錄上記錄鎖,判斷其他條件,發現不滿足所有其他條件,因此釋放主鍵索引和二級索引對應的記錄的鎖。在二級索引中沿鏈表找到下一條"x荀彧"。
3、"x荀彧"操作同上,不再複述,會對其主鍵索引和二級索引上鎖,將記錄返回客戶端。在二級索引中沿鏈表找到下一條"z諸葛亮"。
4、對該二級索引記錄“z諸葛亮”上鎖,“z諸葛亮”不滿足區間範圍,不再回表,因此查詢至此結束(z諸葛亮記錄此時不會釋放鎖);
查詢結束,圖中置灰的部分是被加鎖了的記錄。
需要注意:在 讀已提交和讀未提交的級別,在二級索引中,如果一條記錄不滿足索引條件下推的條件,它是不會被釋放鎖的,例如例子中的 z諸葛亮 記錄就是這種情況(s孫權之所以能釋放鎖是因爲他在主鍵索引檢測出不滿足 country 條件,它是滿足索引條件下推的條件的(即索引區間範圍條件))。
以可重複讀級別爲準的加鎖過程和上面類似,只不過是在二級索引記錄上加臨鍵鎖,在主鍵索引記錄上加記錄鎖,而且不滿足條件也不會釋放鎖。加鎖情況如下圖:
可能大家有點疑惑,上面的兩個例子,有的是在主鍵索引加臨鍵鎖,有的時候是在二級索引加臨鍵鎖,到底什麼時候用臨鍵鎖,什麼時候用記錄鎖?
首先,臨鍵鎖是用來解決幻讀的,因此只有在可重複讀和串行化級別纔會出現;第二,where使用哪個索引就對哪個索引的記錄加臨鍵鎖,例如例1是 where number > 1 AND number <= 15,因此臨鍵鎖加到了主鍵索引上,例2是where name > 'c曹操' AND name <= 'x荀彧',因此臨鍵鎖加到了二級索引,沒有加到主鍵索引。
下面我們再看一下 update 和 delete 的例子。
update 的加鎖過程和上面的過程沒有區別,只不過是把S鎖改爲X鎖。不過稍微注意一下這種情況:
如果 update 的where 條件不涉及二級索引列,按理說是不會對二級索引加鎖,只會對主鍵索引加鎖,但如果修改的列是索引列,那麼即使where 條件不涉及二級索引列也會對二級索引記錄加鎖。
例子3:
讀已提交/讀未提交的加鎖情況如下:
可重複讀/串行化的加鎖情況如下:
例子4:精確匹配(單點查詢,如 =,in)
讀已提交/讀未提交 級別的加鎖情況
加鎖情況如下,爲曹操記錄加了一個記錄鎖。
可重複讀/串行化的加鎖情況
如果是 可重複讀/串行化 則會爲掃描區間後面的下一條記錄加gap鎖,掃描區間是哪個索引的掃描區間,就在哪個索引上加。在這裏是name這個二級索引的掃描區間['c曹操', 'c曹操']。
加鎖情況如下,二級索引上,爲曹操加了一個臨鍵鎖,並在曹操和劉備之間加了一個間隙鎖。
如果單點查詢的掃描區間沒有記錄,也要爲這個區間加一個gap鎖:
例子5:單點查詢的掃描區間沒有找到記錄
加鎖情況如下,沒有記錄被鎖住,但"c 曹操"和 "l 劉備"之間的間隙被加了一個間隙鎖。
例子6:可重複讀/串行化 下,非精確匹配,沒找到記錄,會爲區間範圍的下一條記錄加 臨鍵鎖。
加鎖情況:爲劉備這條記錄加臨鍵鎖。
例子7:可重複讀/串行化 下,條件是主鍵索引,非精確匹配,區間範圍的左區間是閉區間,且左邊界剛好存在記錄,則該記錄加的是記錄鎖。
加鎖情況:爲number爲8的記錄加了記錄鎖,爲掃描到的其他記錄加臨鍵鎖。
例子8:唯一性搜索(主鍵和唯一索引)加的是記錄鎖。
半一致性讀
半一致性讀是一種介於一致性讀和鎖定讀之間的讀取方式。半一致性讀只用於 讀已提交/讀未提交 的update語句。
我們知道,在前面介紹update和delete的時候說過,update 和 delete 在更改之前需要先定位到索引的記錄位置才能更改,因此更改前需要讀,而且絕大部分情況下是當前讀。
實際上,在 讀已提交/讀未提交 級別下,如果事務A的update語句要修改的記錄已經被其他事務加了X鎖,事務A就會讀取該記錄版本鏈的最新已提交版本,並判斷該版本是否與update語句中的搜索條件相匹配,如果不匹配則不對其加鎖(不對其修改,跳到下一條記錄),匹配則對其加鎖(然後陷入阻塞,待其他事務解鎖後對該記錄進行修改),這就是半一致性讀。
半一致性讀可以避免update讀到where不匹配的記錄時被阻塞的情況,從而提高寫寫之間的效率。
這樣說可能很抽象,下面看一個例子幫助理解。
例子9:有兩個讀已提交的事務T1、T2
T1執行了當前讀,未提交
此時聚簇索引的記錄8被加了X記錄鎖。
T2執行update語句
掃描區間在[8, 20),T2不會先對記錄8加鎖,而是先查記錄8的最新已提交版本到server層,該版本的country是'魏',不滿足T2的update條件,因此server層會放棄讓事務T2對記錄8上鎖也不會修改記錄8。如此一來,T2就避免被阻塞從而提高了併發效率。
換做是 可重複讀和串行化 的情況下,無論如何T2都會嘗試對曹操記錄加鎖因而被阻塞。
半一致性讀讓寫寫在某些特殊場景下可以併發進行,雖然沒有產生髒寫,但相當於打了擦邊球。
最終提醒大家要不忘初心,加鎖的目的是爲了保證事務的隔離性,具體說是爲了避免事務併發引起的髒讀、髒寫、不可重複讀和幻讀問題。這些例子只是爲了幫助大家瞭解Mysql的加鎖機制,實際工作中我們幾乎不會涉及到如此微觀的層面,因此我們也不需要專門去記住什麼時候加什麼鎖、鎖住哪幾條記錄之類的事情,僅做了解即可。
insert語句的加鎖情況
一個事務插入一條記錄會先檢查該位置是否被其他事務上了gap鎖,如果有則會加一個插入意向鎖再進入阻塞狀態;如果沒有則不會生成插入意向鎖,而且插入時也不會生成顯式鎖。
然而如果遇到下面這兩種情況,insert就會生成顯式鎖。
1、插入重複鍵(duplicate key)
如果insert時發現主鍵將出現重複值(例如已經有一條記錄A,後來插入一條和A有主鍵衝突的記錄B),在報錯之前會先爲該重複記錄(記錄A)加一個S鎖(如果是讀已提交/讀未提交 則上一個S記錄鎖,如果是可重複讀/串行化 則上一個S臨鍵鎖)。
如果是非主鍵的唯一二級索引出現重複值,則不論什麼隔離級別都是要在二級索引的那條重複記錄上加一個臨鍵鎖。
如果使用 insert ... on duplicate key... 來插入發生了唯一鍵字段重複,則在重複記錄上加X鎖而不是S鎖(因爲 on duplicate key語法在唯一鍵重複時會轉insert爲update,update就必須上鎖)。
2、外鍵檢查
如果一個表的某個字段A指向另一個表的主鍵,那麼這個字段A就是外鍵。
外鍵所在的表是從表,被指向的表是主表。在具有外鍵的從表中插入一條記錄,系統會對主表加鎖。
外鍵的約束如下(這是些關於外鍵的前置知識,知道的可以跳過蛤):
如果主表沒有某個主鍵的記錄,從表就不能插入這個主鍵對應的外鍵記錄。因此必須先插入主表才能插入從表。
在修改和刪除上也有類似的約束和對應的級聯操作。
可選的級聯操作如下:
cascade:關聯操作,如果主表的行被更新或刪除,從表也會執行相應的操作。
set null:不關聯任何操作。
restrict:拒絕主表的相關操作
例如:
外鍵和普通的表與表連接字段的區別在於,前者有強約束,使一致性更容易得到保障,但實際應用中由於約束較強,很少使用。
回到insert加鎖的問題上,假設hero表是主表,horse表是從表,外鍵是horse.hero_id,如果在從表插入一個記錄,插入的hero_id在主表中找得到(假設hid=8),那麼需要對主表中id爲8的記錄加S記錄鎖。
如果插入的hid在主表中找不到(假設hid=5),那麼對於 讀未提交和讀已提交級別 無需加鎖,對 可重複讀和串行化則需要對主表中id爲5的間隙加S間隙鎖。
查看加鎖情況
INNODB_TRX表:該表存儲了 lnnoDB 存儲引擎當前正在執行的事務信息,包括事務 id、事務狀態(比如事務是正在運行還是在等待獲取某個鎖、事務正在執行的語句、事務是何時開啓的〉等。
其中包含以下重要字段:
trx_tables_locked :該事務加了多少表級鎖;
trx_rows_locked :該事務加了多少行級鎖;
trx_lock_struct :該事務生成了多少個鎖結構;
INNODB_LOCKS表:記錄鎖信息,包括一個事務嘗試獲取某個鎖但沒能獲取到的信息 和 一個事務獲取到了鎖但阻塞了別的事務的信息。如果沒有阻塞,則該表沒有記錄。
INNODB_LOCK_WAITS 表:記錄更多鎖和阻塞的信息。
其中,requesting_trx_id是被阻塞的事務id,blocking_trx_id 是導致 requesting_trx_id這個事務被阻塞的事務id。
如果想看更詳細的事務和鎖信息,可以執行
只看其中的transactions信息即可。
下面我們看一個結果返回的例子
事務 id爲 46688 的事務對xiaohaizi 數據庫下 hero 表加了表級別的意向獨佔鎖。
表示一個鎖結構,這個鎖結構對應 表空間號203 ,頁號5,n_bits 屬性值爲 72(約等於該頁中的記錄數)。
對應的索引是 idx_name,鎖類型是 gap 間隙鎖(Iock_ mode X locks gap before rec 代表的就是 gap 鎖)。
後面那兩串內容是鎖結構的詳細信息,包括鎖住的記錄的字段。
鎖類型是 next-key 臨鍵鎖(Iock_ mode X 代表的就是 next-key 鎖),鎖住的是二級索引 idx_name的記錄。
鎖類型是 記錄鎖(Iock_ mode X
locks rec but not gap代表的就是記錄鎖),這裏鎖住的是主鍵索引 idx_name的記錄。
最後需要注意的是,一個事務是在執行了第一條更改語句後才被分配事務id,如果事務只執行了 鎖定讀/當前讀 就結束事務,那麼這個事務不會有事務id,使用 show engine innodb status 也不會看到該事務過程整產生的鎖(因爲它沒有被分配事務id)。