1. 程式人生 > 程式設計 >MySQL 的鎖

MySQL 的鎖

MySQL裡有非常多鎖的概念,經常可以聽到的有:樂觀鎖、悲觀鎖、行鎖、表鎖、Gap鎖(間隙鎖)、MDL鎖(元資料鎖)、意向鎖、讀鎖、寫鎖、共享鎖、排它鎖。這麼鎖一聽就讓人頭大,於是去看一些部落格,有些講樂觀鎖、悲觀鎖,有些在講讀鎖、寫鎖,於是樂觀鎖和悲觀鎖好像理解了,讀鎖寫鎖好像也理解了,但是我任然不知道怎麼用,也不知道樂觀鎖與讀鎖寫鎖有沒有什麼關係?再看了很多文章後,逐漸弄懂了它們之間的關係,於是寫下這篇文章來梳理思路。能力有限,難免有誤,請酌情參考。

雖然上面列舉了很多鎖的名詞,但是這些鎖其實並不是在同一個維度上的,這就是我之所以含糊不清的原因。接下來從不同的維度來分析 MySQL 的鎖。

讀鎖和寫鎖

首先讀鎖還有一個名稱叫共享鎖,寫鎖也相應的還有個名稱叫排它鎖,也就是說共享鎖和讀鎖是同一個東西,排它鎖和寫鎖是同一個東西。讀鎖、寫鎖是系統實現層面上的鎖,也是最基礎的鎖。讀鎖和寫鎖還是鎖的一種性質,比如行鎖裡,有行寫鎖和行讀鎖。MDL 鎖裡也有 MDL 寫鎖和 MDL 讀鎖。讀鎖和寫鎖的加鎖關係如下,Y 表示可以共存,X 表示互斥。

讀鎖 寫鎖
讀鎖 Y X
寫鎖 X X

從這個表格裡可以知道讀鎖和寫鎖不能共存,請考慮這樣一個場景,一個請求佔用了讀鎖,這時又來了一個請求要求加寫鎖,但是資源已經被讀鎖佔據,寫鎖阻塞。這樣本沒有問題,但是如果後續不斷的有請求佔用讀鎖,讀鎖一直沒有釋放,造成寫鎖一直等待。這樣寫鎖就被餓死了,為了避免這種情況發生,資料庫做了優化,當有寫鎖阻塞時,後面的讀鎖也會阻塞,這樣就避免了餓死現象的發生。後面還會再次提到這個現象。

之前的文章已經介紹了 MySQL 的儲存模型,對於 InnoDB 引擎而言,採用的是 B+ 樹索引,假設需要將整個表鎖住那麼需要在整個 B+ 樹的每個節點上都加上鎖,顯然這是個非常低效的做法。因此,MySQL 提出了意向鎖的概念,意向鎖就是如果要在一個節點上加鎖就必須在其所有的祖先節點加上意向鎖。關於意向鎖還有更多複雜設計,如果想了解可以檢視 《資料庫系統概率》 一書。

表鎖和行鎖

表鎖和行鎖是兩種不同加鎖粒度的鎖。除了表鎖和行鎖以外還有更大粒度的鎖——全域性鎖。

全域性鎖: 全域性鎖會鎖住整個資料庫,MySQL 使用 flush tables with read lock 命令來加全域性鎖,使用 unlock tables 解鎖。執行緒退出後鎖也會自動釋放。當加上全域性鎖以後,除了當前執行緒以外,其他執行緒的更新操作都會被阻塞,包括增刪改資料表中的資料、建表、修改表結構等。全域性鎖的典型使用場景是全庫的邏輯備份。

表鎖: 表鎖會鎖住一張表,MySQL 使用 lock tables

read/write 命令給表加上讀鎖或寫鎖,通過 unlock tables 命令釋放表鎖。通過 lock tables t read 給表 t 加上讀鎖後,當前執行緒只能訪問表 t,不能訪問資料庫中的其他表,對錶 t 也只有讀許可權,不能進行修改操作。通過 lock tables t write 給表 t 加上寫鎖後,當前執行緒只能訪問表 t,不能訪問資料庫中的其他表,對錶 t 有讀寫許可權。

