1. 程式人生 > >20 | 幻讀是什麽,幻讀有什麽問題?

20 | 幻讀是什麽,幻讀有什麽問題?

time 幫我 狀態 upd 隔離 ica 實體 之前 gap

在上一篇文章最後,我給你留了一個關於加鎖規則的問題。今天,我們就從這個問題說起吧。

為了便於說明問題,這一篇文章,我們就先使用一個小一點兒的表。建表和初始化語句如下(為了便於本期的例子說明,我把上篇文章中用到的表結構做了點兒修改):

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);

這個表除了主鍵id外,還有一個索引c,初始化語句在表中插入了6行數據。

上期我留給你的問題是,下面的語句序列,是怎麽加鎖的,加的鎖又是什麽時候釋放的呢?

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

比較好理解的是,這個語句會命中d=5的這一行,對應的主鍵id=5,因此在select 語句執行完成後,id=5這一行會加一個寫鎖,而且由於兩階段鎖協議,這個寫鎖會在執行commit語句的時候釋放。

由於字段d上沒有索引,因此這條查詢語句會做全表掃描。那麽,其他被掃描到的,但是不滿足條件的5行記錄上,會不會被加鎖呢?

我們知道,InnoDB的默認事務隔離級別是可重復讀,所以本文接下來沒有特殊說明的部分,都是設定在可重復讀隔離級別下。

幻讀是什麽?

現在,我們就來分析一下,如果只在id=5這一行加鎖,而其他行的不加鎖的話,會怎麽樣。

下面先來看一下這個場景(註意:這是我假設的一個場景):

技術分享圖片

圖 1 假設只在id=5這一行加行鎖

可以看到,session A裏執行了三次查詢,分別是Q1、Q2和Q3。它們的SQL語句相同,都是select * from t where d=5 for update。這個語句的意思你應該很清楚了,查所有d=5的行,而且使用的是當前讀,並且加上寫鎖。現在,我們來看一下這三條SQL語句,分別會返回什麽結果。

  1. Q1只返回id=5這一行;

  2. 在T2時刻,session B把id=0這一行的d值改成了5,因此T3時刻Q2查出來的是id=0和id=5這兩行;

  3. 在T4時刻,session C又插入一行(1,1,5),因此T5時刻Q3查出來的是id=0、id=1和id=5的這三行。

其中,Q3讀到id=1這一行的現象,被稱為“幻讀”。也就是說,幻讀指的是一個事務在前後兩次查詢同一個範圍的時候,後一次查詢看到了前一次查詢沒有看到的行。

這裏,我需要對“幻讀”做一個說明:

  1. 在可重復讀隔離級別下,普通的查詢是快照讀,是不會看到別的事務插入的數據的。因此,幻讀在“當前讀”下才會出現。

  2. 上面session B的修改結果,被session A之後的select語句用“當前讀”看到,不能稱為幻讀。幻讀僅專指“新插入的行”。

如果只從第8篇文章《事務到底是隔離的還是不隔離的?》我們學到的事務可見性規則來分析的話,上面這三條SQL語句的返回結果都沒有問題。

因為這三個查詢都是加了for update,都是當前讀。而當前讀的規則,就是要能讀到所有已經提交的記錄的最新值。並且,session B和sessionC的兩條語句,執行後就會提交,所以Q2和Q3就是應該看到這兩個事務的操作效果,而且也看到了,這跟事務的可見性規則並不矛盾。

但是,這是不是真的沒問題呢?

不,這裏還真就有問題。

幻讀有什麽問題?

首先是語義上的。session A在T1時刻就聲明了,“我要把所有d=5的行鎖住,不準別的事務進行讀寫操作”。而實際上,這個語義被破壞了。

如果現在這樣看感覺還不明顯的話,我再往session B和session C裏面分別加一條SQL語句,你再看看會出現什麽現象。

技術分享圖片

圖 2 假設只在id=5這一行加行鎖--語義被破壞

session B的第二條語句update t set c=5 where id=0,語義是“我把id=0、d=5這一行的c值,改成了5”。

