更好的理解 MySQL(4):鎖
摘要
在這篇文章中,我將從上一篇的一個(gè)小例子開始,跟你介紹一下InnoDB中的行鎖。
在這里,會(huì)涉及到一個(gè)概念:兩階段加鎖協(xié)議。
之后,我會(huì)介紹行鎖中的S鎖和X鎖,以及這兩種鎖的作用。
但是我們會(huì)發(fā)現(xiàn)僅僅有行鎖是不能解決幻讀問題的,于是我會(huì)用例子的方式跟你介紹各種間隙鎖。
最后,我會(huì)聊一聊粒度更大的表級(jí)鎖和庫鎖。
1 行鎖
在上一篇的文章中,我們用了這個(gè)具體的例子來解釋MVCC:?

假設(shè)我們調(diào)換一下T5和T6:

此時(shí),T5是沒有辦法執(zhí)行的。
原因是這樣的:InnoDB在更新一行的時(shí)候,需要先獲取這一行的行鎖。
但是,當(dāng)一條語句獲取了行鎖之后,不是這行語句執(zhí)行完畢就能釋放鎖,而是要等到這個(gè)事務(wù)執(zhí)行完畢,才會(huì)釋放鎖。
這里涉及到了兩階段加鎖協(xié)議:它規(guī)定事務(wù)的加鎖和解鎖分為兩個(gè)獨(dú)立的階段,加鎖階段只能加鎖不能解鎖,一旦開始解鎖,則進(jìn)入解鎖階段,不能再加鎖。
然后我們再來說說共享鎖(S鎖,讀鎖)和排他鎖(X鎖,寫鎖)。
對(duì)于共享鎖來說,如果一個(gè)事務(wù)獲取了某一行的共享鎖,則這個(gè)事務(wù)只能讀這一行數(shù)據(jù),而不能修改,并且其他事務(wù)也可以獲取這一行數(shù)據(jù)的共享鎖,讀取這一行的數(shù)據(jù),同樣不能修改數(shù)據(jù)。
對(duì)于排它鎖,只能被某一個(gè)事務(wù)獲取。并且在獲取排它鎖之前,這一行數(shù)據(jù)上不能存在共享鎖。一旦某一個(gè)事務(wù)獲取了這一行的排它鎖,那么只有這一個(gè)事務(wù)可以對(duì)這一行數(shù)據(jù)進(jìn)行讀寫操作,其他事務(wù)對(duì)這一行數(shù)據(jù)的讀寫操作都會(huì)被阻塞。
此外,不僅僅只有更新操作,插入、刪除操作也會(huì)獲取這一行數(shù)據(jù)的X鎖。
在這里我還要再介紹這兩個(gè)概念:“快照讀”和“當(dāng)前讀”。
你可能還會(huì)有印象,在上一篇內(nèi)容中,我提到了所有的更新操作都必須是“當(dāng)前讀”,現(xiàn)在可以解釋原理了,在更新一行數(shù)據(jù)的時(shí)候,InnoDB會(huì)對(duì)需要更新的那行數(shù)據(jù)加上X鎖,直接獲取最新的那一行數(shù)據(jù)。
與之相對(duì)的是“快照讀”,也就是MVCC中的數(shù)據(jù)讀取方式,利用“快照”來讀取數(shù)據(jù)的方式,可以極大的提高事務(wù)的并發(fā)度。
但是并不是說select語句就只能讀取快照,它也照樣可以給需要讀取的數(shù)據(jù)加鎖,來讀取最新的數(shù)據(jù)。也就是說,select語句也一樣可以“當(dāng)前讀”。
下面這兩個(gè)select語句,就是分別加了讀鎖(S鎖,共享鎖)和寫鎖(X鎖,排他鎖)。
mysql>?select?k?from?t?where?id=1?lock?in?share?mode;
mysql>?select?k?from?t?where?id=1?for?update;
注意,由于兩階段加鎖協(xié)議的存在,如果你采用了一致性讀,那么這個(gè)鎖必須要等事務(wù)提交后才能解除。這是犧牲了并發(fā)度的一種做法。所以,如果所有的select語句,都加上了S鎖,此時(shí)的“可重復(fù)讀”,就變成了“序列化”。
2 間隙鎖
2.1 幻讀問題
還記得我們上面提到過的幻讀嗎?
現(xiàn)在你應(yīng)該能夠理解幻讀產(chǎn)生的原因了:因?yàn)樵诓迦霐?shù)據(jù)的時(shí)候,InnoDB采用的是當(dāng)前讀,而讀取數(shù)據(jù)的時(shí)候,由于MVCC的存在,采用的是快照讀,這就造成了幻讀。
但是我們在上面又提到了,select語句也一樣可以采用“當(dāng)前讀”。那么,這樣能解決幻讀嗎?
答案是能解決其中一種情況的幻讀。
比如我們在上一篇文章中舉的關(guān)于幻讀的例子:

現(xiàn)在你能理解了,因?yàn)檫@里的select是快照讀,而事務(wù)B的插入操作對(duì)于事務(wù)A來說是不可見的。如果在T5時(shí)刻,事務(wù)A的sql語句是select * from t where v = 0 for update,即采用當(dāng)前讀的話,是可以看得到事務(wù)B所提交的數(shù)據(jù)的,這樣的話,就避免了幻讀的情況。
那如果在T2時(shí)刻,事務(wù)A的語句就是select * from t where v = 0 for update會(huì)怎么樣的?
如果在T2時(shí)刻就使用了“當(dāng)前讀”,那么T3時(shí)刻事務(wù)B是無法進(jìn)行插入操作的。你可以理解為,T2時(shí)刻,InnoDB把v=0的數(shù)據(jù),都給加上了一把鎖。
因?yàn)檫@行sql語句把v=0的數(shù)據(jù)行都鎖住了,所以沒有辦法再插入一行v=0的數(shù)據(jù)。 ?
這聽起來似乎沒什么不對(duì)的,但是你仔細(xì)想一想,InnoDB中的行鎖,鎖住的是已經(jīng)存在的數(shù)據(jù)。而對(duì)于即將要插入的數(shù)據(jù),為什么也會(huì)被鎖住呢?這是不符合行鎖的定義的。
這個(gè)時(shí)候就可以說到間隙鎖了。
簡單來講,就是這條語句不僅會(huì)鎖住所查詢的那行數(shù)據(jù),還會(huì)把這行數(shù)據(jù)周圍的間隙鎖住,不讓其他事務(wù)插入。
也就是說,行鎖是鎖住已有的數(shù)據(jù),而間隙鎖,是鎖住即將要插入的位置,不讓其他數(shù)據(jù)插入。
在官方文檔有這么一句話:
Gap locking can be disabled explicitly. This occurs if you change the transaction isolation level to READ COMMITTED or enable the
innodb_locks_unsafe_for_binlogsystem variable (which is now deprecated).
也就是說,間隔鎖在“可重復(fù)讀”事務(wù)隔離級(jí)別是默認(rèn)生效的。所以,MySQL在“可重復(fù)讀”的事務(wù)隔離級(jí)別下,是有辦法解決幻讀問題的。
下面我們來看看哪些情況InnoDB會(huì)給數(shù)據(jù)加上間隔鎖,并且這里的間隔鎖范圍有多大,注意,下面列舉的四種情況,指的是where條件中的字段的索引類型。
主鍵索引
唯一普通索引
非唯一普通索引
無索引 ?
先定義這么一個(gè)表:
CREATE?TABLE?`t`?(
??`id`?int(11)?NOT?NULL,
??`a`?int(11)?DEFAULT?NULL,
??`b`?int(11)?DEFAULT?NULL,
??`c`?int(11)?DEFAULT?NULL,
??PRIMARY?KEY?(`id`),
??UNIQUE?KEY?`a`?(`a`),
??KEY?`b`?(`b`)
)?ENGINE=InnoDB;
id是主鍵,a是一個(gè)唯一索引,b是一個(gè)普通索引,c不包含任何的索引字段。
然后插入以下的這些數(shù)據(jù):
insert?into?t?values(0,0,0,0),(5,5,5,5),(10,10,10,10);
然后我們開始分析各種情況。
2.2 主鍵索引

因?yàn)闆]有其他的數(shù)據(jù),所以主鍵索引在數(shù)據(jù)頁內(nèi)的編排如上圖,并且含有4個(gè)空隙。這里說的“空隙”,指的是數(shù)據(jù)可以插入的位置。
比如我要插入一個(gè)id為3的數(shù)據(jù),這條數(shù)據(jù)就會(huì)插入到位于(0,5)這個(gè)空隙內(nèi)。
下面我們開始嘗試:

毫無疑問T3時(shí)刻的sql語句是會(huì)被阻塞的,原因是id = 5的這行數(shù)據(jù)已經(jīng)被加鎖了。那么,會(huì)不會(huì)存在有間隙鎖呢?
因?yàn)檫@是一個(gè)主鍵索引,InnoDB必須保證id = 5的數(shù)據(jù)是唯一的,所以對(duì)于id=5的周圍,比如(0,5)和(5,10),不需要再加間隙鎖了。

那么換一個(gè)條件再試試,我們查找id大于6且id小于8的數(shù)據(jù),此時(shí)事務(wù)B中的語句同樣會(huì)被阻塞。
這是因?yàn)椋谥麈I索引沒有命中的時(shí)候,會(huì)對(duì)所在的空白范圍,全部加鎖。注意,我這里說的是未命中的所有空白范圍,哪怕我這里的查找條件是大于6且小于8,但是加鎖的范圍不是(6,8),而是(5,10)。
你可以簡單的理解為:從查找條件的最小值開始,往前找到第一個(gè)索引值;并且從查找條件的最大值開始,往后找到第一個(gè)索引值,這個(gè)范圍就是加鎖的范圍。
你可能還會(huì)有一個(gè)疑問,如果是select * from t where id = 8 for update會(huì)怎么樣呢?這個(gè)問題和上面一樣,只要未命中,就加范圍鎖,鎖住空隙(5,10)。
總結(jié)一下:對(duì)于主鍵索引來說,命中了,就只加行鎖;沒命中,則對(duì)查找范圍的最小值往前找第一個(gè)主鍵,查找范圍的最大值往后找第一個(gè)主鍵,并對(duì)這個(gè)范圍加上間隙鎖。
2.3 唯一索引

對(duì)于唯一索引來說,和主鍵索引其實(shí)是差不多的。當(dāng)索引命中之后,因?yàn)槲ㄒ凰饕瑯颖WC了索引的唯一性,所以不需要給這行數(shù)據(jù)的周圍加上間隙鎖,只會(huì)給命中的數(shù)據(jù)加鎖。
但是這里和主鍵索引不同的地方是,在給唯一索引a = 5加鎖的同時(shí),還會(huì)回表,將a = 5對(duì)應(yīng)的主鍵id = 5這行記錄加鎖。所以,事務(wù)B的修改也同樣會(huì)被阻塞。
這也是為了防止造成數(shù)據(jù)不一致的情況,比如我把a = 5的這行數(shù)據(jù)刪了,然后事務(wù)B又通過這行數(shù)據(jù)的主鍵來對(duì)這行數(shù)據(jù)進(jìn)行操作。
對(duì)于帶有范圍的查找,和上面主鍵索引的間隙鎖規(guī)則是一樣的,這里不再贅述。值得注意的是,在唯一索引中,只要命中了,就會(huì)相應(yīng)的給這條索引對(duì)應(yīng)的主鍵id也加鎖。
還需要補(bǔ)充一點(diǎn),當(dāng)主鍵索引和唯一索引直接命中的時(shí)候,如下圖所示,InnoDB除了給a = 5這行數(shù)據(jù)加了行鎖,還可能給(5, 5)這個(gè)間隙加了間隙鎖,這樣的說法聽起來很奇怪。

