幻讀
-
場景例子(innodb的默認事物隔離級別是可重復讀)
-
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);
begin; select * from t where d=5 for update; commit;
- 這個語句會命中d=5的這一行,對應的主鍵id=5,因此在select 語句執行完成后,id=5這一行會加一個寫鎖,而且由於兩階段鎖協議,這個寫鎖會在執行commit語句的時候釋放。
- 由於字段d上沒有索引,因此這條查詢語句會做全表掃描。那么,其他被掃描到的,但是不滿足條件的5行記錄上,會不會被加鎖呢?
-
-
Q3讀到id=1這一行的現象,被稱為“幻讀”。也就是說,幻讀指的是一個事務在前后兩次查詢同一個范圍的時候,后一次查詢看到了前一次查詢沒有看到的行。
- 在可重復讀隔離級別下,普通的查詢是快照讀,是不會看到別的事務插入的數據的。因此,
幻讀在“當前讀”下才會出現。 - 上面session B的修改結果,被session A之后的select語句用“當前讀”看到,不能稱為幻
讀。幻讀僅專指“新插入的行”。
- 在可重復讀隔離級別下,普通的查詢是快照讀,是不會看到別的事務插入的數據的。因此,
-
因為這三個查詢都是加了for update,都是當前讀。而當前讀的規則,就是要能讀到所有已經提交的記錄的最新值。
-
幻讀有什么問題?
-
語義上:session A在T1時刻就聲明了,“我要把所有d=5的行鎖住,不准別的事務進行讀寫操作”。而實際上,這個語義被破壞了。
-
-
session B的第二條語句將id=0,d=5的c值改成了5,在T1時刻,session A只是給id=5加了行鎖,並沒有給id=0加鎖,所以,在T2時刻,是可以執行session B的更新語句,這樣就破壞了session A中Q1要鎖住d=5的行的加鎖生命,session C也是一樣。
-
-
數據一致性:
-
-
在T1時刻的session A中,update的加鎖語義和select ...for update 是一致的,所以這時候加上這條update語句也很合理。session A聲明說“要給d=5的語句加上鎖”,就是為了要更新數據,新加的這條update語句就是把它認為加上了鎖的這一行的d值修改成了100。
-
經過T1時刻,id=5這一行變成 (5,5,100),當然這個結果最終是在T6時刻正式提交的;
-
經過T2時刻,id=0這一行變成(0,5,5);
-
經過T4時刻,表里面多了一行(1,5,5);
-
再看一下binlog里的內容
-
T2時刻,session B事務提交,寫入了兩條語句;
-
T4時刻,session C事務提交,寫入了兩條語句;
-
T6時刻,session A事務提交,寫入了update t set d=100 where d=5 這條語句。
-
update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/ insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/
-
-
好,你應該看出問題了。這個語句序列,不論是拿到備庫去執行,還是以后用binlog來克隆一個庫,這三行的結果,都變成了 (0,5,100)、(1,5,100)和(5,5,100)。也就是說,id=0和id=1這兩行,發生了數據不一致。不一致的原因是什么呢?
-
這是我們假設“select * from t where d=5 for update這條語句只給d=5這一行,也就是id=5的這一行加鎖”導致的。加入我們吧掃描過程中碰到的行,都加上寫鎖,再來看看執行效果。
-
假設掃描的行都加了行鎖 -
由於session A把所有的行都加了寫鎖,所以session B在執行第一個update語句的時候就被鎖住了。需要等到T6時刻session A提交以后,session B才能繼續執行。
-
這樣對於id=0這一行,在數據庫里的最終結果還是 (0,5,5)。在binlog里面,執行序列是這樣的:
-
insert into t values(1,1,5); /*(1,1,5)*/ update t set c=5 where id=1; /*(1,5,5)*/ update t set d=100 where d=5;/*所有d=5的行,d改成100*/ update t set d=5 where id=0; /*(0,0,5)*/ update t set c=5 where id=0; /*(0,5,5)*/
-
id=1這一行,在數據庫里面的結果是(1,5,5),而根據binlog的執行結果是(1,5,100),也就是說幻讀的問題還是沒有解決。原因很簡單。在T3時刻,我們給所有行加鎖的時候,id=1這一行還不存在,不存在也就加不上鎖。
-
-
也就是說,即使把所有的記錄都加上鎖,還是阻止不了新插入的記錄,這也是為什么“幻讀”會被單獨拿出來解決的原因。
-
產生幻讀的原因是,行鎖只能鎖住行,但是新插入記錄這個動作,要更新的是記錄之間的“間隙”。因此,為了解決幻讀問題,InnoDB只好引入新的鎖,也就是間隙鎖(Gap
Lock)。鎖的就是兩個值之間的空隙。比如表t,初始化插入了6個記錄,這就產生了7個間隙。 -
這樣,當你執行 select * from t where d=5 for update的時候,就不止是給數據庫中已有的6個記錄加上了行鎖,還同時加了7個間隙鎖。這樣就確保了無法再插入新的記錄。也就是說這時候,在一行行掃描的過程中,不僅將給行加上了行鎖,還給行兩邊的空隙,也加上了間隙鎖。
-
行鎖中讀鎖和寫鎖的沖突關系
-
間隙所不一樣,跟間隙所存在沖突關系的是“往這個間隙中插入一個記錄”這個操作,間隙鎖之間不存在沖突關系。
這里session B並不會被堵住。因為表t里並沒有c=7這個記錄,因此session A加的是間隙鎖(5,10)。而session B也是在這個間隙加的間隙鎖。它們有共同的目標,即:保護這個間隙,不允許插入值。但,它們之間是不沖突的。
-
間隙鎖和行鎖合稱next-key lock,每個next-key lock是前開后閉區間。也就是說,我們的表t初始化以后,如果用select * from t for update要把整個表所有記錄鎖起來,就形成了7個nextkeylock,分別是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +suprenum]。
-
間隙鎖和next-key lock的引入,幫我們解決了幻讀的問題,但同時也帶來了一些“困擾”。
- session A 執行select ... for update語句,由於id=9這一行並不存在,因此會加上間隙鎖(5,10);
- session B 執行select ... for update語句,同樣會加上間隙鎖(5,10),間隙鎖之間不會沖突,因此這個語句可以執行成功;
- session B 試圖插入一行(9,9,9),被session A的間隙鎖擋住了,只好進入等待;
- session A試圖插入一行(9,9,9),被session B的間隙鎖擋住了。
- 至此,兩個session進入互相等待狀態,形成死鎖。當然,InnoDB的死鎖檢測馬上就發現了這對死鎖關系,讓session A的insert語句報錯返回了。
- 間隙鎖的引入,可能會導致同樣的語句鎖住更大的范圍,這其實是影響了並發度的。
-
-
-
間隙鎖是在可重復讀隔離級別下才會生效的。所以,你如果把隔離級別設置為讀提交的話,就沒有間隙鎖了。但同時,你要解決可能出現的數據和日志不一致問題,需要把binlog格式設置為row。如果讀提交隔離級別夠用,也就是說,業務不需要可重復讀的保證,這樣考慮到讀提交下操作數據的鎖范圍更小(沒有間隙鎖),這個選擇是合理的。
-
所以我們在設置參數之前要先確定我們業務是讀提交級別就夠了還是要可重復讀級別,我們怎么得到事務隔離級別的結論的也要弄清楚,才會從根本上指定措施解決幻讀問題。