mysql:行鎖(五)

感謝文章出處:極客邦科技

================

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

的行鎖。

MySQL 的行鎖是在引擎層由各個引擎自己實現的。但並不是所有的引擎都支持行鎖,比

如 MyISAM 引擎就不支持行鎖。不支持行鎖意味着併發控制只能使用表鎖,對於這種引

擎的表,同一張表上任何時刻只能有一個更新在執行,這就會影響到業務併發度。InnoDB

是支持行鎖的,這也是 MyISAM 被 InnoDB 替代的重要原因之一。

我們今天就主要來聊聊 InnoDB 的行鎖,以及如何通過減少鎖衝突來提升業務併發度。

顧名思義,行鎖就是針對數據表中行記錄的鎖。這很好理解,比如事務 A 更新了一行,而

這時候事務 B 也要更新同一行,則必須等事務 A 的操作完成後才能進行更新。

當然,數據庫中還有一些沒那麼一目瞭然的概念和設計,這些概念如果理解和使用不當,

容易導致程序出現非預期行爲,比如兩階段鎖。

從兩階段鎖說起

 

這個問題的結論取決於事務 A 在執行完兩條 update 語句後,持有哪些鎖,以及在什麼時

候釋放。你可以驗證一下:實際上事務 B 的 update 語句會被阻塞,直到事務 A 執行

commit 之後,事務 B 才能繼續執行。

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

釋放的。

也就是說,在 InnoDB 事務中,行鎖是在需要的時候才加上的,但並不是不需要了就立刻

釋放,而是要等到事務結束時才釋放。這個就是兩階段鎖協議。

 

知道了這個設定,對我們使用事務有什麼幫助呢?那就是,如果你的事務中需要鎖多個

行,要把最可能造成鎖衝突、最可能影響併發度的鎖儘量往後放。我給你舉個例子。

假設你負責實現一個電影票在線交易業務,顧客 A 要在影院 B 購買電影票。我們簡化一

點,這個業務需要涉及到以下操作:

1. 從顧客 A 賬戶餘額中扣除電影票價;

2. 給影院 B 的賬戶餘額增加這張電影票價;

3. 記錄一條交易日誌。

也就是說,要完成這個交易,我們需要 update 兩條記錄,並 insert 一條記錄。當然,爲

了保證交易的原子性,我們要把這三個操作放在一個事務中。那麼,你會怎樣安排這三個

語句在事務中的順序呢?

試想如果同時有另外一個顧客 C 要在影院 B 買票,那麼這兩個事務衝突的部分就是語句 2

了。因爲它們要更新同一個影院賬戶的餘額,需要修改同一行數據。

根據兩階段鎖協議,不論你怎樣安排語句順序,所有的操作需要的行鎖都是在事務提交的

時候才釋放的。所以,如果你把語句 2 安排在最後,比如按照 3、1、2 這樣的順序,那麼

影院賬戶餘額這一行的鎖時間就最少。這就最大程度地減少了事務之間的鎖等待,提升了

併發度。

好了,現在由於你的正確設計,影院餘額這一行的行鎖在一個事務中不會停留很長時間。

但是,這並沒有完全解決你的困擾。

如果這個影院做活動,可以低價預售一年內所有的電影票,而且這個活動只做一天。於是

在活動時間開始的時候,你的 MySQL 就掛了。你登上服務器一看,CPU 消耗接近

100%,但整個數據庫每秒就執行不到 100 個事務。這是什麼原因呢?

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

死鎖和死鎖檢測

當併發系統中不同線程出現循環資源依賴,涉及的線程都在等待別的線程釋放資源時,就

會導致這幾個線程都進入無限等待的狀態,稱爲死鎖。這裏我用數據庫中的行鎖舉個例

子。

這時候,事務 A 在等待事務 B 釋放 id=2 的行鎖,而事務 B 在等待事務 A 釋放 id=1 的

行鎖。 事務 A 和事務 B 在互相等待對方的資源釋放,就是進入了死鎖狀態。當出現死鎖

以後,有兩種策略:

一種策略是,直接進入等待,直到超時。這個超時時間可以通過參數

innodb_lock_wait_timeout 來設置。

另一種策略是,發起死鎖檢測,發現死鎖後,主動回滾死鎖鏈條中的某一個事務,讓其

他事務得以繼續執行。將參數 innodb_deadlock_detect 設置爲 on,表示開啓這個邏

輯。

在 InnoDB 中,innodb_lock_wait_timeout 的默認值是 50s,意味着如果採用第一個策

略,當出現死鎖以後,第一個被鎖住的線程要過 50s 纔會超時退出,然後其他線程纔有可

能繼續執行。對於在線服務來說,這個等待時間往往是無法接受的。

但是,我們又不可能直接把這個時間設置成一個很小的值,比如 1s。這樣當出現死鎖的時

候,確實很快就可以解開,但如果不是死鎖,而是簡單的鎖等待呢?所以,超時時間設置

太短的話,會出現很多誤傷。加微信 ixuexi66 獲取最新一手資源

所以,正常情況下我們還是要採用第二種策略,即:主動死鎖檢測,而且

innodb_deadlock_detect 的默認值本身就是 on。主動死鎖檢測在發生死鎖的時候,是

能夠快速發現並進行處理的,但是它也是有額外負擔的。

你可以想象一下這個過程:每當一個事務被鎖的時候,就要看看它所依賴的線程有沒有被

別人鎖住,如此循環,最後判斷是否出現了循環等待,也就是死鎖。

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

每個新來的被堵住的線程,都要判斷會不會由於自己的加入導致了死鎖,這是一個時間復

雜度是 O(n) 的操作。假設有 1000 個併發線程要同時更新同一行,那麼死鎖檢測操作就

是 100 萬這個量級的。雖然最終檢測的結果是沒有死鎖,但是這期間要消耗大量的 CPU

資源。因此,你就會看到 CPU 利用率很高,但是每秒卻執行不了幾個事務。

根據上面的分析,我們來討論一下,怎麼解決由這種熱點行更新導致的性能問題呢?問題

的癥結在於,死鎖檢測要耗費大量的 CPU 資源。

一種頭痛醫頭的方法,就是如果你能確保這個業務一定不會出現死鎖,可以臨時把死鎖檢

測關掉。但是這種操作本身帶有一定的風險,因爲業務設計的時候一般不會把死鎖當做一

個嚴重錯誤,畢竟出現死鎖了,就回滾,然後通過業務重試一般就沒問題了,這是業務無

損的。而關掉死鎖檢測意味着可能會出現大量的超時,這是業務有損的。

另一個思路是控制併發度。根據上面的分析,你會發現如果併發能夠控制住,比如同一行

同時最多隻有 10 個線程在更新,那麼死鎖檢測的成本很低,就不會出現這個問題。一個

直接的想法就是,在客戶端做併發控制。但是,你會很快發現這個方法不太可行,因爲客

戶端很多。我見過一個應用,有 600 個客戶端,這樣即使每個客戶端控制到只有 5 個併發

線程,彙總到數據庫服務端以後,峯值併發數也可能要達到 3000。

因此,這個併發控制要做在數據庫服務端。如果你有中間件,可以考慮在中間件實現;如

果你的團隊有能修改 MySQL 源碼的人,也可以做在 MySQL 裏面。基本思路就是,對於

相同行的更新,在進入引擎之前排隊。這樣在 InnoDB 內部就不會有大量的死鎖檢測工作

了。

可能你會問,如果團隊裏暫時沒有數據庫方面的專家,不能實現這樣的方案,能不能從設

計上優化這個問題呢?

你可以考慮通過將一行改成邏輯上的多行來減少鎖衝突。還是以影院賬戶爲例,可以考慮

放在多條記錄上,比如 10 個記錄,影院的賬戶總額等於這 10 個記錄的值的總和。這樣每

次要給影院賬戶加金額的時候,隨機選其中一條記錄來加。這樣每次衝突概率變成原來的

1/10,可以減少鎖等待個數,也就減少了死鎖檢測的 CPU 消耗。

這個方案看上去是無損的,但其實這類方案需要根據業務邏輯做詳細設計。如果賬戶餘額

可能會減少,比如退票邏輯,那麼這時候就需要考慮當一部分行記錄變成 0 的時候,代碼

要有特殊處理。

小結

今天,我和你介紹了 MySQL 的行鎖,涉及了兩階段鎖協議、死鎖和死鎖檢測這兩大部分

內容。

其中,我以兩階段協議爲起點,和你一起討論了在開發的時候如何安排正確的事務語句。

這裏的原則 / 我給你的建議是:如果你的事務中需要鎖多個行,要把最可能造成鎖衝突、

最可能影響併發度的鎖的申請時機儘量往後放。

但是,調整語句順序並不能完全避免死鎖。所以我們引入了死鎖和死鎖檢測的概念,以及

提供了三個方案,來減少死鎖對數據庫的影響。減少死鎖的主要方向,就是控制訪問相同

資源的併發事務量。

 

如果你要刪除一個表裏面的前 10000 行數據,有以下三種

方法可以做到:

第一種,直接執行 delete from T limit 10000;

第二種,在一個連接中循環執行 20 次 delete from T limit 500;

第三種,在 20 個連接中同時執行 delete from T limit 500。

你會選擇哪一種方法呢?爲什麼呢?

 

 

=

3

 

2

 

1

 

 

 

====

第一種方式(即:直接執行 delete from T limit 10000)裏面,單個語句佔用時間長,鎖

的時間也比較長;而且大事務還會導致主從延遲。

第三種方式(即:在 20 個連接中同時執行 delete from T limit 500),會人爲造成鎖衝

突。

第二種方式是相對較好的。

感謝原作者文檔,搬錄於--- 微信 ixuexi66 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章