面試官:為什么MySQL的行鎖有時(shí)會(huì)升級(jí)為表鎖?
今天我們來(lái)深入探討一下MySQL中的鎖機(jī)制。在數(shù)據(jù)庫(kù)相關(guān)的面試中,鎖無(wú)疑是偏向高階且內(nèi)容較為零散的一類問(wèn)題。但它的重要性不言而喻,比如在實(shí)際工作中,一次突發(fā)的死鎖就可能嚴(yán)重影響線上服務(wù)的性能。這就要求我們作為后端工程師,必須對(duì)鎖有扎實(shí)的理解。并且,鎖的底層原理與索引、事務(wù)隔離級(jí)別息息相關(guān),這三個(gè)知識(shí)點(diǎn)常常在面試中被串聯(lián)起來(lái)提問(wèn),無(wú)論從哪個(gè)點(diǎn)切入,都很容易延伸到另外兩個(gè)。
因此,一句話總結(jié)鎖的特點(diǎn)就是:既是難點(diǎn),又是重點(diǎn),還是熱點(diǎn)。在這篇文章中,我將帶你徹底理清MySQL鎖的脈絡(luò),并告訴你如何在面試中將它轉(zhuǎn)化為你的核心亮點(diǎn)。
1. 鎖的核心知識(shí)回顧
1.1 鎖與索引的關(guān)系
在MySQL的InnoDB存儲(chǔ)引擎里,你必須牢記一個(gè)核心原則:鎖是借助索引來(lái)實(shí)現(xiàn)的。更具體地說(shuō),加鎖操作鎖住的并非數(shù)據(jù)行本身,而是數(shù)據(jù)行對(duì)應(yīng)的索引項(xiàng)。從B+樹(shù)的結(jié)構(gòu)來(lái)看,最終鎖定的就是葉子節(jié)點(diǎn)上的索引記錄。

只要你從這個(gè)視角出發(fā),就能理解絕大多數(shù)與鎖相關(guān)的、看似千奇百怪的問(wèn)題。
一個(gè)表可能存在多個(gè)索引,那么加鎖時(shí)究竟會(huì)鎖定哪個(gè)索引呢?答案是:查詢優(yōu)化器最終選擇使用的那個(gè)索引。那么,如果一條查詢語(yǔ)句因?yàn)闆](méi)有合適的索引而走了全表掃描呢?在這種情況下,InnoDB無(wú)法在索引上實(shí)現(xiàn)行級(jí)別的鎖定,只能退而求其次,將鎖的粒度從“行”上升到“表”,也就是我們常說(shuō)的表鎖。
再來(lái)看一個(gè)有趣的場(chǎng)景:如果查詢條件所指定的值在數(shù)據(jù)庫(kù)中根本不存在,例如:
-- 嘗試鎖定一個(gè)不存在的記錄
SELECT * FROM your_tab WHERE id = 150 FOR UPDATE;此時(shí)id=150這條記錄不存在,鎖會(huì)如何作用?InnoDB引擎會(huì)利用B+樹(shù)索引的有序性,找到與150相鄰的索引記錄(例如120和170),并在這兩條記錄之間構(gòu)建一個(gè)**臨鍵鎖 (Next-Key Lock)**,其鎖定的范圍是(120, 170]。

這個(gè)臨鍵鎖的作用就是防止其他事務(wù)向這個(gè)“間隙”中插入id=150的新記錄。
那么范圍查詢呢?其原理也是類似的。InnoDB會(huì)利用索引數(shù)據(jù),構(gòu)造一個(gè)剛好能覆蓋整個(gè)查詢范圍的臨鍵鎖。例如:
-- 對(duì)id大于330的記錄進(jìn)行范圍加鎖
SELECT * FROM your_tab WHERE id > 330 FOR UPDATE;對(duì)于這條語(yǔ)句,InnoDB會(huì)構(gòu)建一個(gè)覆蓋范圍為(330, supremum]的臨鍵鎖。這里的supremum你可以直觀地理解為InnoDB內(nèi)部定義的一個(gè)虛擬的最大值,代表正無(wú)窮。

