InnoDB 鎖
數據庫使用鎖是為了支持更好的并發,提供數據的完整性和一致性。InnoDB是一個支持行鎖的存儲引擎,鎖的類型有:共享鎖(S)、排他鎖(X)、意向共享(IS)、意向排他(IX)。為了提供更好的并發,
InnoDB提供了非鎖定讀:不需要等待訪問行上的鎖釋放,讀取行的一個快照。該方法是通過InnoDB的一個特性:MVCC來實現的。
MySQL InnoDB存儲引擎,實現的是基于多版本的并發控制協議——MVCC (Multi-Version Concurrency Control) (注:與MVCC相對的,是基于鎖的并發控制,Lock-Based Concurrency Control)。MVCC最大的好處,相信也是耳熟能詳:讀不加鎖,讀寫不沖突。在讀多寫少的OLTP應用中,讀寫不沖突是非常重要的,極大的增加了系統的并發性能,這也是為什么現階段,幾乎所有的RDBMS,都支持了MVCC。
在MVCC并發控制中,讀操作可以分成兩類:快照讀 (snapshot read)與當前讀 (current read)。快照讀,讀取的是記錄的可見版本 (有可能是歷史版本),不用加鎖。當前讀,讀取的是記錄的最新版本,并且,當前讀返回的記錄,都會加上鎖,保證其他事務不會再并發修改這條記錄。
在一個支持MVCC并發控制的系統中,哪些讀操作是快照讀?哪些操作又是當前讀呢?以MySQL InnoDB為例:
-
快照讀:簡單的select操作,屬于快照讀,不加鎖。(當然,也有例外,下面會分析)
- select * from table where ?;
-
當前讀:特殊的讀操作,插入/更新/刪除操作,屬于當前讀,需要加鎖。
select * from table where ? lock in share mode;
select * from table where ? for update;
insert into table values (…);
update table set ? where ?;
delete from table where ?;
所有以上的語句,都屬于當前讀,讀取記錄的最新版本。并且,讀取之后,還需要保證其他并發事務不能修改當前記錄,對讀取記錄加鎖。其中,除了第一條語句,對讀取記錄加S鎖 (共享鎖)外,其他的操作,都加的是X鎖 (排它鎖)。
共享鎖【S鎖】
又稱讀鎖,若事務T對數據對象A加上S鎖,則事務T可以讀A但不能修改A,其他事務只能再對A加S鎖,而不能加X鎖,直到T釋放A上的S鎖。這保證了其他事務可以讀A,但在T釋放A上的S鎖之前不能對A做任何修改。排他鎖【X鎖】
又稱寫鎖。若事務T對數據對象A加上X鎖,事務T可以讀A也可以修改A,其他事務不能再對A加任何鎖,直到T釋放A上的鎖。這保證了其他事務在T釋放A上的鎖之前不能再讀取和修改A。
為什么將 插入/更新/刪除 操作,都歸為當前讀?可以看看下面這個 更新 操作,在數據庫中的執行流程:
從圖中,可以看到,一個Update操作的具體流程。當Update SQL被發給MySQL后,MySQL Server會根據where條件,讀取第一條滿足條件的記錄,然后InnoDB引擎會將第一條記錄返回,并加鎖 (current read)。待MySQL Server收到這條加鎖的記錄之后,會再發起一個Update請求,更新這條記錄。一條記錄操作完成,再讀取下一條記錄,直至沒有滿足條件的記錄為止。因此,Update操作內部,就包含了一個當前讀。同理,Delete操作也一樣。Insert操作會稍微有些不同,簡單來說,就是Insert操作可能會觸發Unique Key的沖突檢查,也會進行一個當前讀。
MySQL/InnoDB定義的4種隔離級別:
Read Uncommited
可以讀取未提交記錄。此隔離級別,不會使用,忽略。
Read Committed (RC)
快照讀:忽略,本文不考慮。
當前讀:RC隔離級別保證對讀取到的記錄加鎖 (記錄鎖),存在幻讀現象。
Repeatable Read (RR)
快照讀:忽略,本文不考慮。
當前讀:RR隔離級別保證對讀取到的記錄加鎖 (記錄鎖),同時保證對讀取的范圍加鎖,新的滿足查詢條件的記錄不能夠插入 (間隙鎖),不存在幻讀現象。注意:這里的不存在幻讀,是指使用select ... for update 在同一個事務中查詢,不會出現兩次不一樣的結果
Serializable
從MVCC并發控制退化為基于鎖的并發控制。不區別快照讀與當前讀,所有的讀操作均為當前讀,讀加讀鎖 (S鎖),寫加寫鎖 (X鎖)。
Serializable隔離級別下,讀寫沖突,因此并發度急劇下降,在MySQL/InnoDB下不建議使用。
上面說的當前讀就是上面列出來的 select .. for update, update , delete, insert 等語句
加鎖情況
我們分析一下 RR RC級別下配合不同索引情況的加鎖情況
表goods 定義是 :
id: 主鍵
name: unique key
stock :無索引
組合一:id主鍵+RC
--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods ;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 1 | prod11 | 15 |
+----+--------+-------+
mysql> update goods set stock =20 where id =1
# 這里已經鎖住了id=1的記錄, 因為上面說了update,delete, insert, select ... for update都會讀當前讀,會觸發鎖機制
# 所以用這幾個任何一個命令都能鎖住記錄
mysql> select * from goods where id = 1 for update;
# 同樣鎖住id=1的記錄
--------------------- SESSION 2 -------------------------
mysql> select * from goods where id=1;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 1 | prod11 | 15 |
+----+--------+-------+
1 row in set (0.00 sec)
# 默認的select 不會使用鎖,它是快照讀,不是當前讀
mysql> select * from goods where id=1 for update;
# 這里會阻塞直到session 1事務結束
結論:id是主鍵時,此SQL只需要在id=1這條記錄上加X鎖即可。
組合二:id唯一索引+RC
--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods where name="prod12" for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 2 | prod12 | 1000 |
+----+--------+-------+
--------------------- SESSION 2 -------------------------
mysql> update goods set stock =20 where id=2;
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
mysql> update goods set stock =20 where name="prod12";
ERROR 1205 (HY000): Lock wait timeout exceeded; try restarting transaction
# 使用唯一索引的時候, 無論使用唯一索引name, 還是主鍵索引id 都不能讀到當前讀,這是因為唯一索引會把唯一索引和主鍵索引都加鎖
此組合中,name是unique索引,而主鍵是id列。此時,加鎖的情況由于組合一有所不同。由于name是unique索引,因此delete語句會選擇走name列的索引進行where條件的過濾,在找到name="prod12"的記錄后,首先會將unique索引上的name="prod12"索引記錄加上X鎖,同時,會根據讀取到的id列,回主鍵索引(聚簇索引),然后將聚簇索引上的id=2 對應的主鍵索引項加X鎖。為什么聚簇索引上的記錄也要加鎖?試想一下,如果并發的一個SQL,是通過主鍵索引來更新:update goods set name= "prod30" where id=2; 此時,如果delete語句沒有將主鍵索引上的記錄加鎖,那么并發的update就會感知不到delete語句的存在,違背了同一記錄上的更新/刪除需要串行執行的約束。
所以主鍵和唯一索引都要加X鎖,防止別的update, delete 會使用主鍵來查詢修改
結論 若name列是unique列,其上有unique索引。那么SQL需要加兩個X鎖,一個對應于name unique索引上的name="prod12"的記錄,另一把鎖對應于聚簇索引上的[id=2, name="prod"]的記錄。
組合三:非唯一索引+RC
跟組合二差不多,與組合二唯一的區別在于,組合二最多只有一個滿足等值查詢的記錄,而組合三會將所有滿足查詢條件的記錄都加鎖。結論 對應的所有滿足SQL查詢條件的記錄,都會被加鎖。同時,這些記錄在主鍵索引上的記錄,也會被加鎖。
--------------------- SESSION 1 -------------------------
mysql> select * from goods where id=7
-> ;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 7 | prod17 | 107 |
+----+--------+-------+
1 row in set (0.00 sec)
mysql> delete from goods where stock=107;
Query OK, 1 rows affected (0.00 sec)
--------------------- SESSION 2 -------------------------
mysql> select * from goods where stock=107 for update;
# 普通索引 被鎖 阻塞
mysql> select * from goods where id=7 for update;
# 聚簇索引(主鍵索引被鎖)
mysql> select * from goods where name=7 for update;
# 其他字段頁被鎖,因為主鍵索引被鎖,整行被鎖
組合四:id無索引+RC
stock去掉索引
--------------------- SESSION 1 -------------------------
mysql> begin;
mysql> select * from goods where stock=20 for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 5 | prod15 | 20 |
| 23 | prod21 | 20 |
+----+--------+-------+
# 使用stock 來查詢,stock是沒有索引
--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> select * from goods where id=1 for update
mysql> select * from goods for update;
mysql> update goods set stock =20 where 1;
# 無論查什么當前讀,更新記錄,都被阻塞,說明整個表都被鎖住了。
由于stock列上沒有索引,因此只能走聚簇索引,進行全部掃描。從圖中可以看到,滿足條件的記錄有兩條,但是,聚簇索引上所有的記錄,都被加上了X鎖。無論記錄是否滿足條件,全部被加上X鎖。既不是加表鎖,也不是在滿足條件的記錄上加行鎖。
有人可能會問?為什么不是只在滿足條件的記錄上加鎖呢?這是由于MySQL的實現決定的。如果一個條件無法通過索引快速過濾,那么存儲引擎層面就會將所有記錄加鎖后返回,然后由MySQL Server層進行過濾。因此也就把所有的記錄,都鎖上了。
注:在實際的實現中,MySQL有一些改進,在MySQL Server過濾條件,發現不滿足后,會調用unlock_row方法,把不滿足條件的記錄放鎖 (違背了2PL的約束)。這樣做,保證了最后只會持有滿足條件記錄上的鎖,但是每條記錄的加鎖操作還是不能省略的。
結論:若stock列上沒有索引,SQL會走聚簇索引的全掃描進行過濾,由于過濾是由MySQL Server層面進行的。因此每條記錄,無論是否滿足條件,都會被加上X鎖。但是,為了效率考量,MySQL做了優化,對于不滿足條件的記錄,會在判斷后放鎖,最終持有的,是滿足條件的記錄上的鎖,但是不滿足條件的記錄上的加鎖/放鎖動作不會省略。同時,優化也違背了2PL的約束。
注意!!!! 這里說的對不滿足條件記錄會有 加鎖\放鎖的動作,但是!!實際操作中,還是所有記錄都被鎖住了,根本沒有放鎖?這是為什么??
組合五:id主鍵+RR
這個跟 id主鍵+RC組合效果一樣,都是鎖住被查詢出來的記錄
組合六: id唯一索引+RR
這個跟 id唯一索引+RC組合效果一樣,都是將唯一索引和聚簇索引的記錄鎖住
組合七:id非唯一索引+RR
還記得前面提到的MySQL的四種隔離級別的區別嗎?RC隔離級別允許幻讀,而RR隔離級別,不允許存在幻讀。但是在組合五、組合六中,加鎖行為又是與RC下的加鎖行為完全一致。那么RR隔離級別下,如何防止幻讀呢?問題的答案,就在組合七中揭曉。
我們先看看如果是級別RC,出現的幻讀情況
--------------------- SESSION 1 -------------------------
# RC級別
mysql> begin;
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 21 | prod20 | 15 |
| 22 | prod22 | 15 |
+----+--------+-------+
--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> insert into goods values(24, 'prod24', 15);
# 這里依然可以插入stock=15的記錄,因為session 1直接對21 22兩條stock=15記錄加鎖了
mysql> commit;
--------------------- SESSION 1 -------------------------
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 21 | prod20 | 15 |
| 22 | prod22 | 15 |
| 24 | prod24 | 15 |
+----+--------+-------+
# 多了session 2提交的數據,同一個事務兩次select for update 居然不同了。 這就是幻讀!!
再看看 RR級別會不會出現幻讀
--------------------- SESSION 1 -------------------------
# RR級別
mysql> begin;
mysql> select * from goods where stock=15 for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 21 | prod20 | 15 |
| 22 | prod22 | 15 |
+----+--------+-------+
--------------------- SESSION 2 -------------------------
mysql> begin;
mysql> insert into goods values(24, 'prod24', 15);
# 這里跟RC級別不一樣了, stock=15的24記錄根本插不進去!
# 這就是為什么RR級別不會出現幻讀的原因,因為不能給其他事務插足
RR與RC 幻讀總結
RR隔離級別其實這個多出來的GAP鎖,相對于RC隔離級別,不會出現幻讀的關鍵。確實,GAP鎖鎖住的位置,也不是記錄本身,而是兩條記錄之間的GAP。所謂幻讀,就是同一個事務,連續做兩次當前讀 (例如:select * from goods where stock=15 for update;),那么這兩次當前讀返回的是完全相同的記錄 (記錄數量一致,記錄本身也一致),第二次的當前讀,不會比第一次返回更多的記錄 (幻象)。
如何保證兩次當前讀返回一致的記錄,那就需要在第一次當前讀與第二次當前讀之間,其他的事務不會插入新的滿足條件的記錄并提交。為了實現這個功能,GAP鎖應運而生。
mysql> select * from goods order by stock;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 7 | prod17 | 10 |
| 8 | prod18 | 10 |
| 22 | prod22 | 15 |
| 21 | prod20 | 15 |
| 23 | prod21 | 20 |
| 5 | prod15 | 20 |
| 10 | pro10 | 50
+----+--------+-------+
上面的記錄所示,有哪些位置可以插入新的滿足條件的項 stock= 15,考慮到B+樹索引的有序性,stock索引的存儲一定是有序的,滿足條件的項一定是連續存放的。
所以stock在[10, 15]之間, [15, 20]之間都是可以被其他事務插入stock=15的記錄的。因此要想杜絕幻讀,這個gap鎖也就是間隙鎖,必須鎖住10-15, 15-20之間的記錄。
我們繼續看session 2, 上面示例看到不能插入stock=15的記錄,其實10-15, 15-20之間都是不可以插入的
--------------------- SESSION 2 -------------------------
mysql> insert into goods values('', 'prod24', 11);
mysql> insert into goods values('', 'prod24', 13);
mysql> insert into goods values('', 'prod24', 18);
mysql> insert into goods values('', 'prod24', 19);
# 在10-15, 15-20之間的記錄都不能插入,因為他們都有可能被放入stock=15的記錄
mysql> insert into goods values('', 'prod24', 21);
Query OK, 1 row affected, 1 warning (0.00 sec)
mysql> insert into goods values('', 'prod24', 8);
Query OK, 1 row affected, 1 warning (0.00 sec)
# 超出這個范圍的是可以插入的。
如果我們查的stock=14沒有數據,那么會不會也有gap鎖?答案是:有的。
我發現如果索引是主鍵也會有這個gap鎖,當然查詢的是一個范圍
--------------------- SESSION 1 -------------------------
mysql> select * from goods where id > 16 for update;
+----+--------+-------+
| id | name | stock |
+----+--------+-------+
| 21 | prod20 | 15 |
| 22 | prod22 | 15 |
| 23 | prod21 | 20 |
+----+--------+-------+
--------------------- SESSION 2 -------------------------
mysql> insert into goods values(9, 'prod24', 11);
ERROR 1062 (23000): Duplicate entry '9' for key 'PRIMARY'
mysql> insert into goods values(12, 'prod24', '');
# 發現>16的 還有10-15這個區間也被鎖住了
總結 只要是范圍查詢,都會有gap鎖。
組合八:id無索引+RR
如圖,這是一個很恐怖的現象。首先,聚簇索引上的所有記錄,都被加上了X鎖。其次,聚簇索引每條記錄間的間隙(GAP),也同時被加上了GAP鎖。這個示例表,只有6條記錄,一共需要6個記錄鎖,7個GAP鎖。試想,如果表上有1000萬條記錄呢?
在這種情況下,這個表上,除了不加鎖的快照度,其他任何加鎖的并發SQL,均不能執行,不能更新,不能刪除,不能插入,全表被鎖死。
當然,跟組合四類似,這個情況下,MySQL也做了一些優化,就是所謂的semi-consistent read。semi-consistent read開啟的情況下,對于不滿足查詢條件的記錄,MySQL會提前放鎖。針對上面的這個用例,就是除了記錄[d,10],[g,10]之外,所有的記錄鎖都會被釋放,同時不加GAP鎖。semi-consistent read如何觸發:要么是read committed隔離級別;要么是Repeatable Read隔離級別,同時設置了 innodb_locks_unsafe_for_binlog 參數
結論:在Repeatable Read隔離級別下,如果進行全表掃描的當前讀,那么會鎖上表中的所有記錄,同時會鎖上聚簇索引內的所有GAP,杜絕所有的并發 更新/刪除/插入 操作。當然,也可以通過觸發semi-consistent read,來緩解加鎖開銷與并發影響,但是semi-consistent read本身也會帶來其他問題,不建議使用。
組合九:Serializable
Serializable隔離級別,影響的是SQL1:select * from t1 where id = 10; 這條SQL,在RC,RR隔離級別下,都是快照讀,不加鎖。但是在Serializable隔離級別,SQL1會加讀鎖,也就是說快照讀不復存在,MVCC并發控制降級為Lock-Based CC。
結論:在MySQL/InnoDB中,所謂的讀不加鎖,并不適用于所有的情況,而是隔離級別相關的。Serializable隔離級別,讀不加鎖就不再成立,所有的讀操作,都是當前讀。
鎖總結
- 在MVCC(基于多版本的并發控制協議)并發控制中,讀操作可以分成兩類:快照讀 (snapshot read)與當前讀 (current read)。
- 2PL (二階段鎖):Two-Phase Locking。說的是鎖操作分為兩個階段:加鎖階段與解鎖階段,并且保證加鎖階段與解鎖階段不相交。
- 主鍵索引(聚簇索引) id主鍵+RC :只會在匹配的主鍵上加X鎖
- id唯一索引+RC: id是unique索引,而主鍵是name列。此時,由于id是unique索引,因此delete語句會選擇走id列的索引進行where條件的過濾,在找到id=10的記錄后,首先會將unique索引上的id=10索引記錄加上X鎖(注意:這里是給索引加鎖,innodb的二級索引只會帶上主鍵索引數據,其他數據需要回行查詢),同時,會根據讀取到的name列,回主鍵索引(聚簇索引),然后將聚簇索引上的name = ‘d’ 對應的主鍵索引項加X鎖(注意:由于聚簇索引本身就是帶上行數據,所以要真正鎖這個聚簇索引才能真正鎖行!)。為什么聚簇索引上的記錄也要加鎖?試想一下,如果并發的一個SQL,是通過主鍵索引來更新:update t1 set id = 100 where name = ‘d’; 此時,如果delete語句沒有將主鍵索引上的記錄加鎖,那么并發的update就會感知不到delete語句的存在,違背了同一記錄上的更新/刪除需要串行執行的約束。刪除需要串行執行的約束**
- id非唯一索引+RC :也是會同時鎖匹配的索引和指向的主鍵索引,跟上面那個原理一樣,只不過這個非唯一索引匹配多條記錄。(同樣道理,鎖住普通索引后,還得鎖聚簇索引才行)
- id無索引+RC:若id列上沒有索引,SQL會走聚簇索引的全掃描進行過濾,由于過濾是由MySQL Server層面進行的。因此每條記錄,無論是否滿足條件,都會被加上X鎖。但是,為了效率考量,MySQL做了優化,對于不滿足條件的記錄,會在判斷后放鎖,最終持有的,是滿足條件的記錄上的鎖,但是不滿足條件的記錄上的加鎖/放鎖動作不會省略。同時,優化也違背了2PL的約束。
- id主鍵+RR: 同 id主鍵+RC,因為他們都是精確到記錄,所以就加在主鍵索引上就可以了
- id唯一索引+RR 同id唯一索引+RC 兩個X鎖,id唯一索引滿足條件的記錄上一個,對應的聚簇索引上的記錄一個。
-
id非唯一索引+RR 幻讀的重要分界點:
- RC級別下,session1查詢的select .. where id=7 for update, (非唯一索引),查出有2條記錄, session2事務插入一個id=7的記錄(為什么可以插入?:因為session1的 where id=7 for update, 鎖住了普通索引索引7,和主鍵索引比如a 和 b , 那么此時插入id=7的數據時可以的,因為新插入的數據,首先新的主鍵,沒有被鎖, 而且索引7的兩條記錄是被鎖住,但是新加的記錄沒有被鎖。所以可以繼續插入,session2 這時commit事務,session1 再次查詢select .. where id=7 for update, 就會出現3條記錄,這就是RC級別的幻讀;)
- RR級別下,session1查詢的select .. where id=7 for update, (非唯一索引),查出有2條記錄,session2事務插入一個id=7的記錄,這里就是跟RC級別的最大差別,因為這時的插入時被阻塞的,不能插進去的!這就使得幻讀不能出現,因為根本不允許插入。為什么?因為gap鎖,gap鎖在索引id(非唯一索引)的前后都加了鎖,不允許這中間再出現可能的數據
死鎖原理與分析
--------------------- SESSION 1 -------------------------
mysql> select * from goods where id =1 for update;
--------------------- SESSION 2 -------------------------
mysql> update goods set stock=120 where id=4;
# 兩個session 各自維護一個鎖
--------------------- SESSION 1 -------------------------
mysql> select * from goods where id =4 for update;
# 阻塞中,因為鎖再session2
--------------------- SESSION 2 -------------------------
update goods set stock=120 where id=1;
# 死鎖出現
session1 提示死鎖:
ERROR 1213 (40001): Deadlock found when trying to get lock; try restarting transaction
sesseion2 執行成功
Deadlock found when trying to get lock; try restarting transaction
說明innodb會檢測死鎖
死鎖的發生與否,并不在于事務中有多少條SQL語句,死鎖的關鍵在于:兩個(或以上)的Session加鎖的順序不一致。而使用本文上面提到的,分析MySQL每條SQL語句的加鎖規則,分析出每條語句的加鎖順序,然后檢查多個并發SQL間是否存在以相反的順序加鎖的情況,就可以分析出各種潛在的死鎖情況,也可以分析出線上死鎖發生的原因。
參考
重點好文:MySQL 加鎖處理分析
Innodb鎖機制:Next-Key Lock 淺談
MySQL 四種事務隔離級的說明
Innodb中的事務隔離級別和鎖的關系
MySQL中的鎖(表鎖、行鎖)
mysql、innodb和加鎖分析