MySQL行鎖功過:怎么減少行鎖對性能的影響?

在上一篇文章中,我跟你介紹了 MySQL 的全局鎖和表級鎖,今天我們就來講講 MySQL 的行鎖。

MySQL 的行鎖是在引擎層由各個引擎自己實現(xiàn)的。但并不是所有的引擎都支持行鎖,比如 MyISAM 引擎就不支持行鎖。不支持行鎖意味著并發(fā)控制只能使用表鎖,對于這種引擎的表,同一張表上任何時刻只能有一個更新在執(zhí)行,這就會影響到業(yè)務(wù)并發(fā)度。InnoDB 是支持行鎖的,這也是 MyISAM 被 InnoDB 替代的重要原因之一。

我們今天就主要來聊聊 InnoDB 的行鎖,以及如何通過減少鎖沖突來提升業(yè)務(wù)并發(fā)度。

顧名思義,行鎖就是針對數(shù)據(jù)表中行記錄的鎖。這很好理解,比如事務(wù) A 更新了一行,而這時候事務(wù) B 也要更新同一行,則必須等事務(wù) A 的操作完成后才能進(jìn)行更新。

當(dāng)然,數(shù)據(jù)庫中還有一些沒那么一目了然的概念和設(shè)計,這些概念如果理解和使用不當(dāng),容易導(dǎo)致程序出現(xiàn)非預(yù)期行為,比如兩階段鎖。

從兩階段鎖說起

我先給你舉個例子。在下面的操作序列中,事務(wù) B 的 update 語句執(zhí)行時會是什么現(xiàn)象呢?假設(shè)字段 id 是表 t 的主鍵。

image.jpeg

這個問題的結(jié)論取決于事務(wù) A 在執(zhí)行完兩條 update 語句后,持有哪些鎖,以及在什么時候釋放。你可以驗證一下:實際上事務(wù) B 的 update 語句會被阻塞,直到事務(wù) A 執(zhí)行 commit 之后,事務(wù) B 才能繼續(xù)執(zhí)行。

知道了這個答案,你一定知道了事務(wù) A 持有的兩個記錄的行鎖,都是在 commit 的時候才釋放的。

也就是說,在 InnoDB 事務(wù)中,行鎖是在需要的時候才加上的,但并不是不需要了就立刻釋放,而是要等到事務(wù)結(jié)束時才釋放。這個就是兩階段鎖協(xié)議。

知道了這個設(shè)定,對我們使用事務(wù)有什么幫助呢?那就是,如果你的事務(wù)中需要鎖多個行,要把最可能造成鎖沖突、最可能影響并發(fā)度的鎖盡量往后放。我給你舉個例子。

假設(shè)你負(fù)責(zé)實現(xiàn)一個電影票在線交易業(yè)務(wù),顧客 A 要在影院 B 購買電影票。我們簡化一點(diǎn),這個業(yè)務(wù)需要涉及到以下操作:

  1. 從顧客 A 賬戶余額中扣除電影票價;
  2. 給影院 B 的賬戶余額增加這張電影票價;
  3. 記錄一條交易日志。

也就是說,要完成這個交易,我們需要 update 兩條記錄,并 insert 一條記錄。當(dāng)然,為了保證交易的原子性,我們要把這三個操作放在一個事務(wù)中。那么,你會怎樣安排這三個語句在事務(wù)中的順序呢?

試想如果同時有另外一個顧客 C 要在影院 B 買票,那么這兩個事務(wù)沖突的部分就是語句 2 了。因為它們要更新同一個影院賬戶的余額,需要修改同一行數(shù)據(jù)。

根據(jù)兩階段鎖協(xié)議,不論你怎樣安排語句順序,所有的操作需要的行鎖都是在事務(wù)提交的時候才釋放的。所以,如果你把語句 2 安排在最后,比如按照 3、1、2 這樣的順序,那么影院賬戶余額這一行的鎖時間就最少。這就最大程度地減少了事務(wù)之間的鎖等待,提升了并發(fā)度。