綜上所述,我們得出一個(gè)至關(guān)重要的結(jié)論:鎖與索引密不可分,鎖的粒度和效率直接取決于索引的使用情況。
1.2 鎖的釋放時(shí)機(jī)
很多開(kāi)發(fā)者在學(xué)習(xí)鎖時(shí)會(huì)陷入一個(gè)誤區(qū),認(rèn)為鎖在SQL語(yǔ)句執(zhí)行完畢后就會(huì)立刻被釋放。這是一個(gè)錯(cuò)誤的觀念。實(shí)際上,InnoDB中的鎖是在整個(gè)事務(wù)結(jié)束時(shí)才會(huì)被釋放的。無(wú)論你是執(zhí)行COMMIT提交事務(wù),還是ROLLBACK回滾事務(wù),事務(wù)內(nèi)所有持有的鎖都會(huì)在那個(gè)時(shí)刻被統(tǒng)一釋放。

換言之,一個(gè)事務(wù)一旦給某份數(shù)據(jù)加上了鎖,這把鎖就會(huì)被一直持有,直到事務(wù)的終點(diǎn)。
1.3 樂(lè)觀鎖與悲觀鎖
樂(lè)觀鎖和悲觀鎖并非物理上真實(shí)存在的鎖,而是兩種并發(fā)控制的邏輯思想和設(shè)計(jì)哲學(xué)。
- 悲觀鎖:它總是假設(shè)最壞的情況,認(rèn)為數(shù)據(jù)在任何時(shí)候都可能被其他事務(wù)修改。因此,在操作數(shù)據(jù)之前,它會(huì)先將數(shù)據(jù)鎖定,以阻止任何可能的并發(fā)修改。
SELECT ... FOR UPDATE就是悲觀鎖最典型的實(shí)現(xiàn)。 - 樂(lè)觀鎖:它則持有非常“樂(lè)觀”的態(tài)度,假設(shè)在自己操作數(shù)據(jù)的這段時(shí)間里,別人不會(huì)來(lái)修改它。因此,它在操作數(shù)據(jù)時(shí)不會(huì)加鎖,而是在最后提交更新的時(shí)候,去檢查一下數(shù)據(jù)是否被修改過(guò)。
在數(shù)據(jù)庫(kù)中,樂(lè)觀鎖通常指利用類似CAS(Compare-And-Swap,比較并交換)的思路去更新數(shù)據(jù)。其一般的使用形態(tài)如下:
-- 1. 先查詢出數(shù)據(jù),獲取當(dāng)前值(比如 a=1)
SELECT * FROM your_tab WHERE id=1;
-- ... 在應(yīng)用層執(zhí)行一系列復(fù)雜的業(yè)務(wù)邏輯 ...
-- 2. 更新時(shí),將之前查詢到的值作為WHERE條件的一部分
UPDATE your_tab SET a=3, b=4 WHERE id=1 AND a=1;在上述UPDATE語(yǔ)句中,我們預(yù)期數(shù)據(jù)庫(kù)中a的值仍然是我們最初讀到的1。如果在我們進(jìn)行業(yè)務(wù)操作期間,a的值被其他事務(wù)修改了,那么這條UPDATE語(yǔ)句的WHERE條件將不成立,更新操作就會(huì)失敗(影響行數(shù)為0)。業(yè)務(wù)方通過(guò)判斷受影響的行數(shù),就能得知此次并發(fā)更新是否成功。

