行鎖:InnoDB 替代 MyISAM 的重要原因
- 2019 年 11 月 24 日
- 筆記
來源:慕課網專欄《一線數據庫工程師帶你深入理解 MySQL》 地址:https://www.imooc.com/read/43 作者:馬聽老師
MySQL 5.5 之前的默認存儲引擎是 MyISAM,5.5 之後改成了 InnoDB。InnoDB 後來居上最主要的原因就是:
- InnoDB 支持事務:適合在並發條件下要求數據一致的場景。
- InnoDB 支持行鎖:有效降低由於刪除或者更新導致的鎖定。
本節就一起來探討 InnoDB 的行鎖。
在講解行鎖之前,我們首先來看一下兩階段鎖協議。
1 兩階段鎖
傳統的關係型數據庫加鎖的一個原則是:兩階段鎖原則。
兩階段鎖:鎖操作分為兩個階段,加鎖階段和解鎖階段,並且保證加鎖階段和解鎖階段不相交。在執行語句的時候加上鎖,但並不是語句執行完就立刻釋放鎖,而是要等到事務結束時才釋放。
我們可以通過下面這張表理解兩階段鎖:
序號 |
MySQL 操作 |
解釋 |
鎖階段 |
---|---|---|---|
1 |
begin; |
事務開始 |
|
2 |
insert into …..; |
加 insert 對應的鎖 |
加鎖階段 |
3 |
update table …..; |
加 update 對應的鎖 |
加鎖階段 |
4 |
delete from …..; |
加 delete 對應的鎖 |
加鎖階段 |
5 |
commit; |
事務結束,同時釋放 2、3、4 步驟中加的鎖 |
解鎖階段 |
2 InnoDB 行鎖模式
InnoDB 實現了以下兩種類型的行鎖:
- 共享鎖(S):允許一個事務去讀一行,阻止其他事務獲得相同數據集的排他鎖。
- 排他鎖(X):允許獲得排他鎖的事務更新數據,阻止其他事務取得相同數據集的共享讀鎖和排他寫鎖。
對於普通 select 語句,InnoDB 不會加任何鎖,事務可以通過以下語句顯式給記錄集加共享鎖或排他鎖:
- 共享鎖(S):select * from table_name where … lock in share mode;
- 排他鎖(X):select * from tablbe_name where … for update;
3 InnoDB 行鎖算法
InnoDB 行鎖的三種算法:
- Record Lock:單個記錄上的索引加鎖
- Gap Lock:間隙鎖,對索引項之間的間隙加鎖,但不包括記錄本身
- Next-Key Lock:Gap Lock + Record Lock,鎖定一個範圍,並且鎖定記錄本身。
InnoDB 行鎖實現特點意味着:如果不通過索引條件檢索數據,那麼 InnoDB 將對表中所有記錄加鎖,實際效果跟表鎖一樣。
4 事務隔離級別
不同事務隔離級別對應的行鎖也是不一樣的,因此在講解行鎖的鎖定範圍之前,先簡單聊聊事務隔離級別。事務隔離級別的詳細介紹放在下一章。
MySQL 的 4 種隔離級別:
- Read uncommitted(讀未提交): 在該隔離級別,所有事務都可以看到其他未提交的事務的執行結果。可能會出現臟讀。
- Read Committed(讀已提交,簡稱:RC):一個事務只能看見已經提交事務所做的改變。因為同一事務的其他實例在該實例處理期間可能會有新的 commit,所以可能出現幻讀。
- Repeatable Read(可重複讀,簡稱:RR):這是 MySQL 的默認事務隔離級別,它確保同一事務的多個實例在並發讀取數據時,會看到同樣的數據行。消除了臟讀、不可重複讀,默認也不會出現幻讀。
- Serializable(串行):這是最高的隔離級別,它通過強制事務排序,使之不可能相互衝突,從而解決幻讀問題。
這裡解釋一下臟讀和幻讀:
- 臟讀:讀取未提交的事務。
- 幻讀:一個事務按相同的查詢條件重新讀取以前檢索過的數據,卻發現其他事務插入了滿足其查詢條件的新數據。
5 RC 隔離級別下的行鎖實驗
有時我們可能會思考,某條語句(類似 select * from table_name where a=… for update)是怎麼加鎖的?
要想分析某條 SQL 是怎麼加鎖的,如果其他信息都不知道,那就得分幾種情況了,不同情況加鎖的方式也各不一樣,比較常見的一些情況如下:
- RC 隔離級別,a 字段沒索引。
- RC 隔離級別,a 字段有唯一索引。
- RC 隔離級別,a 字段有非唯一索引。
- RR 隔離級別,a 字段沒索引。
- RR 隔離級別,a 字段有唯一索引。
- RR 隔離級別,a 字段有非唯一索引。
- ……
Read uncommitted 和 Serializable 這兩種隔離級別在生產環境基本不用,就不做分析了。
我們先驗證 RC 隔離級別下的幾種情況,RR 隔離級別的實驗放在下節:
首先創建測試表及寫入數據:
use muke; drop table if exists t16; CREATE TABLE `t16` ( `id` int(11) NOT NULL AUTO_INCREMENT, `a` int(11) NOT NULL, `b` int(11) NOT NULL, `c` int(11) NOT NULL, PRIMARY KEY (`id`), UNIQUE KEY `uniq_a` (`a`) USING BTREE, KEY `idx_c` (`c`) ) ENGINE=InnoDB CHARSET=utf8mb4; insert into t16(a,b,c) values (1,1,1),(2,2,2),(3,3,3),(4,4,3);
讓我們開始實驗吧!
5.1 通過非索引字段查詢
我們首先來看一下條件字段不使用索引的例子:
session1 |
session2 |
---|---|
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
begin; |
|
use muke;select * from t16 where b=1 for update;……1 row in set (0.00 sec) |
|
|
use muke;select * from t16 where b=2 for update;(等待) |
commit; |
select * from t16 where b=2 for update;……1 row in set (6.81 sec)(session1 執行 commit 後立馬返回結果) |
這裡解釋一下為什麼要用 for update? 我們常使用的查詢語句,比如 select * from t16 where b=1 屬於快照讀,是不會看到別的事務插入的數據的。 而在查詢語句後面加了 for update 顯式給記錄集加了排他鎖,也就讓查詢變成了當前讀。插入、更新、刪除操作,都屬於當前讀。其實也就可以理解 select … for update 是為了讓普通查詢獲得插入、更新、刪除操作時所獲得的鎖。
表面看起來 session1 只給了 b=1 這一行加了排他鎖,但 session2 在請求其他行的排他鎖時,卻出現了鎖等待。看下圖:

由於 b 字段沒有索引,因此只能走聚簇索引,進行全表掃描。從上圖中可以看到,滿足條件的記錄有一條,但是聚簇索引上的所有記錄,都被加上了 X 鎖。
為什麼不是只在滿足條件的記錄上加鎖呢?
這是因為在 MySQL 中,如果一個條件無法通過索引快速過濾,那麼存儲引擎層面就會將所有記錄加鎖後返回,然後由 server 層進行過濾。因此也就把所有記錄都鎖上了。
當然 MySQL 在這裡有一些改進的,在 server 層過濾掉不滿足條件的數據後,會把不滿足條件的記錄放鎖。保證了最後只會持有滿足條件的鎖,但是每條記錄的加鎖操作還是不會省略。
總結:沒有索引的情況下,InnoDB 的當前讀會對所有記錄都加鎖。所以在工作中應該特別注意 InnoDB 這一特性,否則可能會產生大量的鎖衝突。
5.2 通過唯一索引查詢
我們再來看一下條件字段有唯一索引的例子:
session1 |
session2 |
---|---|
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
begin;use muke;select * from t16 where a=1 for update;……1 row in set (0.00 sec) |
|
|
use muke;select * from t16 where a=2 for update;……1 row in set (0.00 sec) |
|
select * from t16 where a=1 for update;(等待) |
commit; |
select * from t16 where a=1 for update;……1 row in set (4.70 sec)(session1提交後,馬上返回結果) |
session1 給了 a=1 這一行加了排他鎖,在 session2 中請求其他行的排他鎖時,不會發生等待;但是在 session2 中請求 a=1 這一行的排他鎖時,會發生等待。看下圖:

由於 a 是唯一索引,因此 select * from t16 where a=1 for update;(後面稱為 SQL2) 語句會選擇走 a 列的索引進行條件過濾,在找到 a=1 的記錄後,會將唯一索引上 a=1 索引記錄上加 X 鎖,同時,會根據讀取到的 id 列,回到聚簇索引,然後將 id=1 對應的聚簇索引項加 X 鎖。索引的原理可以複習第 10 節 《為什麼添加索引能提高查詢速度》
為什麼聚簇索引上的記錄也要加鎖呢?
比如,並發的一條 SQL,是通過主鍵索引來更新:update t16 set b=10 where id =1; 如果 SQL2 沒有將主鍵索引上的記錄加鎖,那麼並發的 update 並不知道 SQL2 在執行,所以如果 update 執行了,就違背了同一記錄上的更新或者刪除需要串行執行的約束。
總結:如果查詢的條件是唯一索引,那麼 SQL 需要在滿足條件的唯一索引上加鎖,並且會在對應的聚簇索引上加鎖。
5.3 通過非唯一索引查詢
我們再來看一下條件字段有非唯一索引的例子:
session1 |
session2 |
session3 |
---|---|---|
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
begin; |
|
|
use muke;select * from t16 where c=3 for update;……2 rows in set (0.00 sec) |
|
|
|
use muke;select * from t16 where a=1 for update;……1 row in set (0.00 sec) |
use muke;select * from t16 where a=2 for update;……1 row in set (0.00 sec) |
|
select * from t16 where a=3 for update;(等待) |
select * from t16 where a=4 for update;(等待) |
commit; |
select * from t16 where a=3 for update;…..(session1提交後,馬上返回結果) |
select * from t16 where a=4 for update;…..(session1提交後,馬上返回結果) |
我們在滿足條件 c=3 的數據上加了排他鎖,如上面結果,就是第 3、4 行。因此第 1、2 行的數據沒被鎖,而 3、4 行的數據被鎖了。如下圖:

通過上圖可以看到,在 a 字段的非唯一索引上,滿足 c=3 的所有記錄,都被加了鎖。同時,對應的主鍵索引上的記錄也都加上了鎖。與通過唯一索引查詢的情況相比,唯一索引查詢最多有一行記錄被鎖,而非唯一索引將會把滿足條件的所有記錄都加上鎖。
總結:如果查詢的條件是非唯一索引,那麼 SQL 需要在滿足條件的非唯一索引上都加上鎖,並且會在它們對應的聚簇索引上加鎖。
6 總結
今天我們聊了一下 InnoDB 行鎖,這是 InnoDB 替代 MyISAM(只支持表鎖)的一個比較重要的原因。
在文稿的開始,跟大家講解了兩階段鎖、行鎖模式、行鎖算法以及事務隔離級別等。
我們做了 RC 隔離級別下不同場景的行鎖實驗,比較重要的一點是:在更新數據時,如果條件字段沒索引,則表中所有記錄都會被加上 X 鎖。所以在工作中應該儘可能的讓查詢走索引。
本節講解了 RC 隔離級別的鎖實驗,在下節會講解 RR 隔離級別下的行鎖情況,從而理解間隙鎖的意義。
7 問題
還是拿本節的測試表 t16,進行如下實驗:
session1 |
session2 |
---|---|
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
set session transaction_isolation='READ-COMMITTED';/* 設置會話隔離級別為RC*/ |
begin; |
begin; |
|
use muke;select * from t16 where c=3 for update;Result1 |
use muke;insert into t16(a,b,c) values (5,5,3); |
|
commit; |
|
|
select * from t16 where c=3 for update;Result2 |
|
commit; |
Result1 和 Result 結果是相同的還是不同的,你可以實驗一下,然後分析為什麼會出現這種情況?歡迎把你的思路分享在留言區一起討論,我也會把結果驗證和原因分析放在下一節。
8 參考資料
何登成的 github:https://github.com/hedengcheng/tech/tree/master/database/MySQL
《深入淺出 MySQL》(第 2 版):20.3.4 InnoDB 行鎖實現方式
更多 MySQL 乾貨,見慕課網專欄《一線數據庫工程師帶你深入理解 MySQL》
專欄知識圖如下:
