MySQL 筆記整理(20) --幻讀是什麼,幻讀有什麼問題?

筆記記錄自林曉斌(丁奇)老師的《MySQL實戰45講》sql

(本篇內圖片均來自丁奇老師的講解,若有侵權,請聯繫我刪除)數據庫

20) --幻讀是什麼,幻讀有什麼問題?session

  咱們先來看看錶結構和初始化數據:併發

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除主鍵id外還有一個索引c,初始化語句在表中插入了6行數據。那麼若是有下面這樣一段語句設計

begin;
select * from t where d=5 for update;
commit;

  請問是怎麼加鎖的,加的鎖又是何時釋放的呢?因爲for update,上面的語句會在執行完成select以後加一個寫鎖,並且因爲兩階段鎖協議,這個寫鎖會在執行commit語句的時候釋放。因爲字段d上沒有索引,所以這條查詢語句會作全表掃描。那麼,其餘被掃描到的,可是不知足條件的5行記錄上,會不會也被加鎖呢?咱們知道,InnoDB的默認隔離級別是可重複讀,因此本文接下來沒有特殊說明的部分,都是設定在可重複讀隔離級別下的。3d

幻讀是什麼?日誌

  咱們不妨來分析一下,若是隻在d=5,也就是id=5這一行上加鎖,其餘行上不加鎖,會怎麼樣。咱們來看一下這種狀況的場景,注意,這裏是符合剛纔假設的,只在查詢的那一行加鎖,其餘行不加鎖的狀況。code

  由上圖能夠看到,在session A中執行了三次查詢,分別是Q1,Q2和Q3,他們的查詢語句都相同,可是返回結果都不一樣。其中Q3讀到id=1這一行的現象,被稱爲「幻讀」。也就是說,幻讀值得是一個事務在先後兩次查詢同一個範圍的時候,後一次查詢看到了前一次查詢沒有看到的行。這裏須要對「幻讀」額外說明一下:blog

  1. 在可重複讀隔離級別下,普通的查詢是快照讀,是不會看到別的事務插入的數據的。所以,幻讀在「當前讀」下才會出現。(以前有提到過,update語句是「當前讀」,select 語句若是加鎖,也是「當前讀」)
  2. 上面的session B的修改結果,被 session A以後的select語句(Q2,Q3)用「當前讀」看到,不能稱爲「幻讀」。「幻讀」僅專指「新插入的行」。

  由於這三次查詢都加了for update,都是當前讀。根據規則,就是要能讀到全部已經提交的記錄的最新值,而且Session B和Session C的兩條語句執行完成後就會提交,因此Q2和Q3就是應該看到這兩個事務的操做效果,並且也看到了,這跟事務的可見性規則並不矛盾。但這是否是真的沒有問題呢?不,這還真有一些問題。索引

幻讀有什麼問題?

  首先是語義上的問題。Session A在T1時刻的查詢裏包含for update,意思是「我要把全部d=5的行鎖住,不許別的事務進行讀寫操做」。但實際上,這個語義被破壞掉了。若是這樣還不夠明顯,能夠想象一下,在T2時刻Session B中若是添加這樣一條語句:update t set c = 5 where id = 0;Session A的語義是 全部d=5的行鎖住,不許別的事務進行讀寫操做。但在T2時刻,Session B中id=0這一行沒有被Session A的聲明鎖住,同時,因爲是在同一個事務中,對id=0(d=5)這一行的更新操做也能正常執行。

  其次,是數據一致性的問題。咱們知道,鎖的設計是爲了保證數據的一致性。而這一致性,不止是數據庫內部數據狀態在此刻的一致性,還包含了數據和日誌在邏輯上的一致性。爲了說明這個問題,咱們給session A在T1時刻再加上一個更新語句,即:update t set d = 100 where d = 5;

  update的加鎖語義和select ...for update是一致的,因此這時候加上這條update語句也很合理。session A聲明說「要給d=5的這條語句加上鎖」,也就是爲了要更新數據,新加的這條update語句就把它認爲加上了鎖的這一行的d值修改爲100.咱們來分析一下上圖執行完成以後,數據庫裏會是什麼結果。

  1. 通過T1時刻,id=5這一行變成了(5,5,100),固然這個結果最終是在T6時刻正式提交的;
  2. 通過T2時刻,id=0這一行變成了(0,5,5);
  3. 通過T4時刻,表裏面多了一行(1,5,5);
  4. 其餘行跟這個執行序列無關,保持不變。

  這樣看起來,這些數據頁沒什麼問題。可是咱們再來看看binlog裏的內容

  1. T2時刻,Session B事務提交,寫入了兩條語句。
  2. T4時刻,Session C事務提交,寫入了兩條語句。
  3. 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=0這一行的最終結果也是(0,5,5).因此,id=0這一行的問題解決了。但同時你也會看到,id=1這一行,在數據庫裏面的結果是(1,5,5),而根據binlog的執行結果是(1,5,100),也就是說幻讀的問題仍是沒有解決。爲何咱們已經這麼「兇殘」地把全部記錄都加上鎖了,仍是阻止不了這樣的問題呢?緣由其實很簡單,T3時刻,咱們給全部行加鎖的時候,id=1這一行還不存在,不存在天然咱們的鎖對它也沒有任何辦法。也就是說,即便全部記錄都加上了鎖,仍是阻止不了新插入的記錄。這也是爲何「幻讀」會被單獨拿出來解決的緣由。

如何解決幻讀?

  產生幻讀的緣由是,行鎖只能鎖住行,可是新插入記錄這個動做,要更新的是記錄之間的「間隙」。所以,爲了解決幻讀的問題,InnoDB只好引入新的鎖,也就是間隙鎖(Gap Lock)。顧名思義,間隙鎖,鎖的是兩個值直接的間隙。好比文章開頭的表t,初始化插入了6個記錄,這就產生了7個間隙。

  表t主鍵索引上的行鎖和間隙鎖

  這樣,當你執行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個next-try lock。須要注意的是,結合上面的表t的初始化數據,最後一個區間是 (25, +supremum]。還是前開後閉的。你可能會好奇supremum是什麼。由於整無窮是開區間。實現上,InnoDB給每一個索引加了一個不存在的最大值supremum,這樣才符合咱們剛纔說的「都是前開後閉的區間」。

  間隙鎖和next-key lock的引入,幫咱們解決了幻讀的問題,但同時也帶來了一些「困擾」。咱們以這樣一個業務邏輯來舉例:任意鎖住一行,若是這一行不存的話就插入,若是存在這一行就更新它的數據,代碼以下:

begin;
select * from t where id=N for update;

/* 若是行不存在 */
insert into t values(N,N,N);
/* 若是行存在 */
update t set d=N set id=N;

commit;

  可能你會建議使用 insert... on duplicate key update這條語句,但其實在有多個惟一主鍵的時候這個方法不能知足需求,具體咱們之後會展開說明。如今咱們就單獨考慮一下這個邏輯。這種情景下的一個現象是,這個邏輯一旦有併發,就會碰到死鎖。你必定有點奇怪,這個邏輯每次操做前都有用for update鎖起來,已是最嚴格的模式了,爲何仍是有死鎖呢?這裏,咱們用兩個session來模擬併發,並假設N=9。

  你看到了,其實都不須要用到後面的update語句,就已經造成了死鎖。咱們按語句執行順序分析一下:

  1. Session A執行select...for update語句,因爲id=9這一行並不存在,所以會加上間隙鎖(5,10);
  2. Session B執行select...for update語句,一樣加上間隙鎖(5,10),間隙鎖之間不會衝突,所以這個語句能夠執行成功;
  3. Session B試圖插入一行(9,9,9),被Session A的間隙鎖擋住了,只好進入等待。
  4. Session A試圖插入一行(9,9,9),被Session B的間隙鎖擋住了,死鎖。

  所以,間隙鎖的引入,可能會致使一樣的語句鎖住更大的範圍,這實際上是影響了併發度的。固然,以上的內容都是創建在可重複讀隔離級別下的,若是你吧隔離級別更改爲讀提交,就不會有間隙鎖了。但同時,你可能須要解決出現的數據和日誌不一致問題。須要把binlog格式設置爲row,這也是很多公司使用的配置組合。

相關文章
相關標籤/搜索