1. 程式人生 > 實用技巧 >解決死鎖之路2 - 瞭解常見的鎖型別 (轉)

解決死鎖之路2 - 瞭解常見的鎖型別 (轉)

轉:https://www.aneasystone.com/archives/2017/11/solving-dead-locks-two.html

在上一篇部落格中,我們學習了事務以及事務併發時可能遇到的問題,並介紹了四種不同的隔離級別來解決這些併發問題,在隔離級別的實現一節中,我們提到了鎖的概念,鎖是實現事務併發的關鍵。其實,鎖的概念不僅僅出現在資料庫中,在大多數的程式語言中也存在,譬如 Java 中的 synchronized,C# 中的 lock 等,所以對於開發同學來說應該是不陌生的。但是資料庫中的鎖有很多花樣,一會是行鎖表鎖,一會是讀鎖寫鎖,又一會是記錄鎖意向鎖,概念真是層出不窮,估計很多同學就暈了。

在討論傳統的隔離級別實現的時候,我們就提到:通過對鎖的型別(讀鎖還是寫鎖),鎖的粒度(行鎖還是表鎖),持有鎖的時間(臨時鎖還是持續鎖)合理的進行組合,就可以實現四種不同的隔離級別;但是上一篇部落格中並沒有對鎖做更深入的介紹,我們這一篇就來仔細的學習下 MySQL 中常見的鎖型別。

這是《解決死鎖之路》系列博文中的一篇,你還可以閱讀其他幾篇:

  1. 學習事務與隔離級別
  2. 瞭解常見的鎖型別
  3. 掌握常見 SQL 語句的加鎖分析
  4. 死鎖問題的分析和解決

一、表鎖 vs. 行鎖

在 MySQL 中鎖的種類有很多,但是最基本的還是表鎖和行鎖:表鎖指的是對一整張表加鎖,一般是 DDL 處理時使用,也可以自己在 SQL 中指定;而行鎖指的是鎖定某一行資料或某幾行,或行和行之間的間隙。行鎖的加鎖方法比較複雜,但是由於只鎖住有限的資料,對於其它資料不加限制,所以併發能力強,通常都是用行鎖來處理併發事務。表鎖由 MySQL 伺服器實現,行鎖由儲存引擎實現,常見的就是 InnoDb,所以通常我們在討論行鎖時,隱含的一層意義就是資料庫的儲存引擎為 InnoDb ,而 MyISAM 儲存引擎只能使用表鎖。

1.1 表鎖

表鎖由 MySQL 伺服器實現,所以無論你的儲存引擎是什麼,都可以使用。一般在執行 DDL 語句時,譬如ALTER TABLE就會對整個表進行加鎖。在執行 SQL 語句時,也可以明確對某個表加鎖,譬如下面的例子:

1 2 3 4 5 6 7 mysql> lock table products read; Query OK, 0 rows affected (0.00 sec) mysql> select * from products where id = 100; mysql> unlock tables; Query OK, 0 rows affected (0.00 sec)

上面的 SQL 首先對 products 表加一個表鎖,然後執行查詢語句,最後釋放表鎖。表鎖可以細分成兩種:讀鎖和寫鎖,如果是加寫鎖,則是lock table products write。詳細的語法可以參考MySQL 的官網文件

關於表鎖,我們要了解它的加鎖和解鎖原則,要注意的是它使用的是一次封鎖技術,也就是說,我們會在會話開始的地方使用 lock 命令將後面所有要用到的表加上鎖,在鎖釋放之前,我們只能訪問這些加鎖的表,不能訪問其他的表,最後通過 unlock tables 釋放所有表鎖。這樣的好處是,不會發生死鎖!所以我們在 MyISAM 儲存引擎中,是不可能看到死鎖場景的。對多個表加鎖的例子如下:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 mysql> lock table products read, orders read; Query OK, 0 rows affected (0.00 sec) mysql> select * from products where id = 100; mysql> select * from orders where id = 200; mysql> select * from users where id = 300; ERROR 1100 (HY000): Table 'users' was not locked with LOCK TABLES mysql> update orders set price = 5000 where id = 200; ERROR 1099 (HY000): Table 'orders' was locked with a READ lock and can't be updated mysql> unlock tables; Query OK, 0 rows affected (0.00 sec)

可以看到由於沒有對 users 表加鎖,在持有表鎖的情況下是不能讀取的,另外,由於加的是讀鎖,所以後面也不能對 orders 表進行更新。MySQL 表鎖的加鎖規則如下:

  • 對於讀鎖

    • 持有讀鎖的會話可以讀表,但不能寫表;
    • 允許多個會話同時持有讀鎖;
    • 其他會話就算沒有給表加讀鎖,也是可以讀表的,但是不能寫表;
    • 其他會話申請該表寫鎖時會阻塞,直到鎖釋放。
  • 對於寫鎖

    • 持有寫鎖的會話既可以讀表,也可以寫表;
    • 只有持有寫鎖的會話才可以訪問該表,其他會話訪問該表會被阻塞,直到鎖釋放;
    • 其他會話無論申請該表的讀鎖或寫鎖,都會阻塞,直到鎖釋放。

鎖的釋放規則如下:

  • 使用 UNLOCK TABLES 語句可以顯示釋放表鎖;
  • 如果會話在持有表鎖的情況下執行 LOCK TABLES 語句,將會釋放該會話之前持有的鎖;
  • 如果會話在持有表鎖的情況下執行 START TRANSACTION 或 BEGIN 開啟一個事務,將會釋放該會話之前持有的鎖;
  • 如果會話連線斷開,將會釋放該會話所有的鎖。

1.2 行鎖

表鎖不僅實現和使用都很簡單,而且佔用的系統資源少,所以在很多儲存引擎中使用,如 MyISAM、MEMORY、MERGE 等,MyISAM 儲存引擎幾乎完全依賴 MySQL 伺服器提供的表鎖機制,查詢自動加表級讀鎖,更新自動加表級寫鎖,以此來解決可能的併發問題。但是表鎖的粒度太粗,導致資料庫的併發效能降低,為了提高資料庫的併發能力,InnoDb 引入了行鎖的概念。行鎖和表鎖對比如下:

  • 表鎖:開銷小,加鎖快;不會出現死鎖;鎖定粒度大,發生鎖衝突的概率最高,併發度最低;
  • 行鎖:開銷大,加鎖慢;會出現死鎖;鎖定粒度最小,發生鎖衝突的概率最低,併發度也最高。