因?yàn)槭聞?wù)A是給a = 5這行數(shù)據(jù)加了行鎖,而行鎖只能針對(duì)已經(jīng)存在的數(shù)據(jù),不能加到即將插入的數(shù)據(jù)上;此外,當(dāng)事務(wù)A執(zhí)行這條語句的時(shí)候,事務(wù)B是會(huì)被阻塞的。直到事務(wù)A提交,事務(wù)B才會(huì)提示唯一索引重復(fù)。也就是說,在事務(wù)B執(zhí)行這行語句的時(shí)候,是無法訪問id = 5這行數(shù)據(jù)的,事務(wù)B不知道id = 5到底存不存在。
所以我才說:當(dāng)索引直接命中的時(shí)候,還會(huì)加上這么一個(gè)小小的間隙鎖。我沒有查到這方面的資料,如果你能解釋的話,請留言告訴我。
2.4 普通索引
對(duì)于普通索引來說,與唯一索引最大的區(qū)別,就是普通索引不是必須唯一的,也就是說,當(dāng)插入數(shù)據(jù)的時(shí)候,可能會(huì)有重復(fù)的情況。
而在上面的內(nèi)容中我們也發(fā)現(xiàn)了一個(gè)規(guī)律:InnoDB的間隙鎖,就是為了防止新插入的數(shù)據(jù)影響查找結(jié)果。
所以對(duì)于普通索引來說,還需要防止新插入的數(shù)據(jù)和原數(shù)據(jù)一樣的情況(因?yàn)槲ㄒ凰饕恍枰獡?dān)心這么一種情況)。
下面我們舉例說明,在此之前先插入一行數(shù)據(jù):
?insert?into?t?values(8,8,5,8);
那么此時(shí)我們的索引b,是這樣的:

因?yàn)槭?strong style="font-size:inherit;color:inherit;">非唯一索引的原因,在兩個(gè)b = 5的間隙,也能插入數(shù)據(jù)。

如圖所示,我們這次把查找條件換成了b = 5。此時(shí),我們插入的數(shù)據(jù)id = 1,理論上應(yīng)該要插入(0,5)這個(gè)間隙內(nèi),但是由于間隙鎖的存在,插入將被阻塞。
換一句話說,只要此時(shí)插入的數(shù)據(jù)b = 5,那么就一定無法插入。
而對(duì)于未命中的條件,規(guī)則和上文中說到的一樣,根據(jù)查找條件的最小值往前找到第一個(gè)一個(gè)索引,再根據(jù)這個(gè)條件的最大值往后找到第一個(gè)索引,構(gòu)成間隙鎖的范圍。
此外,與唯一索引一樣,所有命中的數(shù)據(jù)行,都會(huì)回表將主鍵id也鎖住。
2.5 無索引

