MySQL/InnoDB的加鎖分析,一直是一個(gè)比較困難的話題。本文,準(zhǔn)備就MySQL/InnoDB的加鎖問題,展開較為分析與討論,主要是介紹一種思路,運(yùn)用此思路,拿到任何一條SQL語句,都能完整的分析出這條語句會(huì)加什么鎖?會(huì)有什么樣的使用風(fēng)險(xiǎn)?MySQL是一個(gè)支持插件式存儲(chǔ)引擎的數(shù)據(jù)庫系統(tǒng)。本文下面的所有介紹,都是基于InnoDB存儲(chǔ)引擎,其他引擎的表現(xiàn),會(huì)有較大的區(qū)別。
一條簡單SQL的加鎖實(shí)現(xiàn)分析
就如同下面兩條簡單的SQL,他們加什么鎖?
SQL1: select * from t1 where id = 10;
SQL2: delete from t1 where id = 10;
SQL1:不加鎖。因?yàn)镸ySQL是使用多版本并發(fā)控制的,讀不加鎖。
SQL2:對(duì)id = 10的記錄加寫鎖 (走主鍵索引)。
可能是正確的,也有可能是錯(cuò)誤的,已知條件不足,要回答這個(gè)問題,還缺少哪些前提條件?
前提一:id列是不是主鍵?
前提二:當(dāng)前系統(tǒng)的隔離級(jí)別是什么?
前提三:id列如果不是主鍵,那么id列上有索引嗎?
前提四:id列上如果有二級(jí)索引,那么這個(gè)索引是唯一索引嗎?
前提五:兩個(gè)SQL的執(zhí)行計(jì)劃是什么?索引掃描?全表掃描?
組合一:id列是主鍵,RC隔離級(jí)別
這個(gè)組合,是最簡單,最容易分析的組合。id是主鍵,Read Committed隔離級(jí)別,給定SQL:delete from t1 where id = 10; 只需要將主鍵上,id = 10的記錄加上X鎖即可。

組合二:id唯一索引+RC
id是unique索引,而主鍵是name列。由于id是unique索引,因此delete語句會(huì)選擇id列的索引進(jìn)行where條件的過濾,在找到id=10的記錄后,首先會(huì)將unique索引上的id=10索引記錄加上X鎖,同時(shí),會(huì)根據(jù)讀取到的name列,回主鍵索引(聚簇索引),然后將聚簇索引上的name = ‘d’ 對(duì)應(yīng)的主鍵索引項(xiàng)加X鎖。
為什么聚簇索引上的記錄也要加鎖?如果并發(fā)的一個(gè)SQL,是通過主鍵索引來更新:update t1 set id = 100 where name = ‘d’; 此時(shí),如果delete語句沒有將主鍵索引上的記錄加鎖,那么并發(fā)的update就會(huì)感知不到delete語句的存在,違背了同一記錄上的更新/刪除需要串行執(zhí)行的約束。
若id列是unique列,其上有unique索引。那么SQL需要加兩個(gè)X鎖,一個(gè)對(duì)應(yīng)于id unique索引上的id = 10的記錄,另一把鎖對(duì)應(yīng)于聚簇索引上的[name=’d’,id=10]的記錄。

組合三:id非唯一索引+RC
相對(duì)于組合一、二,id列不再唯一,只有一個(gè)普通的索引。
滿足id = 10查詢條件的記錄,均已加鎖。同時(shí),這些記錄對(duì)應(yīng)的主鍵索引上的記錄也都加上了鎖。與組合二唯一的區(qū)別在于,組合二最多只有一個(gè)滿足等值查詢的記錄,而組合三會(huì)將所有滿足查詢條件的記錄都加鎖。

組合四:id無索引+RC
這個(gè)過濾條件,沒法通過索引進(jìn)行過濾,那么只能走全表掃描做過濾。對(duì)應(yīng)于這個(gè)組合,SQL會(huì)加什么鎖?換句話說,全表掃描時(shí),會(huì)加什么鎖?這個(gè)答案也有很多:有人說會(huì)在表上加X鎖;有人說會(huì)將聚簇索引上,選擇出來的id = 10;的記錄加上X鎖。那么實(shí)際情況呢?
由于id列上沒有索引,因此只能走聚簇索引,進(jìn)行全部掃描。聚簇索引上所有的記錄,都被加上了X鎖。無論記錄是否滿足條件,全部被加上X鎖。既不是加表鎖,也不是在滿足條件的記錄上加行鎖。