由於在T1時刻,session A 還只是給id=5這一行加了行鎖, 並沒有給id=0這行加上鎖。因此,session B在T2時刻,是可以執行這兩條update語句的。這樣,就破壞了 session A 裏Q1語句要鎖住所有d=5的行的加鎖聲明。

session C也是一樣的道理,對id=1這一行的修改,也是破壞了Q1的加鎖聲明。

其次,是數據一致性的問題。

我們知道,鎖的設計是為了保證數據的一致性。而這個一致性,不止是數據庫內部數據狀態在此刻的一致性,還包含了數據和日誌在邏輯上的一致性。

為了說明這個問題,我給session A在T1時刻再加一個更新語句,即:update t set d=100 where d=5。

技術分享圖片

圖 3 假設只在id=5這一行加行鎖--數據一致性問題

update的加鎖語義和select …for update 是一致的,所以這時候加上這條update語句也很合理。session A聲明說“要給d=5的語句加上鎖”,就是為了要更新數據,新加的這條update語句就是把它認為加上了鎖的這一行的d值修改成了100。

現在,我們來分析一下圖3執行完成後,數據庫裏會是什麽結果。

  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的這一行加鎖”導致的。

所以我們認為,上面的設定不合理,要改。

那怎麽改呢?我們把掃描過程中碰到的行,也都加上寫鎖,再來看看執行效果。

技術分享圖片

圖 4 假設掃描到的行都被加上了行鎖

由於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),也就是說幻讀的問題還是沒有解決。為什麽我們已經這麽“兇殘”地,把所有的記錄都上了鎖,還是阻止不了id=1這一行的插入和更新呢?

原因很簡單。在T3時刻,我們給所有行加鎖的時候,id=1這一行還不存在,不存在也就加不上鎖。

也就是說,即使把所有的記錄都加上鎖,還是阻止不了新插入的記錄,這也是為什麽“幻讀”會被單獨拿出來解決的原因。

到這裏,其實我們剛說明完文章的標題 :幻讀的定義和幻讀有什麽問題。

接下來,我們再看看InnoDB怎麽解決幻讀的問題。

如何解決幻讀?

現在你知道了,產生幻讀的原因是,行鎖只能鎖住行,但是新插入記錄這個動作,要更新的是記錄之間的“間隙”。因此,為了解決幻讀問題,InnoDB只好引入新的鎖,也就是間隙鎖(Gap Lock)。

顧名思義,間隙鎖,鎖的就是兩個值之間的空隙。比如文章開頭的表t,初始化插入了6個記錄,這就產生了7個間隙。

技術分享圖片

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

這樣,當你執行 select * from t where d=5 for update的時候,就不止是給數據庫中已有的6個記錄加上了行鎖,還同時加了7個間隙鎖。這樣就確保了無法再插入新的記錄。

也就是說這時候,在一行行掃描的過程中,不僅將給行加上了行鎖,還給行兩邊的空隙,也加上了間隙鎖。

現在你知道了,數據行是可以加上鎖的實體,數據行之間的間隙,也是可以加上鎖的實體。但是間隙鎖跟我們之前碰到過的鎖都不太一樣。

比如行鎖,分成讀鎖和寫鎖。下圖就是這兩種類型行鎖的沖突關系。

技術分享圖片

圖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-key lock,分別是 (-∞,0]、(0,5]、(5,10]、(10,15]、(15,20]、(20, 25]、(25, +supremum]。

備註:這篇文章中,如果沒有特別說明,我們把間隙鎖記為開區間,把next-key lock記為前開後閉區間。

你可能會問說,這個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。

技術分享圖片

圖8 間隙鎖導致的死鎖

你看到了,其實都不需要用到後面的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的間隙鎖擋住了。

至此,兩個session進入互相等待狀態,形成死鎖。當然,InnoDB的死鎖檢測馬上就發現了這對死鎖關系,讓session A的insert語句報錯返回了。

你現在知道了,間隙鎖的引入,可能會導致同樣的語句鎖住更大的範圍,這其實是影響了並發度的。其實,這還只是一個簡單的例子,在下一篇文章中我們還會碰到更多、更復雜的例子。

你可能會說,為了解決幻讀的問題,我們引入了這麽一大串內容,有沒有更簡單一點的處理方法呢。

我在文章一開始就說過,如果沒有特別說明,今天和你分析的問題都是在可重復讀隔離級別下的,間隙鎖是在可重復讀隔離級別下才會生效的。所以,你如果把隔離級別設置為讀提交的話,就沒有間隙鎖了。但同時,你要解決可能出現的數據和日誌不一致問題,需要把binlog格式設置為row。這,也是現在不少公司使用的配置組合。

前面文章的評論區有同學留言說,他們公司就使用的是讀提交隔離級別加binlog_format=row的組合。他曾問他們公司的DBA說,你為什麽要這麽配置。DBA直接答復說,因為大家都這麽用呀。

所以,這個同學在評論區就問說,這個配置到底合不合理。

關於這個問題本身的答案是,如果讀提交隔離級別夠用,也就是說,業務不需要可重復讀的保證,這樣考慮到讀提交下操作數據的鎖範圍更小(沒有間隙鎖),這個選擇是合理的。

但其實我想說的是,配置是否合理,跟業務場景有關,需要具體問題具體分析。

但是,如果DBA認為之所以這麽用的原因是“大家都這麽用”,那就有問題了,或者說,遲早會出問題。

比如說,大家都用讀提交,可是邏輯備份的時候,mysqldump為什麽要把備份線程設置成可重復讀呢?(這個我在前面的文章中已經解釋過了,你可以再回顧下第6篇文章《全局鎖和表鎖 :給表加個字段怎麽有這麽多阻礙?》的內容)

然後,在備份期間,備份線程用的是可重復讀,而業務線程用的是讀提交。同時存在兩種事務隔離級別,會不會有問題?

進一步地,這兩個不同的隔離級別現象有什麽不一樣的,關於我們的業務,“用讀提交就夠了”這個結論是怎麽得到的?

如果業務開發和運維團隊這些問題都沒有弄清楚,那麽“沒問題”這個結論,本身就是有問題的。

小結

今天我們從上一篇文章的課後問題說起,提到了全表掃描的加鎖方式。我們發現即使給所有的行都加上行鎖,仍然無法解決幻讀問題,因此引入了間隙鎖的概念。

我碰到過很多對數據庫有一定了解的業務開發人員,他們在設計數據表結構和業務SQL語句的時候,對行鎖有很準確的認識,但卻很少考慮到間隙鎖。最後的結果,就是生產庫上會經常出現由於間隙鎖導致的死鎖現象。

行鎖確實比較直觀,判斷規則也相對簡單,間隙鎖的引入會影響系統的並發度,也增加了鎖分析的復雜度,但也有章可循。下一篇文章,我就會為你講解InnoDB的加鎖規則,幫你理順這其中的“章法”。

作為對下一篇文章的預習,我給你留下一個思考題。

技術分享圖片

圖9 事務進入鎖等待狀態

如果你之前沒有了解過本篇文章的相關內容,一定覺得這三個語句簡直是風馬牛不相及。但實際上,這裏session B和session C的insert 語句都會進入鎖等待狀態。

你可以試著分析一下,出現這種情況的原因是什麽?

這裏需要說明的是,這其實是我在下一篇文章介紹加鎖規則後才能回答的問題,是留給你作為預習的,其中session C被鎖住這個分析是有點難度的。如果你沒有分析出來,也不要氣餒,我會在下一篇文章和你詳細說明。

你也可以說說,你的線上MySQL配置的是什麽隔離級別,為什麽會這麽配置?你有沒有碰到什麽場景,是必須使用可重復讀隔離級別的呢?

你可以把你的碰到的場景和分析寫在留言區裏,我會在下一篇文章選取有趣的評論跟大家一起分享和分析。感謝你的收聽,也歡迎你把這篇文章分享給更多的朋友一起閱讀。

上期問題時間

我們在本文的開頭回答了上期問題。有同學的回答中還說明了讀提交隔離級別下,在語句執行完成後,是只有行鎖的。而且語句執行完成後,InnoDB就會把不滿足條件的行行鎖去掉。

當然了,c=5這一行的行鎖,還是會等到commit的時候才釋放的。

20 | 幻讀是什麽,幻讀有什麽問題?