行鎖和表鎖一樣,也分成兩種型別:讀鎖和寫鎖。常見的增刪改(INSERT、DELETE、UPDATE)語句會自動對操作的資料行加寫鎖,查詢的時候也可以明確指定鎖的型別,SELECT ... LOCK IN SHARE MODE 語句加的是讀鎖,SELECT ... FOR UPDATE 語句加的是寫鎖。

行鎖這個名字聽起來像是這個鎖加在某個資料行上,實際上這裡要指出的是:在 MySQL 中,行鎖是加在索引上的。所以要深入瞭解行鎖,還需要先了解下 MySQL 中索引的結構。

1.2.1 MySQL 的索引結構

我們知道,資料庫中索引的作用是方便伺服器根據使用者條件快速查詢資料庫中的資料。在一堆有序的資料集中查詢某條特定的記錄,通常我們會使用二分查詢演算法(Binary search),使用該演算法查詢一組固定長度的陣列資料是沒問題的,但是如果資料集是動態增減的,使用扁平的陣列結構就變得不那麼方便了。所以,後來又發明了一種新的資料結構:二叉查詢樹(Binary search tree),又叫做排序二叉樹(Sorted binary tree),使用樹形結構的好處是,可以大大的提高資料插入和刪除的複雜度。二叉查詢樹查詢演算法的複雜度依賴於樹的高度,為 O(log n),譬如下面這樣的一顆 3 層的二叉查詢樹,查詢任意元素最多不超過 3 次比較就可以找到。

當然這是最理想的一種情況,我們考慮下最糟糕的一種情況,當資料本身就是有序的時候,生成的二叉樹將會退化為線性表,複雜度就變成了 O(n),如下圖所示:

為了解決這個問題,人們又想出了一種新的解決方法,那就是平衡樹(Balanced search tree),其實平衡樹有很多種,但是通常我們討論的是AVL 樹,這個名字來自於它的發明者:G.M. Adelson-Velsky 和 E.M. Landis。通過平衡樹的樹旋轉操作,可以使得任何情況下二叉樹的任意兩個子樹高度最大差為 1,也就是說讓二叉樹一直保持著矮矮胖胖的身材,這樣保證對樹的所有操作最壞複雜度都是 O(log n)。

那這些樹結構和 MySQL 的索引有什麼關係呢?其實,無論是 InnoDb 還是 MyISAM 儲存引擎,它們的索引採用的資料結構都是B+ 樹,而 B+ 樹又是從B 樹演變而來。二叉樹雖然查詢效率很高,但是也有著一些侷限性,特別是當資料儲存於外部裝置時(如資料庫或檔案系統),因為這個時候不僅需要考慮演算法本身的複雜度,還需要考慮程式與外部裝置之間的讀寫效率。在二叉樹中,每一個樹節點只儲存一條資料,並且最多有兩個子節點。程式在查詢資料時,讀取一條資料,比較,再讀取另一條資料,再比較,再讀取,如此往復。每讀取一次,都涉及到程式和外部裝置之間的 IO 開銷,而這個開銷將大大降低程式的查詢效率。於是,便有人提出了增加節點儲存的資料條數的想法,譬如2-3 樹(每個節點儲存 1 條或 2 條資料)、2-3-4 樹(每個節點儲存 1 條、2 條或 3 條資料)等,當然也不用限定得這麼死,數值範圍可以 1 - n 條,這就是 B 樹。在實際應用中,會根據硬碟上一個 page 的大小來調整 n 的數值,這樣可以讓一次 IO 操作就讀取到 n 條資料,減少了 IO 開銷,並且,樹的高度顯著降低了,查詢時只需幾次 page 的 IO 即可定位到目標(page 翻譯為中文為頁,表示 InnoDB 每次從磁碟(data file)到記憶體(buffer pool)之間傳送資料的大小)。一顆典型的 B 樹如下圖所示(圖片來源):

不過在現實場景裡幾乎沒有地方在使用 B 樹,這是因為 B 樹沒有很好的伸縮性,它將多條資料都儲存在節點裡,如果資料中某個欄位太長,一個 page 能容納的資料量將受到限制,最壞的情況是一個 page 儲存一條資料,這個時候 B 樹退化成二叉樹;另外 B 樹無法修改欄位最大長度,除非調整 page 大小,重建整個資料庫。於是,B+ 樹橫空出世,在 B+ 樹裡,內節點(非葉子節點)中不再儲存資料,而只儲存用於查詢的 key,並且所有的葉子節點按順序使用連結串列進行連線,這樣可以大大的方便範圍查詢,只要先查到起始位置,然後按連結串列順序查詢,一直查到結束位置即可。如下圖所示(圖片來源):

那麼在 B+ 樹中,資料儲存在什麼地方呢?關於這一點,InnoDb 和 MyISAM 的實現是不一樣的,InnoDb 將資料儲存在葉子節點中,而 MyISAM 將資料儲存在獨立的檔案中,MyISAM 有三種類型的檔案:*.frm 用於儲存表的定義,*.MYI 用於存放表索引,*.MYD 用於存放資料。MYD 檔案中的資料是以堆表的形式儲存的,所以像 MyISAM 這樣以堆形式儲存資料的我們通常把它叫做堆組織表(Heap organized table,簡稱 HOT),而像 InnoDb 這種將資料儲存在葉子節點中,叫做索引組織表(Index organized table,簡稱 IOT)

MyISAM 索引結構如下圖所示(圖片來源):

InnoDb 索引結構如下圖所示(圖片來源):

可以看到,MyISAM 索引的 B+ 樹中,非葉子節點中儲存 key,葉子節點中儲存著資料的地址,指向資料檔案中資料的位置;InnoDb 索引的 B+ 樹中,非葉子節點和 MyISAM 一樣儲存 key,但是葉子節點直接儲存資料。所以,MyISAM 在通過索引查詢資料時,必須通過兩步才能拿到資料(先獲取資料的地址,再讀取資料檔案),InnoDb 在通過索引查詢資料時,可以直接讀取資料。

