MySQL 的鎖機制,那麼多的鎖,該怎麼區分?

楔子

本篇文章來聊一下 MySQL 的鎖,首先不光是數據庫,任何的一門高級語言也都內置了鎖機制。從本質上講,鎖是一種協調多個進程或多個線程對某一資源進行訪問的機制。

而之所以要存在鎖,是因爲在併發編程中,程序的某一部分在併發訪問的時候會導致意想不到的結果。所以這部分程序就需要用鎖保護起來,而保護起來的部分就叫做臨界區。

在 MySQL 中,按照不同的角度,可以將鎖分爲如下幾種:

這麼多的鎖,我們該怎麼區分呢?下面就來逐一回答。

髒寫是如何避免的

在區分鎖之前,先來回顧一個問題,前面我們說四種隔離級別,無論哪一種都可以避免髒寫的問題。但怎麼避免的當時卻沒有解釋,原因就是涉及到了鎖,下面來解釋一下。

再來回顧一下什麼是髒寫,假設事務 A 和 事務 B 同時對張三的賬戶餘額進行更新,初始值爲 100,那麼兩個事務拿到的也都是 100。然後事務 A 給餘額增加 100,事務 B 給餘額增加 200。理論上最終應該是 400 纔對,但如果 A 先提交 B 後提交,最終的結果卻是 300;B 先提交 A 後提交,最終的結果就是 200。

以上這種現象就是髒寫,具體表現爲:兩個事務更新同一條數據,後提交的事務將先提交的事務所做的更新給覆蓋了。

那麼如何避免呢?顯然要依賴鎖,多個事務在更新同一條數據的時候要串行更新。

所以當事務在更新數據的時候,會先看這條數據有沒有人加鎖。如果沒有,那麼該事務就會創建一個鎖,裏面包含了事務 ID(trx_id) 和等待狀態,然後將鎖和這條數據關聯在一起。

事務 A 更新數據的時候,會給數據加鎖,然後別的事務就不能再更新了。但假設這個時候又來個事務 B 也要更新這條數據,它會怎麼做呢?

首先還是判斷數據有沒有人加鎖,結果發現被事務 A 加鎖了,就知道自己不能修改這條數據。但事務 B 仍會對這條數據加鎖,只不過它處於等待狀態。

當事務 A 更新完數據,就會將自己的鎖釋放掉,並且還會去找,有沒有別人也對這條數據加了鎖。顯然它會發現該數據也被事務 B 加鎖了,於是會把事務 B 鎖裏的等待狀態修改爲 false,然後喚醒事務 B 開始執行,此時事務 B 就獲取到鎖了。

以上就是 MySQL 鎖機制的一個最基本的原理,其實就和 Python 裏面的互斥鎖是一樣的,但是基於此我們又引申出了很多不同種類的鎖。

MySQL 的讀鎖和寫鎖

先來聊聊讀鎖和寫鎖,讀鎖也被稱爲共享鎖、S 鎖,寫鎖也被稱爲獨佔鎖、排它鎖、X 鎖。而上面多個事務在更新數據時加的鎖,就是寫鎖。

那麼問題來了,如果一個事務在讀數據的時候,發現這條數據被加鎖了,那麼該事務需要繼續加鎖嗎?如果是更新數據,那麼需要加鎖,但讀數據是不需要的。因爲默認情況下,如果是讀數據,會走 MVCC 機制。

因爲讀數據可以根據 ReadView 在 undo log 版本鏈裏找一個能讀取的版本,完全不用考慮是否有別的事務在更新,ReadView 機制不允許當前事務讀取別的事務已經更新的值。所以默認情況下讀數據完全不需要加鎖,更不需要關心別的事務是否在更新數據,直接基於 MVCC 機制讀某個快照即可。

但如果就是想在讀數據的時候加鎖呢?答案是使用讀鎖,也叫 S 鎖、共享鎖。

SELECT * FROM girl
WHERE age > 16 IN SHARE MODE;

在查詢語句後面加上 IN SHARE MODE 就代表查詢數據的時候施加讀鎖。

注意:讀鎖和寫鎖是互斥的,只能有一把寫鎖或者任意多把讀鎖,也就是說如果先施加了寫鎖,就不能再施加讀鎖,因爲兩者互斥,當然更不能施加寫鎖,因爲寫鎖只能有一把。如果先施加了讀鎖,那麼不能再施加寫鎖,但是可以繼續施加讀鎖,因爲讀鎖可以有任意把。

所以可以得到如下結論:

不過說實話,一般開發業務系統的時候,主動給查詢加讀鎖是很少見的。另外,我們說查詢的時候默認沒有鎖,走的是 MVCC,但可以手動加讀鎖。其實除了讀鎖,查詢的時候還可以手動加寫鎖。

SELECT * FROM girl
WHERE age > 16 FOR UPDATE;

在查詢語句後面加上 FOR UPDATE 則表示給該查詢語句施加寫鎖,一般主要出現在事務查詢完畢之後還要更新數據的時候。比如該數據非常重要,事務在處理的時候不希望受到干擾。而一旦查詢的時候加了寫鎖,那麼在事務提交之前,任何人都不能更新數據了,只能在當前事務裏更新數據。而等該事務提交之後,別人才能繼續更新。

另外,讀鎖也被稱爲共享鎖和 S 鎖,寫鎖也被稱爲排它鎖、獨佔鎖和 X 鎖。這裏我們一直說的是讀鎖和寫鎖,但在 MySQL 中更常說共享鎖和獨佔鎖(排它鎖),當然意思都是一樣的,我們理解就好。

MySQL 的行鎖、表鎖和頁面鎖

基於操作類型,我們將鎖分爲讀鎖和寫鎖,如果基於操作的數據粒度劃分的話,還可以將鎖分爲行鎖、表鎖和頁面鎖。

像 IN SHARE MODE 和 FOR UPDATE 施加的都屬於行鎖,因此也可以說行級讀鎖和行級寫鎖。行鎖是針對指定行進行加鎖,比如:

-- 更新數據,MySQL會自動施加寫鎖
-- 並且只對 id = 1 的行施加寫鎖
-- 其它行不受影響
UPDATE * FROM girl
SET age = age + 1 
WHERE id = 1;

行鎖的特點是開銷比較大,加鎖速度慢,可能會出現死鎖,但鎖定的粒度最小,發生鎖衝突的概率最小,併發度最高。

而表鎖則是在整個數據表上對數據進行加鎖和釋放鎖,特點是開銷比較小,加鎖速度快,一般不會出現死鎖,但鎖定的粒度比較大,發生鎖衝突的概率最高,併發度最低。

在 MySQL 中可以通過以下方式手動添加表鎖:

-- 爲 account 表增加表級讀鎖
lock table account read;

-- 爲 account 表增加表級寫鎖
lock table account write;

-- 查看數據表上增加的鎖
show open tables;

-- 刪除添加的表鎖
unlock tables;

但說實話,在工作中我們幾乎不會使用表鎖,好端端的鎖整張表幹什麼。

最後是頁面鎖,也稱爲頁級鎖,就是在頁級別對數據進行加鎖和解鎖。鎖定的粒度介於表鎖和行鎖之間,併發度一般。

工作中最常用的是行鎖,表鎖和頁面鎖基本不用,MySQL 也不會自動添加。但使用行鎖的時候,有以下幾點需要注意:

另外行鎖、表鎖和頁面鎖都是 InnoDB 存儲引擎的特性,可能有人覺得執行 ALTER TABLE 之類的 DDL 語句施加的也是表鎖,雖然 DDL 語句和普通的增刪改語句之間也是互斥的。但其實 DDL 語句執行時施加的不是表鎖,而是元數據鎖(metadata locks),這一點要注意。

死鎖的產生和預防

雖然鎖在一定程度上能夠解決併發問題,但稍有不慎,就可能造成死鎖。發生死鎖的必要條件有 4 個,分別爲互斥條件、不可剝奪條件、請求與保持條件和循環等待條件,如下圖所示。

1)互斥條件

在一段時間內,計算機中的某個資源只能被一個進程佔用,此時如果其他進程請求該資源,則只能等待。

2)不可剝奪條件

某個進程獲得的資源在使用完畢之前,不能被其他進程強行奪走,只能由獲得資源的進程主動釋放。

3)請求與保持條件

進程已經獲得了至少一個資源,又要請求其他資源,但請求的資源已經被其他進程佔有,此時請求的進程就會被阻塞,並且不會釋放自己已獲得的資源。

4)循環等待條件

系統中的進程之間相互等待,同時各自佔用的資源又會被下一個進程所請求。例如有進程 A、進程 B 和進程 C 三個進程,進程 A 請求的資源被進程 B 佔用,進程 B 請求的資源被進程 C 佔用,進程 C 請求的資源被進程 A 佔用,於是形成了循環等待條件。

但需要注意的是,只有 4 個必要條件都滿足時,纔會發生死鎖。而處理死鎖有 4 種方法,分別爲預防死鎖、避免死鎖、檢測死鎖和解除死鎖。

在實際工作中,通常採用有序資源分配法和銀行家算法這兩種方式來避免死鎖,有興趣可自行了解一下。

MySQL 的死鎖問題

在 MySQL 5.5.5 及以上版本中,默認存儲引擎是 InnoDB。該存儲引擎使用的是行級鎖,在某種情況下會產生死鎖問題,所以 InnoDB 存儲引擎採用了一種叫作等待圖(wait-for graph)的方法來自動檢測死鎖,如果發現死鎖,就會自動回滾一個事務。我們舉例說明:

第一步:在終端 1 中將事務隔離級別設置爲可重複讀,開啓事務後爲 account 數據表中 id 爲 1 的數據添加排他鎖。

第二步:在終端 2 中將事務隔離級別設置爲可重複讀,開啓事務後爲 account 數據表中 id 爲 2 的數據添加排他鎖。

第三步:在終端 1 中爲 account 數據表中 id 爲 2 的數據添加排他鎖。

select * from account 
where id = 2 for update;

此時事務 1 會阻塞住,因爲它在等待事務 2 釋放 id  = 2 的排他鎖。

第四步:在終端 2 中爲 account 數據表中 id 爲 1 的數據添加排他鎖。

我們看到死鎖了,事務 1 因事務 2 已經處於阻塞了,但此時事務 2 又因事務 1 陷入阻塞,因此出現了循環等待,所以事務 2 直接報錯、並且終止。而一旦事務 2 終止,那麼它施加的行鎖就會失效,然後事務 1 就會給 id = 2 施加行鎖成功,不再阻塞。

我們可以通過如下命令查看死鎖的日誌信息:show engine innodb status \G,或者通過配置 innodb_print_all_deadlocks(MySQL 5.6.2 版本開始提供)參數爲 ON,將死鎖相關信息打印到 MySQL 錯誤日誌中。

本文參考自:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/k9blC4joymbaLsWYnw_ymA