可以看到,我們的查找條件是c = 5,直接命中了數(shù)據(jù)。此時(shí)我們插入的數(shù)據(jù)是c = 6,看起來和事務(wù)A無關(guān),但是出乎意料的是,事務(wù)B還是會(huì)被阻塞。
直接說結(jié)論:對(duì)于不含有索引的查找項(xiàng)來說,會(huì)鎖住所有的間隙和所有的數(shù)據(jù)。
關(guān)于幻讀的問題的一些case,到這里就研究完了(但是我不確定有沒有遺漏,如果有,還請你留言告訴我)。
在最后還需要說一個(gè)概念,行鎖與間隔鎖,合稱next-key lock。并且需要注意的是,只有在可重復(fù)讀的事務(wù)隔離級(jí)別中,才會(huì)有間隔鎖。并且可重復(fù)讀是遵循兩階段鎖協(xié)議,所有加鎖的資源,都是在事務(wù)提交或者回滾的時(shí)候才釋放的。所以,在防止幻讀產(chǎn)生的時(shí)候,同樣降低了并發(fā)度。
3 表級(jí)鎖
在上一節(jié)說完了行級(jí)鎖之后,我們再來聊聊表級(jí)鎖。
表級(jí)鎖有兩種,一種是顯式添加的,一種是隱式添加的。
3.1 讀寫表鎖
還記得我們在上文中提到的讀鎖和寫鎖的特點(diǎn)嗎,這點(diǎn)在表鎖中是一樣的。
給表加上了寫鎖,意味著只有這個(gè)會(huì)話擁有讀寫這個(gè)表的權(quán)限;給表加上了讀鎖,才能讀取這個(gè)表上的數(shù)據(jù),并且可以多個(gè)線程共享讀鎖,但是,只有當(dāng)某個(gè)表上沒有讀鎖時(shí),才能給這個(gè)表加上寫鎖。
下面是給表加鎖的語法:
lock?tables?table_name?read??
lock?tables?table_name?write
3.2 MDL
MDL指的是(Metadata Lock),指的是元數(shù)據(jù)鎖。
MDL也分為了讀鎖和寫鎖,功能和上面提到的一樣。
只不過MDL不需要像表鎖那樣顯式的使用,它會(huì)在訪問一個(gè)表的時(shí)候會(huì)被自動(dòng)加上。其中,在某個(gè)表對(duì)數(shù)據(jù)進(jìn)行操作(包括insert,delete,update,select)的時(shí)候,會(huì)隱式的加上MDL讀鎖,在修改表的結(jié)構(gòu)的時(shí)候,會(huì)加上寫鎖。
這樣做的目的是,防止在一個(gè)事務(wù)操作數(shù)據(jù)的時(shí)候,表結(jié)構(gòu)被另一個(gè)事務(wù)給修改了?;蛘咴谀骋粋€(gè)事務(wù)修改表結(jié)構(gòu)的時(shí)候,不允許其他的事務(wù)操作數(shù)據(jù)。
4 庫鎖
顧名思義,庫鎖就是對(duì)整個(gè)數(shù)據(jù)庫實(shí)例加鎖。
MySQL提供了一個(gè)加全局讀鎖的方法,命令是Flush tables with read lock (FTWRL)。
使用過這個(gè)命令之后,相當(dāng)于對(duì)全庫增加了一個(gè)讀鎖,此時(shí)其他線程的數(shù)據(jù)更新語句(數(shù)據(jù)的增刪改)、數(shù)據(jù)定義語句(包括建表、修改表結(jié)構(gòu)等)和更新類事務(wù)的提交語句都會(huì)被阻塞。
全局鎖的典型使用場景是,做全庫邏輯備份。當(dāng)然了,實(shí)現(xiàn)這個(gè)功能,我們也可以使用“可重復(fù)讀”的事務(wù)隔離級(jí)別,做一次快照讀,依然可以實(shí)現(xiàn)備份的功能。只不過,有些引擎并沒有實(shí)現(xiàn)這個(gè)事務(wù)隔離級(jí)別。
寫在最后
首先,謝謝你能看到這里。
在這篇文章中,尤其是間隙鎖部分的內(nèi)容,我沒有查到太多的資料,所以很多內(nèi)容都是我自己的理解。所以如果你發(fā)現(xiàn)了一些bad case,請你留言告訴我。又或者你發(fā)現(xiàn)了我哪里的理解是不對(duì)的,也請你留言告訴我,謝謝!
當(dāng)然了,如果有哪里是我講的不夠明白的,也歡迎留言交流~
推薦閱讀
喜歡本文的朋友,歡迎關(guān)注“Go語言中文網(wǎng)”:
Go語言中文網(wǎng)啟用微信學(xué)習(xí)交流群,歡迎加微信:274768166,投稿亦歡迎