行鎖: 行鎖會鎖鎖住表中的某一行或者多行,MySQL 使用 lock in share mode 命令給行加讀鎖,用 for update 命令給行加寫鎖,行鎖不需要顯示釋放,當事務被提交時,該事務中加的行鎖就會被釋放。通過 select k from t where k = 1 for update 命令可以鎖住 k 為 1 的所有行。另外當使用 update 命令更新表資料時,會自動給命中的行加上行鎖。另外 MySQL 加行鎖時並不是一次性把所有的行都加上鎖,執行一個 update 命令之後,server 層將命令傳送給 InnoDB 引擎,InnoDB 引擎找到第一條滿足條件的資料,並加鎖後返回給 server 層,server 層更新這條資料然後傳給 InnoDB 引擎。完成這條資料的更新後,server 層再取下一條資料。

我們用一個例子來驗證這個過程,首先執行如下命令建表並插入幾行資料

mysql-> create table t(id int not null auto_increment,c int not null,primary key(id))ENGINE=InnoDB;
mysql-> insert into t(id,c) values (1,1),(2,2),(3,3);
複製程式碼
事務 A 事務 B 事務 C
begin
select * from t where id = 3 for update;
update t set c = 0 where id = c;
set session transaction isolation level READ UNCOMMITTED; select * from t;
commit

事務 A 執行 select * from t where id = 3 for update 將 id 等於3的行鎖住,事務 B 執行 update 命令的時候被阻塞。這時候再開啟事務 C,並且將事務 C 的隔離級別修改為未提交讀,得到的如下表所示,發現前兩行已經被更新,最後 id 為 3 的行沒有更新,說明事務 B 是阻塞在這裡了。

mysql> select *  from t;
+----+---+
| id | c |
+----+---+
|  1 | 0 |
|  2 | 0 |
|  3 | 3 |
+----+---+
複製程式碼

樂觀鎖和悲觀鎖

樂觀鎖

樂觀鎖總是假設不會發生衝突,因此讀取資源的時候不加鎖,只有在更新的時候判斷在整個事務期間是否有其他事務更新這個資料。如果沒有其他事務更新這個資料那麼本次更新成功,如果有其他事務更新本條資料,那麼更新失敗。

悲觀鎖

悲觀鎖總是假設會發生衝突,因此在讀取資料時候就將資料加上鎖,這樣保證同時只有一個執行緒能更改資料。文章前面介紹的表鎖、行鎖等都是悲觀鎖。

樂觀鎖和悲觀鎖是兩種不同的加鎖策略。樂觀鎖假設的場景是衝突少,因此適合讀多寫少的場景。悲觀鎖則正好相反,合適寫多讀少的場景。樂觀鎖無需像悲觀鎖那樣維護鎖資源,做加鎖阻塞等操作,因此更加輕量化。

樂觀鎖的實現

樂觀鎖的實現有兩種方式:版本號和 CAS 演演算法

版本號

通過版本號來實現樂觀鎖主要有以下幾個步驟:

1 給每條資料都加上一個 version 欄位,表示版本號

2 開啟事務後,先讀取資料,並儲存資料裡的版本號 version1,然後做其他處理

3 最後更新的時候比較 version1 和資料庫裡當前的版本號是否相同。用 SQL 語句表示就是 update t set version = version + 1 where version = version1。 根據前面事務的文章我們知道,update 操作時會進行當前讀,因此即使是在可重複讀的隔離級別下,也會取到到最新的版本號。如果沒有其他事務更新過這條資料,那麼 version 等於 version1,於是更新成功。如果有其他事務更新過這條資料,那麼 version 欄位的值會被增加,那麼 version 不等於 version1,於是更新沒有生效。

CAS 演演算法

CAS 是 compare and swap 的縮寫,翻譯為中文就是先比較然後再交換。CAS 實現的虛擬碼:

<< atomic >>
bool cas(int* p,int old,int new)  
{
    if (*p != old)
    {
        return false
    }
    *p = new
    return true
}
複製程式碼

其中,p 是要修改的變數指標,old 是修改前的舊值,new 是將要寫入的新值。這段虛擬碼的意思就是,先比較 p 所指向的值與舊值是否相同,如果不同說明資料已經被其他執行緒修改過,返回 false。如果相同則將新值賦值給 p 所指向的物件,返回 true。這整個過程是通過硬體同步原語來實現,保證整個過程是原子的。

大多數語言都實現了 CAS 函式,比如 C 語言在 GCC 實現:

bool__sync_bool_compare_and_swap (type *ptr,type oldval type newval,...)
type __sync_val_compare_and_swap (type *ptr,...)
複製程式碼

無鎖程式設計實際上也是通過 CAS 來實現,比如無鎖佇列的實現。CAS 的引入也帶來了 ABA 問題。關於 CAS 後面再開一篇專門的文章來總結無鎖程式設計。

MDL 鎖和 Gap 鎖

MDL 鎖

MDL 鎖也是一種表級鎖,MDL 鎖不需要顯示使用。MDL 鎖是用來避免資料操作與表結構變更的衝突,試想當你執行一條查詢語句時,這個時候另一個執行緒在刪除表中的一個欄位,那麼兩者就發生衝突了,因此 MySQL 在5.5版本以後加上了 MDL 鎖。當對一個表做增刪查改時會加 MDL 讀鎖,當對一個表做結構變更時會加 MDL 寫鎖。讀鎖相互相容,讀鎖與寫鎖不能相容。

MDL 需要注意的就是避免 MDL 寫鎖阻塞 MDL 讀鎖。

事務 A 事務 B 事務 C 事務 D
select * from t
select * from t
alter table t add c int
select * from t

事務 A 執行 select 後給表 t 加 MDL 讀鎖。事務 B 執行 select 後給表再次加上 MDL 讀鎖,讀鎖和讀鎖可以相容。事務 C 執行 alter 命令時會阻塞,需要對錶 t 加 MDL 寫鎖。事務 C 被阻塞問題並不大,但是會導致後面所有的事務都被阻塞,比如事務 D。這是為了避免寫鎖餓死的情況發生,MySQL 對加鎖所做的優化,當有寫鎖在等待的時候,新的讀鎖都需要等待。如果事務 C 長時間拿不到鎖,或者事務 C 執行的時間很長都會導致資料庫的操作被阻塞。

為了避免這種事情發生有以下幾點優化思路:

1 避免長事務。事務 A 和事務 B 如果是長事務就可能導致事務 C 阻塞在 MDL 寫鎖的時間比較長。

2 對於大表,修改表結構的語句可以拆分成多個小的事務,這樣每次修改表結構時佔用 MDL 寫鎖的時間會縮短。

3 給 alter 命令加等待超時時間

Gap 鎖

Gap 鎖是 InnoDB 引擎為了避免幻讀而引入的。在 MySQL的事務一文中已經談到,InnoDB 引擎在可重複讀隔離級別下可以避免幻讀。間隙鎖就是鎖住資料行之間的間隙,避免新的資料插入進來。只有在進行當前讀的時候才會加 gap 鎖。關於什麼是當前讀,可以看我的上一篇文章《MySQL的事務》。

加鎖實踐

一條語句會如何加鎖,單純這一句話是無法分析出來的。對加鎖的分析,必須結合事務的隔離級別和索引來看,阿里資料庫專家對此已經寫了非常詳細分析的文章,直接貼出來大家一起學習,MySQL 加鎖處理分析

思維導圖

文章最後,放上我總結的 MySQL 的思維導圖,算是對 MySQL 系列文章的一個總結。

參考

[1] 資料庫系統概念(第6版)

[2] MySQL實戰45講,林曉斌

[3] 高效能MySQL(第3版)

[4] 事務的隔離級別和mysql事務隔離級別修改

[5] MySQL 加鎖處理分析,何登成

[6] 樂觀鎖、悲觀鎖,這一篇就夠了!