注意上面兩張圖都是對應著 Primary Key 的情況,MySQL 有兩種索引型別:主鍵索引(Primary Index)和非主鍵索引(Secondary Index,又稱為二級索引、輔助索引),MyISAM 儲存引擎對兩種索引的儲存沒有區別,InnoDb 儲存引擎的資料是儲存在主鍵索引裡的,非主鍵索引裡儲存著該節點對應的主鍵。所以 InnoDb 的主鍵索引有時候又被稱為聚簇索引(Clustered Index),二級索引被稱為非聚簇索引(Nonclustered Index)。如果沒有主鍵,InnoDB 會試著使用一個非空的唯一索引(Unique nonnullable index)代替;如果沒有這種索引,會定義一個隱藏的主鍵。所以 InnoDb 的表一定會有主鍵索引。關於聚簇索引和二級索引,可以參看這裡的MySQL 文件。(疑惑:MyISAM 如果沒有索引,會怎麼樣?會定義隱藏的主鍵嗎?)

1.2.2 MySQL 加鎖流程

關於 MySQL 的索引是一個很大的話題,譬如,增刪改查時 B+ 樹的調整演算法是怎樣實現的,如何通過索引加快 SQL 的執行速度,如何優化索引,等等等等。我們這裡為了加強對鎖的理解,只需要瞭解索引的資料結構即可。當執行下面的 SQL 時(id 為 students 表的主鍵),我們要知道,InnoDb 儲存引擎會在 id = 49 這個主鍵索引上加一把 X 鎖。

1 mysql> update students set score = 100 where id = 49;

當執行下面的 SQL 時(name 為 students 表的二級索引),InnoDb 儲存引擎會在 name = 'Tom' 這個索引上加一把 X 鎖,同時會通過 name = 'Tom' 這個二級索引定位到 id = 49 這個主鍵索引,並在 id = 49 這個主鍵索引上加一把 X 鎖。

1 mysql> update students set score = 100 where name = 'Tom';

加鎖過程如下圖所示:

像上面這樣的 SQL 比較簡單,只操作單條記錄,如果要同時更新多條記錄,加鎖的過程又是什麼樣的呢?譬如下面的 SQL(假設 score 欄位為二級索引):

1 mysql> update students set level = 3 where score >= 60;

下圖展示了當使用者執行這條 SQL 時,MySQL Server 和 InnoDb 之間的執行流程:

從圖中可以看到當 UPDATE 語句被髮給 MySQL 後,MySQL Server 會根據 WHERE 條件讀取第一條滿足條件的記錄,然後 InnoDB 引擎會將第一條記錄返回並加鎖(current read),待 MySQL Server 收到這條加鎖的記錄之後,會再發起一個 UPDATE 請求,更新這條記錄。一條記錄操作完成,再讀取下一條記錄,直至沒有滿足條件的記錄為止。因此,MySQL 在操作多條記錄時 InnoDB 與 MySQL Server 的互動是一條一條進行的,加鎖也是一條一條依次進行的,先對一條滿足條件的記錄加鎖,返回給 MySQL Server,做一些 DML 操作,然後在讀取下一條加鎖,直至讀取完畢。理解這一點,對我們後面分析複雜 SQL 語句的加鎖過程將很有幫助。

1.2.3 行鎖種類

根據鎖的粒度可以把鎖細分為表鎖和行鎖,行鎖根據場景的不同又可以進一步細分,在 MySQL 的原始碼裡,定義了四種類型的行鎖,如下:

1 2 3 4 5 6 7 8 #define LOCK_TABLE 16 /* table lock */ #define LOCK_REC 32 /* record lock */ /* Precise modes */ #define LOCK_ORDINARY 0 #define LOCK_GAP 512 #define LOCK_REC_NOT_GAP 1024 #define LOCK_INSERT_INTENTION 2048
  • LOCK_ORDINARY:也稱為Next-Key Lock,鎖一條記錄及其之前的間隙,這是 RR 隔離級別用的最多的鎖,從名字也能看出來;
  • LOCK_GAP:間隙鎖,鎖兩個記錄之間的 GAP,防止記錄插入;
  • LOCK_REC_NOT_GAP:只鎖記錄;
  • LOCK_INSERT_INTENSION:插入意向 GAP 鎖,插入記錄時使用,是 LOCK_GAP 的一種特例。

這四種行鎖將是理解並解決資料庫死鎖的關鍵,我們下面將深入研究這四種鎖的特點。但是在介紹這四種鎖之前,讓我們再來看下 MySQL 下鎖的模式。

二、讀鎖 vs. 寫鎖

MySQL 將鎖分成兩類:鎖型別(lock_type)和鎖模式(lock_mode)。鎖型別就是上文中介紹的表鎖和行鎖兩種型別,當然行鎖還可以細分成記錄鎖和間隙鎖等更細的型別,鎖型別描述的鎖的粒度,也可以說是把鎖具體加在什麼地方;而鎖模式描述的是到底加的是什麼鎖,譬如讀鎖或寫鎖。鎖模式通常是和鎖型別結合使用的,鎖模式在 MySQL 的原始碼中定義如下:

1 2 3 4 5 6 7 8 9 /* Basic lock modes */ enum lock_mode { LOCK_IS = 0, /* intention shared */ LOCK_IX, /* intention exclusive */ LOCK_S, /* shared */ LOCK_X, /* exclusive */ LOCK_AUTO_INC, /* locks the auto-inc counter of a table in an exclusive mode*/ ... };
  • LOCK_IS:讀意向鎖;
  • LOCK_IX:寫意向鎖;
  • LOCK_S:讀鎖;
  • LOCK_X:寫鎖;
  • LOCK_AUTO_INC:自增鎖;

將鎖分為讀鎖和寫鎖主要是為了提高讀的併發,如果不區分讀寫鎖,那麼資料庫將沒辦法併發讀,併發性將大大降低。而 IS(讀意向)、IX(寫意向)只會應用在表鎖上,方便表鎖和行鎖之間的衝突檢測。LOCK_AUTO_INC 是一種特殊的表鎖。下面依次進行介紹。