而悲觀鎖的實(shí)現(xiàn)則更為直接,它從一開(kāi)始就鎖定了資源:
-- 1. 在查詢時(shí)就直接加上排它鎖
SELECT * FROM your_tab WHERE id=1 FOR UPDATE;
-- ... 在應(yīng)用層執(zhí)行一系列復(fù)雜的業(yè)務(wù)邏輯 ...
-- 2. 直接更新數(shù)據(jù),因?yàn)閿?shù)據(jù)已被鎖定,無(wú)需擔(dān)心被修改
UPDATE your_tab SET a=3, b=4 WHERE id=1;在選擇這兩種鎖時(shí),需要權(quán)衡數(shù)據(jù)一致性與并發(fā)性能。樂(lè)觀鎖更適用于讀多寫少的場(chǎng)景,這也是絕大多數(shù)互聯(lián)網(wǎng)應(yīng)用的特征,它的性能開(kāi)銷更小。而悲觀鎖則適用于寫多讀少或?qū)?shù)據(jù)一致性要求極高的場(chǎng)景,例如金融領(lǐng)域?qū)~戶金額的操作,通常以寫操作為主。
相比之下,樂(lè)觀鎖能提供更好的并發(fā)性能。不過(guò),由于其實(shí)現(xiàn)邏輯需要應(yīng)用層配合,寫起來(lái)相對(duì)復(fù)雜,所以很多開(kāi)發(fā)者為了圖省事,會(huì)傾向于直接使用悲觀鎖。
1.4 行鎖與表鎖
行鎖與表鎖是根據(jù)鎖定的范圍(粒度)來(lái)劃分的。顧名思義,行鎖鎖住的是數(shù)據(jù)行,可能是一行,也可能是多行。而表鎖則直接將整張數(shù)據(jù)表鎖定。
在MySQL中,InnoDB存儲(chǔ)引擎同時(shí)支持行鎖和表鎖。但必須再次強(qiáng)調(diào),行鎖的實(shí)現(xiàn)強(qiáng)依賴于索引。如果你的查詢無(wú)法命中任何索引,導(dǎo)致全表掃描,那么InnoDB將無(wú)法使用行鎖,只能退而求其次,使用表鎖。當(dāng)然,如果你使用的是一些較老的存儲(chǔ)引擎,如MyISAM,那么它從設(shè)計(jì)上就只支持表鎖。
1.5 共享鎖與排它鎖
共享鎖和排它鎖是從互斥性的角度來(lái)看待鎖的。
- 共享鎖 (Shared Lock, S鎖):也稱讀鎖。一個(gè)事務(wù)對(duì)某行數(shù)據(jù)加上S鎖后,其他事務(wù)仍然可以繼續(xù)對(duì)該行加S鎖,但不能加排它鎖(X鎖)。它允許多個(gè)事務(wù)同時(shí)讀取同一份數(shù)據(jù)。
- 排它鎖 (Exclusive Lock, X鎖):也稱寫鎖。一個(gè)事務(wù)對(duì)某行數(shù)據(jù)加上X鎖后,其他任何事務(wù)都不能再對(duì)該行加任何類型的鎖(無(wú)論是S鎖還是X鎖),直到持有鎖的事務(wù)釋放。它保證了在任何時(shí)刻,只有一個(gè)事務(wù)能修改數(shù)據(jù)。
它們的兼容關(guān)系如下表所示:
類型 | 共享鎖 | 排它鎖 |
共享鎖 | 兼容 | 不兼容 |
排它鎖 | 不兼容 | 不兼容 |
1.6 意向鎖
意向鎖是一種表級(jí)鎖,但它本身并不真正鎖定數(shù)據(jù),更像是一個(gè)“信號(hào)”或“標(biāo)志”。它的核心作用是告訴其他事務(wù):“我(某個(gè)事務(wù))正準(zhǔn)備或已經(jīng)對(duì)這個(gè)表里的某些行加鎖了”。
意向鎖與共享鎖、排它鎖結(jié)合,就產(chǎn)生了兩種意向鎖:
- 意向共享鎖 (Intention Shared Lock, IS鎖):表示一個(gè)事務(wù)希望獲得表中某些行的共享鎖。
- 意向排它鎖 (Intention Exclusive Lock, IX鎖):表示一個(gè)事務(wù)希望獲得表中某些行的排它鎖。
注意,“意向”強(qiáng)調(diào)的是一種打算,但最終能否成功獲得行鎖是不確定的。
在MySQL中,使用意向鎖的典型場(chǎng)景是協(xié)調(diào)行鎖和表鎖的關(guān)系。當(dāng)一個(gè)事務(wù)需要對(duì)表中的行加S鎖或X鎖時(shí),它會(huì)先在表級(jí)別加上IS鎖或IX鎖。之后,如果另一個(gè)事務(wù)想要獲取整個(gè)表的表鎖(例如LOCK TABLES ... WRITE),它就無(wú)需逐行檢查是否有行鎖存在,只需檢查表級(jí)別是否存在意向鎖即可。這極大地提高了數(shù)據(jù)庫(kù)的并發(fā)性能,并避免了不必要的死鎖。這也是為什么在修改表結(jié)構(gòu)(DDL)時(shí),會(huì)阻塞所有增刪改查(DML)語(yǔ)句的原因。
1.7 記錄鎖、間隙鎖和臨鍵鎖:深度剖析
這是面試中最能檢驗(yàn)候選人技術(shù)深度的部分,也是最容易混淆的概念。反過(guò)來(lái)說(shuō),如果你能將這部分的細(xì)節(jié)闡述清晰,本身就是一個(gè)非常大的亮點(diǎn)。
1.7.1 記錄鎖 (Record Lock)
記錄鎖非常直觀,它鎖定的是某一條特定的索引記錄。通常,當(dāng)你的查詢條件是等值查詢,并且精準(zhǔn)地命中了主鍵或唯一索引時(shí),InnoDB就會(huì)使用記錄鎖。
-- 假設(shè)id是主鍵,這條語(yǔ)句會(huì)對(duì)id=310這一條記錄加上記錄鎖
SELECT * FROM your_tab WHERE id = 310 FOR UPDATE;
如果查詢條件作用在唯一索引上,且能命中一條記錄,效果是完全一樣的。假設(shè)email字段有唯一索引,這條語(yǔ)句也會(huì)使用記錄鎖
-- 假設(shè)email字段有唯一索引,這條語(yǔ)句也會(huì)使用記錄鎖
SELECT * FROM your_tab WHERE email='your_email' FOR UPDATE;但是,如果等值查詢沒(méi)有命中任何記錄,那么就不會(huì)使用記錄鎖,而是會(huì)使用間隙鎖。比如數(shù)據(jù)庫(kù)中只有 id 為(10,40,70)的三條記錄,也就是 id= 30 這個(gè)條件沒(méi)有命中任何數(shù)據(jù),那么這條語(yǔ)句會(huì)在(10,40)加上間隙鎖,注意,這里是對(duì)一個(gè)左開(kāi)右開(kāi)的區(qū)間加鎖。可以看到, 即便是是對(duì)字段加了唯一索引,但是如果查詢沒(méi)有命中索引,對(duì)性能影響也是很大的。
1.7.2 間隙鎖 (Gap Lock)
間隙鎖鎖定的是一個(gè)開(kāi)區(qū)間范圍,即兩條索引記錄之間的“空隙”,但它不包括記錄本身。它的主要作用是在可重復(fù)讀隔離級(jí)別下,防止其他事務(wù)在這個(gè)“間隙”中插入新的記錄,從而避免“幻讀”問(wèn)題。通常,范圍查詢(如<、<=、BETWEEN)會(huì)使用到間隙鎖。
-- 這條語(yǔ)句會(huì)鎖住(500, 1000)這個(gè)開(kāi)區(qū)間
SELECT * FROM your_tab WHERE id BETWEEN 500 AND 1000 FOR UPDATE;間隙鎖會(huì)鎖住(500, 1000)之間的數(shù)據(jù),而500和1000這兩條記錄本身則會(huì)被記錄鎖鎖住。如果你的表中沒(méi)有id=500的記錄,數(shù)據(jù)庫(kù)會(huì)一直向左找到第一個(gè)存在的數(shù)據(jù)(比如400);如果沒(méi)有id=100的記錄,則會(huì)一直向右找到第一個(gè)存在的數(shù)據(jù)(比如1200)。那么此時(shí)使用的間隙鎖范圍就是(400, 1200)。在這種情況下,如果有人想插入一條主鍵為700的行,操作就會(huì)被阻塞。
一個(gè)非常重要的特性是:間隙鎖和間隙鎖之間是不排它的。也就是說(shuō),兩個(gè)不同的事務(wù),即使它們申請(qǐng)的間隙鎖范圍有重疊,也可以同時(shí)加鎖成功。

1.7.3 臨鍵鎖 (Next-Key Lock)
臨鍵鎖是InnoDB在可重復(fù)讀隔離級(jí)別下默認(rèn)的鎖定算法。從直觀上,你可以把它看作是一個(gè)記錄鎖和一個(gè)間隙鎖的組合。也就是說(shuō),臨鍵鎖不僅會(huì)用記錄鎖鎖住命中的記錄,還會(huì)用間隙鎖鎖住該記錄之前的那個(gè)空隙。臨鍵鎖的鎖定區(qū)間是一個(gè)左開(kāi)右閉的區(qū)間。
還是用前面的例子,如果索引中只有(10, 40, 70)三條記錄,那么一個(gè)臨鍵鎖就可以將(10, 40]這個(gè)區(qū)間鎖住。臨鍵鎖與數(shù)據(jù)庫(kù)的隔離級(jí)別聯(lián)系最為緊密,它正是解決幻讀問(wèn)題的關(guān)鍵所在。
1.7.4 加鎖規(guī)則總結(jié)
為了更清晰地理解這三種鎖的適用場(chǎng)景,我們可以將其總結(jié)為一套判斷法則。這套法則的核心在于理解臨鍵鎖作為默認(rèn)行為,以及它在特定條件下的“退化”現(xiàn)象。
- 基礎(chǔ)原則:默認(rèn)使用臨鍵鎖 在可重復(fù)讀(RR)隔離級(jí)別下,臨鍵鎖是InnoDB的基石和默認(rèn)選擇。無(wú)論是等值查詢還是范圍查詢,InnoDB首先傾向于使用臨鍵鎖來(lái)鎖定相關(guān)記錄及其前方的間隙,以此來(lái)杜絕幻讀的可能。
- 優(yōu)化特例:退化為記錄鎖 當(dāng)查詢能夠?qū)崿F(xiàn)最精準(zhǔn)的定位時(shí),臨鍵鎖會(huì)進(jìn)行優(yōu)化,退化為粒度更細(xì)的記錄鎖。這個(gè)特例的觸發(fā)條件非常嚴(yán)格:
a.查詢條件必須是等值查詢(如 id = ? 或 email = ?)。
b.查詢的列必須是主鍵或唯一索引。
c.查詢結(jié)果必須精確命中一條存在的記錄。 只有同時(shí)滿足這三個(gè)條件,InnoDB才會(huì)放棄對(duì)間隙的鎖定,只對(duì)該條記錄加記錄鎖。
- 另一特例:退化為間隙鎖 在某些情況下,臨鍵鎖中“鎖定記錄”的部分會(huì)消失,使其退化為純粹的間隙鎖。這通常發(fā)生在:
a.查詢的范圍不包含任何存在的記錄,例如 WHERE id > 100(假設(shè)100是最大ID)。
b.查詢一個(gè)在索引中不存在的值,例如 WHERE id = 15(假設(shè)12和17是相鄰記錄)。 在這些場(chǎng)景下,由于沒(méi)有具體的“記錄”可鎖,鎖定的重點(diǎn)就完全落在了防止新數(shù)據(jù)插入的“間隙”上。

需要注意的是,對(duì)于普通索引,即使是等值查詢,因?yàn)樗饕悼赡苤貜?fù),InnoDB為了防止幻讀,仍然會(huì)使用臨鍵鎖,而不是退化為記錄鎖。
2. 面試實(shí)戰(zhàn)指南
2.1 面試案例準(zhǔn)備
要在面試中脫穎而出,僅有理論知識(shí)是遠(yuǎn)遠(yuǎn)不夠的。面試官更看重你解決實(shí)際問(wèn)題的能力。因此,你需要主動(dòng)構(gòu)建一個(gè)屬于自己的實(shí)戰(zhàn)案例庫(kù)。
- 復(fù)盤線上問(wèn)題:主動(dòng)去了解和復(fù)盤公司發(fā)生過(guò)的與鎖相關(guān)的線上故障,特別是那些導(dǎo)致服務(wù)抖動(dòng)或中斷的死鎖案例。你需要搞清楚問(wèn)題的全貌:它是在什么業(yè)務(wù)場(chǎng)景下觸發(fā)的?排查過(guò)程是怎樣的?最終是如何解決的?有沒(méi)有留下什么隱患?
- 挖掘性能瓶頸:審視你負(fù)責(zé)的業(yè)務(wù),思考是否存在因?yàn)殒i使用不當(dāng)而導(dǎo)致的性能問(wèn)題。比如,某個(gè)核心接口的響應(yīng)時(shí)間(RT)很高,有沒(méi)有可能是因?yàn)槟硞€(gè)事務(wù)持有了過(guò)大的鎖(如表鎖)或者鎖的持有時(shí)間過(guò)長(zhǎng)?
- 解構(gòu)鎖的應(yīng)用模式:在公司的代碼庫(kù)中,主動(dòng)去尋找和分析樂(lè)觀鎖與悲觀鎖的實(shí)際應(yīng)用。對(duì)于樂(lè)觀鎖,它的CAS操作具體是如何通過(guò)SQL實(shí)現(xiàn)的?版本號(hào)是如何管理的?對(duì)于悲觀鎖,
SELECT ... FOR UPDATE用在哪些關(guān)鍵業(yè)務(wù)上?這些用法是否合理?有沒(méi)有優(yōu)化的空間?
通過(guò)這種方式,將別人的經(jīng)驗(yàn)和線上的教訓(xùn)內(nèi)化為自己的知識(shí),你才能在面試中言之有物。同時(shí),你需要刻意訓(xùn)練自己分析SQL加鎖行為的能力,拿到一條SQL和表結(jié)構(gòu),就能大致推斷出它在特定隔離級(jí)別下會(huì)加什么鎖,影響范圍有多大,這會(huì)讓你顯得非常專業(yè)。
2.2 面試應(yīng)答技巧
當(dāng)面試官拋出“談?wù)勀銓?duì)MySQL鎖的理解”這類宏觀問(wèn)題時(shí),你的回答需要有層次感,先搭建框架,再填充細(xì)節(jié)。
你可以這樣開(kāi)場(chǎng),先給出一個(gè)全局視圖:
“好的。對(duì)于MySQL的鎖,我習(xí)慣從幾個(gè)不同的維度去理解它。首先,從鎖定粒度上,有我們熟知的行鎖和表鎖。其次,從鎖的兼容性上,可以分為共享鎖(S鎖)和排它鎖(X鎖),這決定了它們是否可以共存。
在這兩個(gè)基礎(chǔ)維度之上,InnoDB為了提升性能,還引入了意向鎖,它是一種表級(jí)鎖,用來(lái)預(yù)示事務(wù)接下來(lái)想要加的行鎖類型。
而在InnoDB的行鎖實(shí)現(xiàn)中,又有三種具體的鎖算法,這也是面試中的重點(diǎn):記錄鎖、間隙鎖和臨鍵鎖。它們是InnoDB在RR級(jí)別下解決幻讀問(wèn)題的核心。
最后,從編程思想的層面,我們還有樂(lè)觀鎖和悲觀鎖的概念,它們是我們?cè)趹?yīng)用層進(jìn)行并發(fā)控制的兩種不同策略。”
在構(gòu)建了整體認(rèn)知框架后,再拋出鎖機(jī)制的兩個(gè)核心支柱:
“要深入理解InnoDB的鎖,關(guān)鍵在于抓住兩個(gè)核心:索引和事務(wù)隔離級(jí)別。InnoDB的行鎖是構(gòu)建在索引之上的,沒(méi)有索引,行鎖就無(wú)從談起,只能退化為表鎖。而像臨鍵鎖和間隙鎖這類精細(xì)的鎖定策略,其主要舞臺(tái)是在可重復(fù)讀(RR)這個(gè)隔離級(jí)別下,其根本目的是為了解決幻讀問(wèn)題。”
這樣的回答方式,先展現(xiàn)了你知識(shí)的廣度和體系性,然后點(diǎn)出了問(wèn)題的本質(zhì),自然會(huì)引導(dǎo)面試官對(duì)你感興趣的深層部分進(jìn)行追問(wèn),比如:
- “你剛才提到的臨鍵鎖,能詳細(xì)講講它的工作原理和使用場(chǎng)景嗎?”
- “既然RR級(jí)別有臨鍵鎖可以防止幻讀,那它在什么極端情況下還會(huì)出現(xiàn)幻讀呢?”
- “你在項(xiàng)目中是如何選擇使用樂(lè)觀鎖還是悲觀鎖的?能舉個(gè)例子嗎?”
- “聽(tīng)起來(lái)你對(duì)鎖研究得比較深,有沒(méi)有處理過(guò)線上死鎖問(wèn)題的經(jīng)驗(yàn)?”
當(dāng)面試官開(kāi)始追問(wèn)這些細(xì)節(jié)時(shí),你就成功地將面試的主動(dòng)權(quán)掌握在了自己手中,接下來(lái)就是你展示項(xiàng)目經(jīng)驗(yàn)和技術(shù)深度的最佳時(shí)機(jī)。
2.3 面試亮點(diǎn)方案
2.3.1 基礎(chǔ)優(yōu)化:為高頻查詢添加索引
這是一個(gè)基礎(chǔ)卻非常有效的優(yōu)化,你可以把它作為一個(gè)“排查線上性能抖動(dòng)”的故事來(lái)講述。
“我曾經(jīng)處理過(guò)一個(gè)線上問(wèn)題,某個(gè)核心服務(wù)的性能會(huì)周期性地出現(xiàn)毛刺,響應(yīng)時(shí)間突然飆升。經(jīng)過(guò)一系列排查,我們定位到瓶頸在于數(shù)據(jù)庫(kù)。但奇怪的是,通過(guò)慢查詢?nèi)罩咀サ降腟QL,單獨(dú)執(zhí)行時(shí)速度很快,并且
EXPLAIN分析也顯示它命中了索引。
后來(lái),我們通過(guò)
SHOW PROCESSLIST和數(shù)據(jù)庫(kù)監(jiān)控,才發(fā)現(xiàn)在性能抖動(dòng)的時(shí)刻,有大量正常的查詢都阻塞在Waiting for table metadata lock狀態(tài)。我們順藤摸瓜,最終發(fā)現(xiàn)源頭是后臺(tái)一個(gè)新功能引入的報(bào)表SQL。這條SQL的查詢條件非常復(fù)雜,且沒(méi)有建立合適的索引,導(dǎo)致它在執(zhí)行時(shí),MySQL對(duì)目標(biāo)表施加了表鎖。由于報(bào)表查詢數(shù)據(jù)量大,執(zhí)行時(shí)間長(zhǎng),這個(gè)表鎖被長(zhǎng)時(shí)間持有,從而阻塞了所有其他針對(duì)該表的正常業(yè)務(wù)操作。
找到問(wèn)題后,解決方案就很明確了:我們與業(yè)務(wù)方溝通,優(yōu)化了這條SQL,并為其添加了合適的復(fù)合索引,使其從表鎖降級(jí)為行鎖。上線后,服務(wù)性能毛刺的問(wèn)題就徹底消失了。”
2.3.2 進(jìn)階方案:破解臨鍵鎖引發(fā)的并發(fā)死鎖
這個(gè)案例更能體現(xiàn)你對(duì)InnoDB鎖機(jī)制的深度理解。
“我們有一個(gè)業(yè)務(wù)場(chǎng)景,需要在一個(gè)事務(wù)里先查詢某個(gè)資源位是否存在,如果不存在,就計(jì)算并插入一個(gè)新資源;如果已存在,就基于現(xiàn)有資源進(jìn)行更新。這個(gè)邏輯可以簡(jiǎn)化為‘查詢并鎖定,然后插入或更新’。

偽代碼大概是這樣:
BEGIN;
SELECT * FROM resource WHERE position = ? FOR UPDATE;
-- 如果記錄不存在
INSERT INTO resource (...) VALUES (...);
-- 如果記錄存在
-- UPDATE resource SET ... WHERE position = ?;
COMMIT;看起來(lái)是不是沒(méi)有任何問(wèn)題?實(shí)際上,這個(gè)地方在高并發(fā)下會(huì)引起死鎖。
假設(shè)現(xiàn)在數(shù)據(jù)庫(kù)中ID最大的值是780。那么如果兩個(gè)業(yè)務(wù)線程幾乎同時(shí)進(jìn)來(lái),執(zhí)行這段邏輯,一個(gè)準(zhǔn)備插入id=790的數(shù)據(jù),一個(gè)準(zhǔn)備插入id=800的數(shù)據(jù)。如果它們的執(zhí)行時(shí)序如下圖所示,那么你就會(huì)得到一個(gè)死鎖錯(cuò)誤。

這段代碼在并發(fā)量上來(lái)后,頻繁地引發(fā)數(shù)據(jù)庫(kù)死鎖。經(jīng)過(guò)復(fù)盤,我們發(fā)現(xiàn)死鎖的根源在于臨鍵鎖。假設(shè)
position索引當(dāng)前的最大值是780,兩個(gè)并發(fā)事務(wù)分別嘗試操作positinotallow=790和positinotallow=800。由于這兩條記錄都不存在,它們都會(huì)在(780, +∞)這個(gè)間隙上成功獲得臨鍵鎖。接著,事務(wù)A想插入790,需要獲取790的行鎖,但這個(gè)位置被事務(wù)B的臨鍵鎖覆蓋,于是等待;同時(shí),事務(wù)B想插入800,也被事務(wù)A的臨鍵鎖覆蓋,也開(kāi)始等待。這樣,兩個(gè)事務(wù)互相等待對(duì)方釋放自己需要的間隙,就形成了死鎖。

