一、數據庫隔離級別
一般來講,數據庫的隔離級別分為讀未提交、讀已提交(read commit,rc)、可重復讀(read reapeat,rr)、串行化四個級別。在mysql中默認隔離級別是rr。讀未提交存在臟讀問題(A事務讀到B事務未提交的數據),讀已提交存在重復讀問題(A事務讀取兩次數據a,期間a被B事務修改后提交,兩次數據不一致),可重復讀存在幻讀問題(A事務讀取兩次a=1的數據,期間B事務插入了一天a=1的數據,導致兩次讀取結果不一致)。
mysql中通過mvcc解決了臟讀和重復讀的問題,其中rr是在事務開啟時,創建read view。rc在每次查詢時創建read view。實際上rc隔離級別上不存在幻讀問題,所以可以使用rc+row格式的binlog組合避免幻讀。
rr隔離離別下,正常的查詢語句其實也不存在幻讀問題。但是一些update/delete語句采用的是當前讀,這會導致只有行鎖的情況下,產生幻讀,假設沒有間隙鎖,當前讀中也會出現重復讀的問題。mysql在rr隔離級別下解決幻讀問題,采用的是行鎖+間隙鎖,兩者合稱next-key lock。
我們知道mysql的行鎖實際上是加在索引上的,所以進入正題前,我還多聊幾句索引與行鎖。
二、行鎖
在innodb引擎下才有行鎖,
行鎖是兩階段鎖,在事務結束后才會釋放。行鎖有分為讀鎖和寫鎖,兩者關系如下圖:

跟行鎖沖突的是另一個行鎖。
三、索引
索引可以根據存儲的數據,分為主鍵索引與非主鍵索引。其中主鍵索引又稱聚簇索引,它的葉子節點存整行的值,實際數據就存儲在這個索引上,即表和索引存在一起。非主鍵索引又稱非聚簇索引(二級索引),它的葉子節點存的是主鍵的數據。所以使用非覆蓋的非聚簇索引會引起回表操作。
之前說過行鎖實際加載索引上,所以如果一個update語句的where條件中,如果沒有明確的索引時,會導致所有行均被加上行鎖(所有間隙也會加上間隙鎖)
四、幻讀是如何產生的
接下來我們先來聊一下幻讀是怎么產生的。我們可以先建表如下:
CREATE TABLE `t` (
`id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, `d` int(11) DEFAULT NULL, PRIMARY KEY (`id`), KEY `c` (`c`) ) ENGINE=InnoDB; insert into t values(0,0,0),(5,5,5), (10,10,10),(15,15,15),(20,20,20),(25,25,25);
對於t表如果如下操作:
|
|
session A |
session B |
session C |
| t1 |
begin update t set d=100 where c=5 |
|
|
| t2 |
|
update t set c=5 where id=0 |
|
| t3 |
|
|
insert into t values(50,5,50) |
| t4 |
commit |
|
|
在只有行鎖的情況下。如果在t1,t2與t3之間,t4時刻分別用select * from t where c=5 for update進行當前讀,會發現當前讀中出現重復讀與幻讀的問題。但這並不是最嚴重的的問題,同樣的只考慮行鎖的情況下,事務提交順序是b->c->a。最后的實際數據是(5,5,100),(0,5,0),(100,5,100)
但是binlog中記錄的sql如下:
update t set c=5 where id=0 insert into t values(50,5,50) update t set d=100 where c=5
這樣binlog執行后產生的數據是(5,5,100),(0,5,100),(50,5,100)。此處id為0和id為5的數據均和預期不一致。
為了解決上面的問題,innodb引入了間隙鎖(gap lock)
五、間隙鎖
索引上的記錄之間,是有間隙的,上文中的表插入數據后,其間隙如下:

我們可以對上圖中的間隙加鎖,也就是間隙鎖,但是與行鎖不同,間隙鎖質檢不互斥,與間隙鎖沖突的是“往這個間隙中插入一個記錄”這個操作。
根據B+樹索引的搜索規則,非唯一索引滿足條件的數據與后一條數據,一定會被搜索到,唯一索引只會搜索到滿足條件的數據,而所有在索引上查找到的數據,都會加鎖。加鎖時,數據所在行加行鎖,所在行前一個間隙加間隙鎖,兩者組合成為next key lock。這里需要注意,比如上圖中(0,5]next-key lock中,也會阻止其他事物插入值為5的數據。
對於唯一索引的等值查詢,因為不可能插入其他等值的數據,所以next-key會退化為行鎖。
非唯一索引等值查詢,向兩側遍歷到最新值后,由於最新值一定不等於當前值,所以next-key會退化為間隙鎖。
下面我們一起看一下,innodb是如何用next-key lock解決幻讀的。
六、間隙鎖的運轉
6.1間隙鎖的基本使用
先回到我們之前看的問題,可以知道t1時刻會在索引c上有(0,5]和(5,10)的next-key鎖。所以t2時刻的事務實際無法提交,需要等待t4時刻session A提交后釋放next-key鎖,才能進行事務。
但實際上由於session B和session C雖然沒獲取到行鎖上的寫鎖,單兩者都獲取了(0,5]和(5,10)的間隙鎖,所以t4時刻后,兩者事務提交都會被阻塞,需要死鎖檢測釋放其中一個持有的間隙鎖后,才能依次執行。
之前索引中我們提到過非聚簇非覆蓋索引會引起回表,實際這個回表操作會額外對主鍵索引(聚簇索引)加鎖。如果通過lock in share mode先使用覆蓋索引,再使用主鍵索引時,主鍵索引上會會不產生鎖,所以可以修改數據,示例如下:
|
|
session A |
session B |
session C |
| t1 |
begin select id from t where c=5 lock in share mode |
|
|
| t2 |
|
update t set d=100 where id=5 |
|
| t3 |
|
|
insert into t values(7,7,7) |
上面的例子中t2時刻session B的update語句是可以執行的,但是由於之前說過對普通鎖因c,t1時刻會產生(0,5]和(5,10)的next-key鎖,所以t3時刻的session C的insert語句是無法執行的。
如果t1時刻使用for update進行加鎖,系統會認為你接下來要更新數據,因此會順便給主鍵索引上滿足條件的行加上行鎖。這會導致t2時刻session B的update語句是無法執行。
6.2間隙鎖與二級索引
二級索引非唯一是,會按照主鍵進行排序,這會導致下面案例中的問題:
|
|
session A |
session B |
session C |
| t1 |
begin update t set d=100 where c=10 |
|
|
| t2 |
|
insert into t values(2,10,50) |
|
| t3 |
|
|
insert into t values(7,10,50) |
| t4 |
commit |
|
|
上面案例中sessionb可以執行,但是session c不能執行。原因如下圖:

6.3動態的間隙
|
|
session A |
session B |
session C |
| t1 |
begin select * from t where c=11 for update |
|
|
| t2 |
|
delete from t where c=10 |
|
| t3 |
|
|
insert into t values(10,10,10) |
| t4 |
commit |
|
|
上面流程中session b可以執行,但是session c執行會失敗。原因是session a中是對(10,15)這個間隙加鎖,session b中刪除了c=10的數據后,會導致(5,10)和(10,15)這兩個間隙合並成了一個新的間隙(5,15),同時它繼承了之前的加鎖狀態,所以ssssion c中的插入操作會失敗