2.1 讀寫鎖

讀鎖和寫鎖都是最基本的鎖模式,它們的概念也比較容易理解。讀鎖,又稱共享鎖(Share locks,簡稱 S 鎖),加了讀鎖的記錄,所有的事務都可以讀取,但是不能修改,並且可同時有多個事務對記錄加讀鎖。寫鎖,又稱排他鎖(Exclusive locks,簡稱 X 鎖),或獨佔鎖,對記錄加了排他鎖之後,只有擁有該鎖的事務可以讀取和修改,其他事務都不可以讀取和修改,並且同一時間只能有一個事務加寫鎖。(注意:這裡說的讀都是當前讀,快照讀是無需加鎖的,記錄上無論有沒有鎖,都可以快照讀)

在其他的資料庫系統中(譬如 MSSQL),我們可能還會看到一種基本的鎖模式:更新鎖(Update locks,簡稱 U 鎖),MySQL 暫時不支援 U 鎖,所以這裡只是稍微瞭解一下。這個鎖主要是用來防止死鎖的,因為多數資料庫在加 X 鎖的時候是先獲取 S 鎖,獲取成功之後再升級成 X 鎖,如果有兩個事務同時獲取了 S 鎖,然後又同時嘗試升級 X 鎖,就會發生死鎖。增加 U 鎖表示有事務對該行有更新意向,只允許一個事務拿到 U 鎖,該事務在發生寫後 U 鎖變 X 鎖,未寫時看做 S 鎖。(疑問:MySQL 更新的時候是直接申請 X 鎖麼?)

2.2 讀寫意向鎖

表鎖鎖定了整張表,而行鎖是鎖定表中的某條記錄,它們倆鎖定的範圍有交集,因此表鎖和行鎖之間是有衝突的。譬如某個表有 10000 條記錄,其中有一條記錄加了 X 鎖,如果這個時候系統需要對該表加表鎖,為了判斷是否能加這個表鎖,系統需要遍歷表中的所有 10000 條記錄,看看是不是某條記錄被加鎖,如果有鎖,則不允許加表鎖,顯然這是很低效的一種方法,為了方便檢測表鎖和行鎖的衝突,從而引入了意向鎖。

意向鎖為表級鎖,也可分為讀意向鎖(IS 鎖)和寫意向鎖(IX 鎖)。當事務試圖讀或寫某一條記錄時,會先在表上加上意向鎖,然後才在要操作的記錄上加上讀鎖或寫鎖。這樣判斷表中是否有記錄加鎖就很簡單了,只要看下錶上是否有意向鎖就行了。意向鎖之間是不會產生衝突的,也不和 AUTO_INC 表鎖衝突,它只會阻塞表級讀鎖或表級寫鎖,另外,意向鎖也不會和行鎖衝突,行鎖只會和行鎖衝突。

下面是各個表鎖之間的相容矩陣:

這個矩陣看上去有點眼花繚亂,其實很簡單,因為是斜對稱的,所以我們用一條斜線把表格分割成兩個部分,只需要看左下角的一半即可。總結起來有下面幾點:

  • 意向鎖之間互不衝突;
  • S 鎖只和 S/IS 鎖相容,和其他鎖都衝突;
  • X 鎖和其他所有鎖都衝突;
  • AI 鎖只和意向鎖相容;

2.3 AUTO_INC 鎖

AUTO_INC 鎖又叫自增鎖(一般簡寫成 AI 鎖),它是一種特殊型別的表鎖,當插入的表中有自增列(AUTO_INCREMENT)的時候可能會遇到。當插入表中有自增列時,資料庫需要自動生成自增值,在生成之前,它會先為該表加 AUTO_INC 表鎖,其他事務的插入操作阻塞,這樣保證生成的自增值肯定是唯一的。AUTO_INC 鎖具有如下特點:

  • AUTO_INC 鎖互不相容,也就是說同一張表同時只允許有一個自增鎖;
  • 自增鎖不遵循二段鎖協議,它並不是事務結束時釋放,而是在 INSERT 語句執行結束時釋放,這樣可以提高併發插入的效能。
  • 自增值一旦分配了就會 +1,如果事務回滾,自增值也不會減回去,所以自增值可能會出現中斷的情況。

顯然,AUTO_INC 表鎖會導致併發插入的效率降低,為了提高插入的併發性,MySQL 從 5.1.22 版本開始,引入了一種可選的輕量級鎖(mutex)機制來代替 AUTO_INC 鎖,我們可以通過引數innodb_autoinc_lock_mode控制分配自增值時的併發策略。引數innodb_autoinc_lock_mode可以取下列值:

  • innodb_autoinc_lock_mode = 0 (traditional lock mode)

    • 使用傳統的 AUTO_INC 表鎖,併發性比較差。
  • innodb_autoinc_lock_mode = 1 (consecutive lock mode)

    • MySQL 預設採用這種方式,是一種比較折中的方法。
    • MySQL 將插入語句分成三類:Simple inserts、Bulk inserts、Mixed-mode inserts。通過分析 INSERT 語句可以明確知道插入數量的叫做Simple inserts,譬如最經常使用的 INSERT INTO table VALUE(1,2) 或 INSERT INTO table VALUES(1,2), (3,4);通過分析 INSERT 語句無法確定插入數量的叫做Bulk inserts,譬如 INSERT INTO table SELECT 或 LOAD DATA 等;還有一種是不確定是否需要分配自增值的,譬如 INSERT INTO table VALUES(1,'a'), (NULL,'b'), (5, 'C'), (NULL, 'd') 或 INSERT ... ON DUPLICATE KEY UPDATE,這種叫做Mixed-mode inserts
    • Bulk inserts 不能確定插入數使用表鎖;Simple inserts 和 Mixed-mode inserts 使用輕量級鎖 mutex,只鎖住預分配自增值的過程,不鎖整張表。Mixed-mode inserts 會直接分析語句,獲得最壞情況下需要插入的數量,一次性分配足夠的自增值,缺點是會分配過多,導致浪費和空洞。
    • 這種模式的好處是既平衡了併發性,又能保證同一條 INSERT 語句分配的自增值是連續的。
  • innodb_autoinc_lock_mode = 2 (interleaved lock mode)

    • 全部都用輕量級鎖 mutex,併發效能最高,按順序依次分配自增值,不會預分配。
    • 缺點是不能保證同一條 INSERT 語句內的自增值是連續的,這樣在複製(replication)時,如果 binlog_format 為 statement-based(基於語句的複製)就會存在問題,因為是來一個分配一個,同一條 INSERT 語句內獲得的自增值可能不連續,主從資料集會出現資料不一致。所以在做資料庫同步時要特別注意這個配置。