針對(duì)這個(gè)問(wèn)題,我們當(dāng)時(shí)探討了三種解決方案:
- 調(diào)整業(yè)務(wù)邏輯:將‘先查后插’改為‘直接插入’。我們嘗試直接
INSERT,如果成功,說(shuō)明資源位原先為空;如果因?yàn)橹麈I或唯一鍵沖突而失敗,則說(shuō)明資源位已存在,再去執(zhí)行更新邏輯。這樣,成功的事務(wù)從一開(kāi)始就持有行鎖,而不是臨鍵鎖,從而規(guī)避了死鎖。 - 降低隔離級(jí)別:將事務(wù)的隔離級(jí)別從RR降為RC(讀已提交)。在RC級(jí)別下,沒(méi)有間隙鎖,自然也就沒(méi)有臨鍵鎖,死鎖問(wèn)題迎刃而解。但這個(gè)方案影響較大,需要評(píng)估業(yè)務(wù)是否能接受RC級(jí)別可能帶來(lái)的不可重復(fù)讀和幻讀問(wèn)題。
- 改用樂(lè)觀鎖:完全放棄悲觀鎖,但這需要對(duì)代碼進(jìn)行較大范圍的重構(gòu)。
最終,我們選擇了第一種方案,因?yàn)樗鼘?duì)代碼的侵入性最小,且效果立竿見(jiàn)影。”
2.3.3 終極方案:以樂(lè)觀鎖替代不必要的悲觀鎖
你可以將這個(gè)案例包裝成一次主動(dòng)的技術(shù)重構(gòu),體現(xiàn)你的架構(gòu)思維和性能意識(shí)。
“在我之前負(fù)責(zé)的一個(gè)項(xiàng)目中,我注意到許多核心業(yè)務(wù)模塊為了保證數(shù)據(jù)一致性,普遍采用了
SELECT ... FOR UPDATE的悲觀鎖模式。這種模式雖然簡(jiǎn)單可靠,但在讀多寫少的場(chǎng)景下,會(huì)造成不必要的線程等待,極大地限制了系統(tǒng)的并發(fā)能力。因此,我主導(dǎo)了一項(xiàng)技術(shù)優(yōu)化,對(duì)這些不必要的悲觀鎖進(jìn)行樂(lè)觀鎖改造。改造的核心思想是,在事務(wù)中去掉
FOR UPDATE,并在UPDATE語(yǔ)句中增加對(duì)數(shù)據(jù)版本的校驗(yàn)。
改造前的偽代碼:
// 開(kāi)啟事務(wù)并加鎖
tx.Begin()
data := tx.SelectForUpdate(id)
// 業(yè)務(wù)計(jì)算
newData := calculate(data)
tx.Update(newData)
tx.Commit()改造后的偽代碼:
for {
// 1. 普通查詢,獲取數(shù)據(jù)和版本號(hào)
data, version := SelectWithVersion(id)
// 2. 業(yè)務(wù)計(jì)算
newData := calculate(data)
// 3. CAS更新,通過(guò)版本號(hào)防止并發(fā)修改
rowsAffected := UpdateWithCAS(id, newData, version)
// 4. 如果更新成功(影響行數(shù)為1),則退出循環(huán)
if rowsAffected == 1 {
break
}
// 如果更新失敗,則循環(huán)重試
}這次重構(gòu)的效果非常顯著,核心接口的吞吐量提升了將近30%。當(dāng)然,這個(gè)方案并非萬(wàn)能靈藥。對(duì)于那些寫沖突非常頻繁的業(yè)務(wù),使用樂(lè)觀鎖可能會(huì)導(dǎo)致大量的重試,反而降低性能,這種場(chǎng)景下保留悲觀鎖依然是更明智的選擇。”
3. 小結(jié)
這篇文章,我們系統(tǒng)性地梳理了MySQL鎖機(jī)制的方方面面。從鎖與索引的共生關(guān)系,到不同維度下鎖的分類(行鎖與表鎖、共享鎖與排它鎖、意向鎖),再到InnoDB為了解決幻讀問(wèn)題而設(shè)計(jì)的三種核心鎖形態(tài)(記錄鎖、間隙鎖、臨鍵鎖),我們建立了一個(gè)相對(duì)完整的知識(shí)框架。
更重要的是,我們必須認(rèn)識(shí)到,對(duì)鎖的理解不能止步于理論。無(wú)論是通過(guò)“為缺失索引的查詢補(bǔ)上索引”來(lái)避免表鎖的基礎(chǔ)優(yōu)化,還是分析“臨鍵鎖引發(fā)死鎖”和“以樂(lè)觀鎖替代悲觀鎖”這類更復(fù)雜的實(shí)戰(zhàn)場(chǎng)景,最終目的都是為了將理論知識(shí)轉(zhuǎn)化為解決實(shí)際問(wèn)題的能力。你在面試中準(zhǔn)備的案例,不應(yīng)僅僅是背誦的腳本,而應(yīng)是你對(duì)這些問(wèn)題深入思考和理解的體現(xiàn)。

