好了,現(xiàn)在由于你的正確設(shè)計,影院余額這一行的行鎖在一個事務(wù)中不會停留很長時間。但是,這并沒有完全解決你的困擾。

如果這個影院做活動,可以低價預(yù)售一年內(nèi)所有的電影票,而且這個活動只做一天。于是在活動時間開始的時候,你的 MySQL 就掛了。你登上服務(wù)器一看,CPU 消耗接近 100%,但整個數(shù)據(jù)庫每秒就執(zhí)行不到 100 個事務(wù)。這是什么原因呢?

這里,我就要說到死鎖和死鎖檢測了。

死鎖和死鎖檢測
當(dāng)并發(fā)系統(tǒng)中不同線程出現(xiàn)循環(huán)資源依賴,涉及的線程都在等待別的線程釋放資源時,就會導(dǎo)致這幾個線程都進(jìn)入無限等待的狀態(tài),稱為死鎖。這里我用數(shù)據(jù)庫中的行鎖舉個例子。

死鎖和死鎖檢測

當(dāng)并發(fā)系統(tǒng)中不同線程出現(xiàn)循環(huán)資源依賴,涉及的線程都在等待別的線程釋放資源時,就會導(dǎo)致這幾個線程都進(jìn)入無限等待的狀態(tài),稱為死鎖。這里我用數(shù)據(jù)庫中的行鎖舉個例子。

這時候,事務(wù) A 在等待事務(wù) B 釋放 id=2 的行鎖,而事務(wù) B 在等待事務(wù) A 釋放 id=1 的行鎖。 事務(wù) A 和事務(wù) B 在互相等待對方的資源釋放,就是進(jìn)入了死鎖狀態(tài)。當(dāng)出現(xiàn)死鎖以后,有兩種策略:

  • 一種策略是,直接進(jìn)入等待,直到超時。這個超時時間可以通過參數(shù) innodb_lock_wait_timeout 來設(shè)置。
  • 另一種策略是,發(fā)起死鎖檢測,發(fā)現(xiàn)死鎖后,主動回滾死鎖鏈條中的某一個事務(wù),讓其他事務(wù)得以繼續(xù)執(zhí)行。將參數(shù) innodb_deadlock_detect 設(shè)置為 on,表示開啟這個邏輯。

在 InnoDB 中,innodb_lock_wait_timeout 的默認(rèn)值是 50s,意味著如果采用第一個策略,當(dāng)出現(xiàn)死鎖以后,第一個被鎖住的線程要過 50s 才會超時退出,然后其他線程才有可能繼續(xù)執(zhí)行。對于在線服務(wù)來說,這個等待時間往往是無法接受的。

但是,我們又不可能直接把這個時間設(shè)置成一個很小的值,比如 1s。這樣當(dāng)出現(xiàn)死鎖的時候,確實很快就可以解開,但如果不是死鎖,而是簡單的鎖等待呢?所以,超時時間設(shè)置太短的話,會出現(xiàn)很多誤傷。

所以,正常情況下我們還是要采用第二種策略,即:主動死鎖檢測,而且 innodb_deadlock_detect 的默認(rèn)值本身就是 on。主動死鎖檢測在發(fā)生死鎖的時候,是能夠快速發(fā)現(xiàn)并進(jìn)行處理的,但是它也是有額外負(fù)擔(dān)的。

你可以想象一下這個過程:每當(dāng)一個事務(wù)被鎖的時候,就要看看它所依賴的線程有沒有被別人鎖住,如此循環(huán),最后判斷是否出現(xiàn)了循環(huán)等待,也就是死鎖。

那如果是我們上面說到的所有事務(wù)都要更新同一行的場景呢?

每個新來的被堵住的線程,都要判斷會不會由于自己的加入導(dǎo)致了死鎖,這是一個時間復(fù)雜度是 O(n) 的操作。假設(shè)有 1000 個并發(fā)線程要同時更新同一行,那么死鎖檢測操作就是 100 萬這個量級的。雖然最終檢測的結(jié)果是沒有死鎖,但是這期間要消耗大量的 CPU 資源。因此,你就會看到 CPU 利用率很高,但是每秒?yún)s執(zhí)行不了幾個事務(wù)。