可以參考 MySQL 的這篇文件AUTO_INCREMENT Handling in InnoDB瞭解自增鎖,InnoDb 處理自增值的方式,以及在不同的複製模式下可能遇到的問題。

三、細說 MySQL 鎖型別

前面在講行鎖時有提到,在 MySQL 的原始碼中定義了四種類型的行鎖,我們這一節將學習這四種鎖。在我剛接觸資料庫鎖的概念時,我理解的行鎖就是將鎖鎖在行上,這一行記錄不能被其他人修改,這種理解其實很膚淺,因為行鎖也有可能並不是鎖在行上而是行與行之間的間隙上,事實上,我理解的這種鎖是最簡單的行鎖模式:記錄鎖。

3.1 記錄鎖(Record Locks)

記錄鎖是最簡單的行鎖,並沒有什麼好說的。譬如下面的 SQL 語句(id 為主鍵):

1 mysql> UPDATE accounts SET level = 100 WHERE id = 5;

這條 SQL 語句就會在 id = 5 這條記錄上加上記錄鎖,防止其他事務對 id = 5 這條記錄進行修改或刪除。記錄鎖永遠都是加在索引上的,就算一個表沒有建索引,資料庫也會隱式的建立一個索引。如果 WHERE 條件中指定的列是個二級索引,那麼記錄鎖不僅會加在這個二級索引上,還會加在這個二級索引所對應的聚簇索引上(參考上面的加鎖流程一節)。

注意,如果 SQL 語句無法使用索引時會走主索引實現全表掃描,這個時候 MySQL 會給整張表的所有資料行加記錄鎖。如果一個 WHERE 條件無法通過索引快速過濾,儲存引擎層面就會將所有記錄加鎖後返回,再由 MySQL Server 層進行過濾。不過在實際使用過程中,MySQL 做了一些改進,在 MySQL Server 層進行過濾的時候,如果發現不滿足,會呼叫 unlock_row 方法,把不滿足條件的記錄釋放鎖(顯然這違背了二段鎖協議)。這樣做,保證了最後只會持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。可見在沒有索引時,不僅會消耗大量的鎖資源,增加資料庫的開銷,而且極大的降低了資料庫的併發效能,所以說,更新操作一定要記得走索引。

3.2 間隙鎖(Gap Locks)

還是看上面的那個例子,如果 id = 5 這條記錄不存在,這個 SQL 語句還會加鎖嗎?答案是可能有,這取決於資料庫的隔離級別。

還記得我們在上一篇部落格中介紹的資料庫併發過程中可能存在的問題嗎?其中有一個問題叫做幻讀,指的是在同一個事務中同一條 SQL 語句連續兩次讀取出來的結果集不一樣。在 read committed 隔離級別很明視訊記憶體在幻讀問題,在 repeatable read 級別下,標準的 SQL 規範中也是存在幻讀問題的,但是在 MySQL 的實現中,使用了間隙鎖的技術避免了幻讀。

間隙鎖是一種加在兩個索引之間的鎖,或者加在第一個索引之前,或最後一個索引之後的間隙。有時候又稱為範圍鎖(Range Locks),這個範圍可以跨一個索引記錄,多個索引記錄,甚至是空的。使用間隙鎖可以防止其他事務在這個範圍內插入或修改記錄,保證兩次讀取這個範圍內的記錄不會變,從而不會出現幻讀現象。很顯然,間隙鎖會增加資料庫的開銷,雖然解決了幻讀問題,但是資料庫的併發性一樣受到了影響,所以在選擇資料庫的隔離級別時,要注意權衡效能和併發性,根據實際情況考慮是否需要使用間隙鎖,大多數情況下使用 read committed 隔離級別就足夠了,對很多應用程式來說,幻讀也不是什麼大問題。

回到這個例子,這個 SQL 語句在 RC 隔離級別不會加任何鎖,在 RR 隔離級別會在 id = 5 前後兩個索引之間加上間隙鎖。

值得注意的是,間隙鎖和間隙鎖之間是互不衝突的,間隙鎖唯一的作用就是為了防止其他事務的插入,所以加間隙 S 鎖和加間隙 X 鎖沒有任何區別。

3.3 Next-Key Locks

Next-key 鎖是記錄鎖和間隙鎖的組合,它指的是加在某條記錄以及這條記錄前面間隙上的鎖。假設一個索引包含
10、11、13 和 20 這幾個值,可能的 Next-key 鎖如下:

  • (-∞, 10]
  • (10, 11]
  • (11, 13]
  • (13, 20]
  • (20, +∞)

通常我們都用這種左開右閉區間來表示 Next-key 鎖,其中,圓括號表示不包含該記錄,方括號表示包含該記錄。前面四個都是 Next-key 鎖,最後一個為間隙鎖。和間隙鎖一樣,在 RC 隔離級別下沒有 Next-key 鎖,只有 RR 隔離級別才有。繼續拿上面的 SQL 例子來說,如果 id 不是主鍵,而是二級索引,且不是唯一索引,那麼這個 SQL 在 RR 隔離級別下會加什麼鎖呢?答案就是 Next-key 鎖,如下:

  • (a, 5]
  • (5, b)

其中,a 和 b 是 id = 5 前後兩個索引,我們假設 a = 1、b = 10,那麼此時如果插入一條 id = 3 的記錄將會阻塞住。之所以要把 id = 5 前後的間隙都鎖住,仍然是為了解決幻讀問題,因為 id 是非唯一索引,所以 id = 5 可能會有多條記錄,為了防止再插入一條 id = 5 的記錄,必須將下面標記 ^ 的位置都鎖住,因為這些位置都可能再插入一條 id = 5 的記錄:

1 ^ 5 ^ 5 ^ 5 ^ 10 11 13 15

可以看出來,Next-key 鎖確實可以避免幻讀,但是帶來的副作用是連插入 id = 3 這樣的記錄也被阻塞了,這根本就不會引起幻讀問題的。

關於 Next-key 鎖,有一個比較有意思的問題,比如下面這個 orders 表(id 為主鍵,order_id 為二級非唯一索引):

1 2 3 4 5 6 7 8 9 +-----+----------+ | id | order_id | +-----+----------+ | 1 | 1 | | 3 | 2 | | 5 | 5 | | 7 | 5 | | 10 | 9 | +-----+----------+

事務 A 執行下面的 SQL:

1 2 3 4 5 6 7 8 9 mysql> begin; mysql> select * from orders where order_id = 5 for update; +-----+----------+ | id | order_id | +-----+----------+ | 5 | 5 | | 7 | 5 | +-----+----------+ 2 rows in set (0.00 sec)

這個時候不僅 order_id = 5 這條記錄會加上 X 記錄鎖,而且這條記錄前後的間隙也會加上鎖,加鎖位置如下:

1 2 ^ 5 ^ 5 ^ 9

可以看到 (2, 9) 這個區間都被鎖住了,這個時候如果插入 order_id = 4 或者 order_id = 8 這樣的記錄肯定會被阻塞,這沒什麼問題,那麼現在問題來了,如果插入一條記錄 order_id = 2 或者 order_id = 9 會被阻塞嗎?答案是可能阻塞,也可能不阻塞,這取決於插入記錄主鍵的值,感興趣的讀者可以參考這篇部落格

3.4 插入意向鎖(Insert Intention Locks)

插入意向鎖是一種特殊的間隙鎖(所以有的地方把它簡寫成 II GAP),這個鎖表示插入的意向,只有在 INSERT 的時候才會有這個鎖。注意,這個鎖雖然也叫意向鎖,但是和上面介紹的表級意向鎖是兩個完全不同的概念,不要搞混淆了。插入意向鎖和插入意向鎖之間互不衝突,所以可以在同一個間隙中有多個事務同時插入不同索引的記錄。譬如在上面的例子中,id = 1 和 id = 5 之間如果有兩個事務要同時分別插入 id = 2 和 id = 3 是沒問題的,雖然兩個事務都會在 id = 1 和 id = 5 之間加上插入意向鎖,但是不會衝突。

插入意向鎖只會和間隙鎖或 Next-key 鎖衝突,正如上面所說,間隙鎖唯一的作用就是防止其他事務插入記錄造成幻讀,那麼間隙鎖是如何防止幻讀的呢?正是由於在執行 INSERT 語句時需要加插入意向鎖,而插入意向鎖和間隙鎖衝突,從而阻止了插入操作的執行。

3.5 行鎖的相容矩陣

下面我們對這四種行鎖做一個總結,它們之間的相容矩陣如下圖所示:

其中,第一行表示已有的鎖,第一列表示要加的鎖。這個矩陣看起來很複雜,因為它是不對稱的,如果要死記硬背可能會暈掉。其實仔細看可以發現,不對稱的只有插入意向鎖,所以我們先對插入意向鎖做個總結,如下:

  • 插入意向鎖不影響其他事務加其他任何鎖。也就是說,一個事務已經獲取了插入意向鎖,對其他事務是沒有任何影響的;
  • 插入意向鎖與間隙鎖和 Next-key 鎖衝突。也就是說,一個事務想要獲取插入意向鎖,如果有其他事務已經加了間隙鎖或 Next-key 鎖,則會阻塞。

瞭解插入意向鎖的特點之後,我們將它從矩陣中移去,相容矩陣就變成了下面這個樣子:

這個看起來就非常簡單了,可以得出下面的結論:

  • 間隙鎖不和其他鎖(不包括插入意向鎖)衝突;
  • 記錄鎖和記錄鎖衝突,Next-key 鎖和 Next-key 鎖衝突,記錄鎖和 Next-key 鎖衝突;

3.6 在 MySQL 中觀察行鎖

為了更好的理解不同的行鎖,下面我們在 MySQL 中對不同的鎖實際操作一把。有兩種方式可以在 MySQL 中觀察行鎖,第一種是通過下面的 SQL 語句:

1 mysql> select * from information_schema.innodb_locks;

這個命令會打印出 InnoDb 的所有鎖資訊,包括鎖 ID、事務 ID、以及每個鎖的型別和模式等其他資訊。第二種是使用下面的 SQL 語句:

1 mysql> show engine innodb status\G

這個命令並不是專門用來檢視鎖資訊的,而是用於輸出當前 InnoDb 引擎的狀態資訊,包括:BACKGROUND THREAD、SEMAPHORES、TRANSACTIONS、FILE I/O、INSERT BUFFER AND ADAPTIVE HASH INDEX、LOG、BUFFER POOL AND MEMORY、ROW OPERATIONS 等等。其中 TRANSACTIONS 部分會列印當前 MySQL 所有的事務,如果某個事務有加鎖,還會顯示加鎖的詳細資訊。如果發生死鎖,也可以通過這個命令來定位死鎖發生的原因。不過在這之前需要先開啟 Innodb 的鎖監控:

1 2 mysql> set global innodb_status_output = ON; mysql> set global innodb_status_output_locks = ON;

開啟鎖監控之後,使用show engine innodb status命令,會輸出大量的資訊,我們在其中可以找到TRANSACTIONS部分,這裡面就包含了每個事務及相關鎖的資訊,如下所示:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 ------------ TRANSACTIONS ------------ Trx id counter 3125 Purge done for trx's n:o < 3106 undo n:o < 0 state: running but idle History list length 17 LIST OF TRANSACTIONS FOR EACH SESSION: ---TRANSACTION 3124, ACTIVE 10 sec 4 lock struct(s), heap size 1136, 3 row lock(s) MySQL thread id 19, OS thread handle 6384, query id 212 localhost ::1 root TABLE LOCK table `accounts` trx id 3124 lock mode IX RECORD LOCKS space id 53 page no 5 n bits 72 index createtime of table `accounts` trx id 3124 lock_mode X Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 5a119f98; asc Z ;; 1: len 4; hex 80000005; asc ;; RECORD LOCKS space id 53 page no 3 n bits 80 index PRIMARY of table `accounts` trx id 3124 lock_mode X locks rec but not gap Record lock, heap no 4 PHYSICAL RECORD: n_fields 7; compact format; info bits 0 0: len 4; hex 80000005; asc ;; 1: len 6; hex 000000000c1c; asc ;; 2: len 7; hex b70000012b0110; asc + ;; 3: len 4; hex 80000005; asc ;; 4: len 4; hex 80000005; asc ;; 5: len 0; hex ; asc ;; 6: len 4; hex 5a119f98; asc Z ;; RECORD LOCKS space id 53 page no 5 n bits 72 index createtime of table `accounts` trx id 3124 lock_mode X locks gap before rec Record lock, heap no 5 PHYSICAL RECORD: n_fields 2; compact format; info bits 0 0: len 4; hex 5a119fa1; asc Z ;; 1: len 4; hex 8000000a; asc ;;

可以看出,show engine innodb status的輸出比較晦澀,要讀懂它還需要學習一些其他知識,我們這裡暫且不提,後面再專門對其進行介紹。這裡使用information_schema.innodb_locks表來體驗一下 MySQL 中不同的行鎖。

要注意的是,只有在兩個事務出現鎖競爭時才能在這個表中看到鎖資訊,譬如你執行一條 UPDATE 語句,它會對某條記錄加 X 鎖,這個時候information_schema.innodb_locks表裡是沒有任何記錄的。

另外,只看這個表只能得到當前持有鎖的事務,至於是哪個事務被阻塞,可以通過information_schema.innodb_lock_waits表來檢視。

3.6.1 記錄鎖

根據上面的行鎖相容矩陣,記錄鎖和記錄鎖或 Next-key 鎖衝突,所以想觀察到記錄鎖,可以讓兩個事務都對同一條記錄加記錄鎖,或者一個事務加記錄鎖另一個事務加 Next-key 鎖。

事務 A 執行:

1 2 3 4 5 6 7 8 mysql> begin; mysql> select * from accounts where id = 5 for update; +----+----------+-------+ | id | name | level | +----+----------+-------+ | 5 | zhangsan | 7 | +----+----------+-------+ 1 row in set (0.00 sec)

事務 B 執行:

1 2 mysql> begin; mysql> select * from accounts where id = 5 lock in share mode;

事務 B 阻塞,出現鎖競爭,檢視鎖狀態:

1 2 3 4 5 6 7 8 mysql> select * from information_schema.innodb_locks; +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ | 3108:53:3:4 | 3108 | S | RECORD | `accounts` | PRIMARY | 53 | 3 | 4 | 5 | | 3107:53:3:4 | 3107 | X | RECORD | `accounts` | PRIMARY | 53 | 3 | 4 | 5 | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ 2 rows in set, 1 warning (0.00 sec)

3.6.2 間隙鎖

根據相容矩陣,間隙鎖只和插入意向鎖衝突,而且是先加間隙鎖,然後加插入意向鎖時才會衝突。

事務 A 執行(id 為主鍵,且 id = 3 這條記錄不存在):

1 2 3 mysql> begin; mysql> select * from accounts where id = 3 lock in share mode; Empty set (0.00 sec)

事務 B 執行:

1 2 mysql> begin; mysql> insert into accounts(id, name, level) value(3, 'lisi', 10);

事務 B 阻塞,出現鎖競爭,檢視鎖狀態:

1 2 3 4 5 6 7 8 mysql> select * from information_schema.innodb_locks; +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ | 3110:53:3:4 | 3110 | X,GAP | RECORD | `accounts` | PRIMARY | 53 | 3 | 4 | 3 | | 3109:53:3:4 | 3109 | S,GAP | RECORD | `accounts` | PRIMARY | 53 | 3 | 4 | 3 | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+-----------+ 2 rows in set, 1 warning (0.00 sec)

3.6.3 Next-key 鎖

根據相容矩陣,Next-key 鎖和記錄鎖、Next-key 鎖或插入意向鎖衝突,但是貌似很難製造 Next-key 鎖和記錄鎖衝突的場景,也很難製造 Next-key 鎖和 Next-key 鎖衝突的場景(如果你能找到這樣的例子,還望不吝賜教)。所以還是用 Next-key 鎖和插入意向鎖衝突的例子,和上面間隙鎖的例子幾乎一樣。

事務 A 執行(level 為二級索引):

1 2 3 4 5 6 7 8 9 mysql> begin; mysql> select * from accounts where level = 7 lock in share mode; +----+----------+-------+ | id | name | level | +----+----------+-------+ | 5 | zhangsan | 7 | | 9 | liusan | 7 | +----+----------+-------+ 2 rows in set (0.00 sec)

事務 B 執行:

1 2 mysql> begin; mysql> insert into accounts(name, level) value('lisi', 7);

事務 B 阻塞,出現鎖競爭,檢視鎖狀態:

1 2 3 4 5 6 7 8 mysql> select * from information_schema.innodb_locks; +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+----------------+ | lock_id | lock_trx_id | lock_mode | lock_type | lock_table | lock_index | lock_space | lock_page | lock_rec | lock_data | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+----------------+ | 3114:53:5:5 | 3114 | X,GAP | RECORD | `accounts` | level | 53 | 5 | 5 | 0x5A119FA1, 10 | | 3113:53:5:5 | 3113 | S,GAP | RECORD | `accounts` | level | 53 | 5 | 5 | 0x5A119FA1, 10 | +-------------+-------------+-----------+-----------+------------+------------+------------+-----------+----------+----------------+ 2 rows in set, 1 warning (0.00 sec)

可以看到除了鎖住的索引不同之外,Next-key 鎖和間隙鎖之間幾乎看不出任何差異。

四、樂觀鎖 vs. 悲觀鎖

