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

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

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

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行數據。數據庫

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

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

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

因爲字段d上沒有索引,所以這條查詢語句會作全表掃描。那麼,其餘被掃描到的,可是不知足條件的5行記錄上,會不會被加鎖呢?運維

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

幻讀是什麼?

如今,咱們就來分析一下,若是隻在id=5這一行加鎖,而其餘行的不加鎖的話,會怎麼樣。線程

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

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

能夠看到,session A裏執行了三次查詢,分別是Q一、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的時候才釋放的。

相關文章
相關標籤/搜索