為什么不是只在滿足條件的記錄上加鎖呢?這是由于MySQL的實(shí)現(xiàn)決定的。如果一個(gè)條件無法通過索引快速過濾,那么存儲(chǔ)引擎層面就會(huì)將所有記錄加鎖后返回,然后由MySQL Server層進(jìn)行過濾。因此也就把所有的記錄,都鎖上了。
在實(shí)際的實(shí)現(xiàn)中,MySQL有一些改進(jìn),在MySQL Server過濾條件,發(fā)現(xiàn)不滿足后,會(huì)調(diào)用unlock_row方法,把不滿足條件的記錄放鎖 (違背了2PL的約束)。這樣做,保證了最后只會(huì)持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。
組合五:id主鍵+RR
上面的四個(gè)組合,都是在Read Committed隔離級(jí)別下的加鎖行為,接下來的四個(gè)組合,是在Repeatable Read隔離級(jí)別下的加鎖行為。
id列是主鍵列,Repeatable Read隔離級(jí)別,針對(duì)delete from t1 where id = 10; 這條SQL,加鎖與組合一:[id主鍵,Read Committed]一致。
組合六:id唯一索引+RR
與組合二:[id唯一索引,Read Committed]一致。兩個(gè)X鎖,id唯一索引滿足條件的記錄上一個(gè),對(duì)應(yīng)的聚簇索引上的記錄一個(gè)。
組合七:id非唯一索引+RR
RC隔離級(jí)別允許幻讀,而RR隔離級(jí)別,不允許存在幻讀。那么RR隔離級(jí)別下,如何防止幻讀呢?
組合七,Repeatable Read隔離級(jí)別,id上有一個(gè)非唯一索引,執(zhí)行delete from t1 where id = 10; 假設(shè)選擇id列上的索引進(jìn)行條件過濾,最后的加鎖行為,是怎么樣的呢?

相對(duì)于組合三:[id列上非唯一鎖,Read Committed]看似相同,其實(shí)卻有很大的區(qū)別。
最大的區(qū)別在于,多了一個(gè)GAP鎖,而且GAP鎖看起來也不是加在記錄上的,是加載兩條記錄之間的位置,GAP鎖有何用?
這個(gè)多出來的GAP鎖,就是RR隔離級(jí)別,相對(duì)于RC隔離級(jí)別,不會(huì)出現(xiàn)幻讀的關(guān)鍵。GAP鎖鎖住的位置,不是記錄本身,而是兩條記錄之間的GAP。所謂幻讀,就是同一個(gè)事務(wù),連續(xù)做兩次當(dāng)前讀(例如:select * from t1 where id = 10 for update;),那么這兩次當(dāng)前讀返回的是完全相同的記錄 (記錄數(shù)量一致,記錄本身也一致),第二次的當(dāng)前讀,不會(huì)比第一次返回更多的記錄 (幻象)。
如何保證兩次當(dāng)前讀返回一致的記錄,那就需要在第一次當(dāng)前讀與第二次當(dāng)前讀之間,其他的事務(wù)不會(huì)插入新的滿足條件的記錄并提交。為了實(shí)現(xiàn)這個(gè)功能,GAP鎖應(yīng)運(yùn)而生。
如圖中所示,有哪些位置可以插入新的滿足條件的項(xiàng) (id = 10),考慮到B+樹索引的有序性,滿足條件的項(xiàng)一定是連續(xù)存放的。記錄[6,c]之前,不會(huì)插入id=10的記錄;[6,c]與[10,b]間、[10,b]與[10,d]間、[10,d]與[11,f]間可以插入滿足條件的[10,e],[10,z]等;而[11,f]之后也不會(huì)插入滿足條件的記錄。
因此,為了保證[6,c]與[10,b]間,[10,b]與[10,d]間,[10,d]與[11,f]不會(huì)插入新的滿足條件的記錄,MySQL選擇了用GAP鎖,將這三個(gè)GAP給鎖起來。
Insert操作,如insert [10,aa],首先會(huì)定位到[6,c]與[10,b]間,然后在插入前,會(huì)檢查這個(gè)GAP是否已經(jīng)被鎖上,如果被鎖上,則Insert不能插入記錄。因此,通過第一遍的當(dāng)前讀,不僅將滿足條件的記錄鎖上 (X鎖),與組合三類似。同時(shí)還是增加3把GAP鎖,將可能插入滿足條件記錄的3個(gè)GAP給鎖上,保證后續(xù)的Insert不能插入新的id=10的記錄,也就杜絕了同一事務(wù)的第二次當(dāng)前讀,出現(xiàn)幻象的情況。
既然防止幻讀,需要靠GAP鎖的保護(hù),為什么組合五、組合六,也是RR隔離級(jí)別,卻不需要加GAP鎖呢?
GAP鎖的目的,是為了防止同一事務(wù)的兩次當(dāng)前讀,出現(xiàn)幻讀的情況。而組合五,id是主鍵;組合六,id是unique鍵,都能夠保證唯一性。一個(gè)等值查詢,最多只能返回一條記錄,而且新的相同取值的記錄,一定不會(huì)在新插入進(jìn)來,因此也就避免了GAP鎖的使用。
結(jié)論:Repeatable Read隔離級(jí)別下,id列上有一個(gè)非唯一索引,對(duì)應(yīng)SQL:delete from t1 where id = 10; 首先,通過id索引定位到第一條滿足查詢條件的記錄,加記錄上的X鎖,加GAP上的GAP鎖,然后加主鍵聚簇索引上的記錄X鎖,然后返回;然后讀取下一條,重復(fù)進(jìn)行。直至進(jìn)行到第一條不滿足條件的記錄[11,f],此時(shí),不需要加記錄X鎖,但是仍舊需要加GAP鎖,最后返回結(jié)束。
組合八:id無索引+RR
id列上沒有索引。此時(shí)SQL:delete from t1 where id = 10; 沒有其他的路徑可以選擇,只能進(jìn)行全表掃描。最終的加鎖情況,如下圖所示:

這是一個(gè)很恐怖的現(xiàn)象。首先,聚簇索引上的所有記錄,都被加上了X鎖。其次,聚簇索引每條記錄間的間隙(GAP),也同時(shí)被加上了GAP鎖。這個(gè)示例表,只有6條記錄,一共需要6個(gè)記錄鎖,7個(gè)GAP鎖。試想,如果表上有1000萬條記錄呢?
在這種情況下,這個(gè)表上,除了不加鎖的快照度,其他任何加鎖的并發(fā)SQL,均不能執(zhí)行,不能更新,不能刪除,不能插入,全表被鎖死。
當(dāng)然,跟組合四:[id無索引, Read Committed]類似,這個(gè)情況下,MySQL也做了一些優(yōu)化,就是所謂的semi-consistent read。semi-consistent read開啟的情況下,對(duì)于不滿足查詢條件的記錄,MySQL會(huì)提前放鎖。
結(jié)論:在Repeatable Read隔離級(jí)別下,如果進(jìn)行全表掃描的當(dāng)前讀,那么會(huì)鎖上表中的所有記錄,同時(shí)會(huì)鎖上聚簇索引內(nèi)的所有GAP,杜絕所有的并發(fā) 更新/刪除/插入 操作。當(dāng)然,也可以通過觸發(fā)semi-consistent read,來緩解加鎖開銷與并發(fā)影響,但是semi-consistent read本身可能會(huì)帶來其他問題。
組合九:Serializable
Serializable隔離級(jí)別。對(duì)于SQL2:delete from t1 where id = 10; 來說,Serializable隔離級(jí)別與Repeatable Read隔離級(jí)別完全一致
Serializable隔離級(jí)別,影響的是SQL1:select * from t1 where id = 10; 這條SQL,在RC,RR隔離級(jí)別下,都是快照讀,不加鎖。但是在Serializable隔離級(jí)別,SQL1會(huì)加讀鎖,也就是說快照讀不復(fù)存在,MVCC并發(fā)控制降級(jí)為Lock-Based CC。
MVCC--基于多版本的并發(fā)控制協(xié)議
最大的好處:讀不加鎖,讀寫不沖突。在讀多寫少的OLTP應(yīng)用中,讀寫不沖突是非常重要的,極大的增加了系統(tǒng)的并發(fā)性能,這也是為什么現(xiàn)階段,幾乎所有的RDBMS,都支持了MVCC
在MySQL/InnoDB中,所謂的讀不加鎖,并不適用于所有的情況,而是隔離級(jí)別相關(guān)的。Serializable隔離級(jí)別,讀不加鎖就不再成立,所有的讀操作,都是當(dāng)前讀。
轉(zhuǎn)載:http://hedengcheng.com/?p=771