關於 MySQL 下的鎖型別到這裡就告一段落了。在結束這邊部落格之前,我認為還有必要介紹下樂觀鎖和悲觀鎖的概念,這兩個概念聽起來很像是一種特殊的鎖,但實際上它並不是什麼具體的鎖,而是一種鎖的思想。這種思想無論是在操作資料庫時,還是在程式設計專案中,都非常實用。

我們知道,不同的隔離級別解決不同的併發問題,MySQL 能夠根據設定的隔離級別自動管理事務內的鎖,不需要開發人員關心就能避免那些併發問題的發生,譬如在 RC 級別下,開發人員不用擔心會出現髒讀問題,只要正常的寫 SQL 語句就可以了。但對於當前隔離級別無法解決的併發問題,我們就需要自己來處理了,譬如在 MySQL 的 RR 級別下(不是標準的 RR 級別),你肯定會遇到丟失更新問題,對於這種問題,通常有兩種解決思路。其實在講 MVCC 的時候也提到過,解決併發問題的方式除了鎖,還可以利用時間戳或者版本號等等手段。前一種處理資料的方式通常叫做悲觀鎖(Pessimistic Lock),第二種無鎖方式叫做樂觀鎖(Optimistic Lock)

  • 悲觀鎖,顧名思義就是很悲觀,每次拿資料時都假設有別人會來修改,所以每次在拿資料的時候都會給資料加上鎖,用這種方式來避免跟別人衝突,雖然很有效,但是可能會出現大量的鎖衝突,導致效能低下。
  • 樂觀鎖則是完全相反,每次去拿資料的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有改過這個資料,可以使用版本號等機制來判斷。

我們在上一篇部落格中丟失更新那一節舉了一個商品庫存的例子,我們分別用悲觀鎖和樂觀鎖來解決這個問題,以此來體會兩個思想之間的區別以及優缺點。這個例子如下:

譬如商品表的庫存欄位,每次下單之後庫存值需要減 1,大概的流程如下:

  • SELECT name, stock FROM product WHERE id = 100;
  • 判斷 stock 值是否足夠,如果足夠,則下單:if (stock > n) process order;
  • 更新 stock 值,減去下單的商品數量:new_stock = stock - n;
  • UPDATE product SET stock = new_stock WHERE id = 100;

如果兩個執行緒同時下單,很可能就會出現下面這樣的情況:

  • 執行緒 A 獲取庫存值為 10;
  • 執行緒 B 獲取庫存值為 10;
  • 執行緒 A 需要買 5 個商品,校驗通過,並下單;
  • 執行緒 B 需要買 5 個商品,校驗通過,並下單;
  • 執行緒 A 下單完成,更新庫存值為 10 - 5 = 5;
  • 執行緒 B 下單完成,更新庫存值為 10 - 5 = 5;

如果採用悲觀鎖的思想,我們線上程 A 獲取商品庫存的時候對該商品記錄加 X 鎖,並在最後下單完成並更新庫存值之後再釋放鎖,這樣執行緒 B 在獲取庫存值時就會等待,從而也不會出現執行緒 B 併發修改庫存的情況了。

如果採用樂觀鎖的思想,我們不對記錄加鎖,於是執行緒 A 獲取庫存值為 10,執行緒 B 獲取庫存也為 10,然後執行緒 A 更新庫存為 5 的時候使用類似於這樣的 SQL 來校驗當前庫存值是否被修改過:UPDATE product SET stock = new_stock WHERE id = 100 AND stock = 10;如果 UPDATE 成功則認為沒有修改過,下單成功;同樣執行緒 B 更新庫存為 5 的時候也用同樣的方式校驗,很顯然校驗失敗,這個時候我們可以重新查詢最新的庫存值並下單,或者直接丟擲異常提示下單失敗。這種帶條件的更新就是樂觀鎖。但是要注意的是,這種帶條件的更新還可能會遇到ABA 問題(關於 ABA 問題可以參考CAS),解決方法則是為每一條記錄增加一個唯一的版本號欄位,使用版本號欄位來進行判斷。再舉一個很現實的例子,我們通常使用 svn 更新和提交程式碼,也是使用了樂觀鎖的思想,當用戶提交程式碼時,會根據你提交的版本號和程式碼倉庫中最新的版本號進行比較,如果一致則允許提交,如果不一致,則提示使用者更新程式碼到最新版本。

總的來說,悲觀鎖需要使用資料庫的鎖機制來實現,而樂觀鎖是通過程式的手段來實現,這兩種鎖各有優缺點,不可認為一種好於另一種,像樂觀鎖適用於讀多寫少的情況下,即衝突真的很少發生,這樣可以省去鎖的開銷,加大系統的吞吐量。但如果經常產生衝突,上層應用不斷的進行重試,這樣反倒是降低了效能,所以這種情況下用悲觀鎖更合適。雖然使用帶版本檢查的樂觀鎖能夠同時保持高併發和高可伸縮性,但它也不是萬能的,譬如它不能解決髒讀問題,所以在實際應用中還是會和資料庫的隔離級別一起使用。

不僅僅是資料庫,其實在分散式系統中,我們也可以看到悲觀鎖和樂觀鎖的影子。譬如酷殼上的這篇文章《多版本併發控制(MVCC)在分散式系統中的應用》中提到的案例,就是一個典型的提交覆蓋問題,可以通過悲觀鎖或者樂觀鎖來解決。

參考

    1. MySQL 5.7 Reference Manual - LOCK TABLES and UNLOCK TABLES Syntax
    2. MySQL 5.7 Reference Manual - Internal Locking Methods
    3. MySQL 5.7 Reference Manual - InnoDB Locking
    4. MySQL 5.7 Reference Manual - AUTO_INCREMENT Handling in InnoDB
    5. MySQL 5.7 Reference Manual - Clustered and Secondary Indexes
    6. 克魯斯卡爾的部落格 - MySQL總結
    7. 克魯斯卡爾的部落格 - InnoDB 鎖
    8. MySQL InnoDB鎖機制之Gap Lock、Next-Key Lock、Record Lock解析
    9. MySQL加鎖分析
    10. mysql、innodb和加鎖分析
    11. MySQL中的鎖(表鎖、行鎖) 併發控制鎖
    12. 《深入淺出MySQL:資料庫開發、優化與管理維護》
    13. MySQL 樂觀鎖與悲觀鎖