根據(jù)上面的分析,我們來討論一下,怎么解決由這種熱點(diǎn)行更新導(dǎo)致的性能問題呢?問題的癥結(jié)在于,死鎖檢測要耗費(fèi)大量的 CPU 資源。

一種頭痛醫(yī)頭的方法,就是如果你能確保這個業(yè)務(wù)一定不會出現(xiàn)死鎖,可以臨時把死鎖檢測關(guān)掉。但是這種操作本身帶有一定的風(fēng)險,因為業(yè)務(wù)設(shè)計的時候一般不會把死鎖當(dāng)做一個嚴(yán)重錯誤,畢竟出現(xiàn)死鎖了,就回滾,然后通過業(yè)務(wù)重試一般就沒問題了,這是業(yè)務(wù)無損的。而關(guān)掉死鎖檢測意味著可能會出現(xiàn)大量的超時,這是業(yè)務(wù)有損的。

另一個思路是控制并發(fā)度。根據(jù)上面的分析,你會發(fā)現(xiàn)如果并發(fā)能夠控制住,比如同一行同時最多只有 10 個線程在更新,那么死鎖檢測的成本很低,就不會出現(xiàn)這個問題。一個直接的想法就是,在客戶端做并發(fā)控制。但是,你會很快發(fā)現(xiàn)這個方法不太可行,因為客戶端很多。我見過一個應(yīng)用,有 600 個客戶端,這樣即使每個客戶端控制到只有 5 個并發(fā)線程,匯總到數(shù)據(jù)庫服務(wù)端以后,峰值并發(fā)數(shù)也可能要達(dá)到 3000。

因此,這個并發(fā)控制要做在數(shù)據(jù)庫服務(wù)端。如果你有中間件,可以考慮在中間件實現(xiàn);如果你的團(tuán)隊有能修改 MySQL 源碼的人,也可以做在 MySQL 里面?;舅悸肪褪?,對于相同行的更新,在進(jìn)入引擎之前排隊。這樣在 InnoDB 內(nèi)部就不會有大量的死鎖檢測工作了。

可能你會問,如果團(tuán)隊里暫時沒有數(shù)據(jù)庫方面的專家,不能實現(xiàn)這樣的方案,能不能從設(shè)計上優(yōu)化這個問題呢?

你可以考慮通過將一行改成邏輯上的多行來減少鎖沖突。還是以影院賬戶為例,可以考慮放在多條記錄上,比如 10 個記錄,影院的賬戶總額等于這 10 個記錄的值的總和。這樣每次要給影院賬戶加金額的時候,隨機(jī)選其中一條記錄來加。這樣每次沖突概率變成原來的 1/10,可以減少鎖等待個數(shù),也就減少了死鎖檢測的 CPU 消耗。

這個方案看上去是無損的,但其實這類方案需要根據(jù)業(yè)務(wù)邏輯做詳細(xì)設(shè)計。如果賬戶余額可能會減少,比如退票邏輯,那么這時候就需要考慮當(dāng)一部分行記錄變成 0 的時候,代碼要有特殊處理。

小結(jié)
今天,我和你介紹了 MySQL 的行鎖,涉及了兩階段鎖協(xié)議、死鎖和死鎖檢測這兩大部分內(nèi)容。

其中,我以兩階段協(xié)議為起點(diǎn),和你一起討論了在開發(fā)的時候如何安排正確的事務(wù)語句。這里的原則 / 我給你的建議是:如果你的事務(wù)中需要鎖多個行,要把最可能造成鎖沖突、最可能影響并發(fā)度的鎖的申請時機(jī)盡量往后放。

但是,調(diào)整語句順序并不能完全避免死鎖。所以我們引入了死鎖和死鎖檢測的概念,以及提供了三個方案,來減少死鎖對數(shù)據(jù)庫的影響。減少死鎖的主要方向,就是控制訪問相同資源的并發(fā)事務(wù)量。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容