在第3篇文章和你講事務(wù)隔離級別的時(shí)候提到過,如果是可重復(fù)讀隔離級別,事務(wù)T啟動的時(shí)候會創(chuàng)建一個(gè)視圖read-view,之后事務(wù)T執(zhí)行期間,即使有其他事務(wù)修改了數(shù)據(jù),事務(wù)T看到的仍然跟在啟動時(shí)看到的一樣。也就是說,一個(gè)在可重復(fù)讀隔離級別下執(zhí)行的事務(wù),好像與世無爭,不受外界影響。
但是,我在上一篇文章中,和你分享行鎖的時(shí)候又提到,一個(gè)事務(wù)要更新一行,如果剛好有另外一個(gè)事務(wù)擁有這一行的行鎖,它又不能這么超然了,會被鎖住,進(jìn)入等待狀態(tài)。問題是,既然進(jìn)入了等待狀態(tài),那么等到這個(gè)事務(wù)自己獲取到行鎖要更新數(shù)據(jù)的時(shí)候,它讀到的值又是什么呢?
我給你舉一個(gè)例子吧。下面是一個(gè)只有兩行的表的初始化語句。
mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `k` int(11) DEFAULT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;insert into t(id, k) values(1,1),(2,2);
這里,我們需要注意的是事務(wù)的啟動時(shí)機(jī)。
begin/start transaction 命令并不是一個(gè)事務(wù)的起點(diǎn),在執(zhí)行到它們之后的第一個(gè)操作InnoDB表的語句(第一個(gè)快照讀語句),事務(wù)才真正啟動。如果你想要馬上啟動一個(gè)事務(wù),可以使用start transaction with consistent snapshot 這個(gè)命令。
還需要注意的是,在整個(gè)專欄里面,我們的例子中如果沒有特別說明,都是默認(rèn)autocommit=1。
在這個(gè)例子中,事務(wù)C沒有顯式地使用begin/commit,表示這個(gè)update語句本身就是一個(gè)事務(wù),語句完成的時(shí)候會自動提交。事務(wù)B在更新了行之后查詢; 事務(wù)A在一個(gè)只讀事務(wù)中查詢,并且時(shí)間順序上是在事務(wù)B的查詢之后。
這時(shí),如果我告訴你事務(wù)B查到的k的值是3,而事務(wù)A查到的k的值是1,你是不是感覺有點(diǎn)暈?zāi)兀?/p>
所以,今天這篇文章,我其實(shí)就是想和你說明白這個(gè)問題,希望借由把這個(gè)疑惑解開的過程,能夠幫助你對InnoDB的事務(wù)和鎖有更進(jìn)一步的理解。
在MySQL里,有兩個(gè)“視圖”的概念:
一個(gè)是view。它是一個(gè)用查詢語句定義的虛擬表,在調(diào)用的時(shí)候執(zhí)行查詢語句并生成結(jié)果。創(chuàng)建視圖的語法是create view ... ,而它的查詢方法與表一樣。
另一個(gè)是InnoDB在實(shí)現(xiàn)MVCC時(shí)用到的一致性讀視圖,即consistent read view,用于支持RC(Read Committed,讀提交)和RR(Repeatable Read,可重復(fù)讀)隔離級別的實(shí)現(xiàn)。
它沒有物理結(jié)構(gòu),作用是事務(wù)執(zhí)行期間用來定義“我能看到什么數(shù)據(jù)”。
在第3篇文章《事務(wù)隔離:為什么你改了我還看不見?》中,我跟你解釋過一遍MVCC的實(shí)現(xiàn)邏輯。今天為了說明查詢和更新的區(qū)別,我換一個(gè)方式來說明,把read view拆開。你可以結(jié)合這兩篇文章的說明來更深一步地理解MVCC。
在可重復(fù)讀隔離級別下,事務(wù)在啟動的時(shí)候就“拍了個(gè)快照”。注意,這個(gè)快照是基于整庫的。
這時(shí),你會說這看上去不太現(xiàn)實(shí)啊。如果一個(gè)庫有100G,那么我啟動一個(gè)事務(wù),MySQL就要拷貝100G的數(shù)據(jù)出來,這個(gè)過程得多慢啊??墒?,我平時(shí)的事務(wù)執(zhí)行起來很快啊。
實(shí)際上,我們并不需要拷貝出這100G的數(shù)據(jù)。我們先來看看這個(gè)快照是怎么實(shí)現(xiàn)的。
InnoDB里面每個(gè)事務(wù)有一個(gè)唯一的事務(wù)ID,叫作transaction id。它是在事務(wù)開始的時(shí)候向InnoDB的事務(wù)系統(tǒng)申請的,是按申請順序嚴(yán)格遞增的。
而每行數(shù)據(jù)也都是有多個(gè)版本的。每次事務(wù)更新數(shù)據(jù)的時(shí)候,都會生成一個(gè)新的數(shù)據(jù)版本,并且把transaction id賦值給這個(gè)數(shù)據(jù)版本的事務(wù)ID,記為row trx_id。同時(shí),舊的數(shù)據(jù)版本要保留,并且在新的數(shù)據(jù)版本中,能夠有信息可以直接拿到它。
也就是說,數(shù)據(jù)表中的一行記錄,其實(shí)可能有多個(gè)版本(row),每個(gè)版本有自己的row trx_id。
如圖2所示,就是一個(gè)記錄被多個(gè)事務(wù)連續(xù)更新后的狀態(tài)。
圖中虛線框里是同一行數(shù)據(jù)的4個(gè)版本,當(dāng)前最新版本是V4,k的值是22,它是被transaction id 為25的事務(wù)更新的,因此它的row trx_id也是25。
你可能會問,前面的文章不是說,語句更新會生成undo log(回滾日志)嗎?那么,undo log在哪呢?
實(shí)際上,圖2中的三個(gè)虛線箭頭,就是undo log;而V1、V2、V3并不是物理上真實(shí)存在的,而是每次需要的時(shí)候根據(jù)當(dāng)前版本和undo log計(jì)算出來的。比如,需要V2的時(shí)候,就是通過V4依次執(zhí)行U3、U2算出來。
明白了多版本和row trx_id的概念后,我們再來想一下,InnoDB是怎么定義那個(gè)“100G”的快照的。
按照可重復(fù)讀的定義,一個(gè)事務(wù)啟動的時(shí)候,能夠看到所有已經(jīng)提交的事務(wù)結(jié)果。但是之后,這個(gè)事務(wù)執(zhí)行期間,其他事務(wù)的更新對它不可見。
因此,一個(gè)事務(wù)只需要在啟動的時(shí)候聲明說,“以我啟動的時(shí)刻為準(zhǔn),如果一個(gè)數(shù)據(jù)版本是在我啟動之前生成的,就認(rèn);如果是我啟動以后才生成的,我就不認(rèn),我必須要找到它的上一個(gè)版本”。
當(dāng)然,如果“上一個(gè)版本”也不可見,那就得繼續(xù)往前找。還有,如果是這個(gè)事務(wù)自己更新的數(shù)據(jù),它自己還是要認(rèn)的。
在實(shí)現(xiàn)上, InnoDB為每個(gè)事務(wù)構(gòu)造了一個(gè)數(shù)組,用來保存這個(gè)事務(wù)啟動瞬間,當(dāng)前正在“活躍”的所有事務(wù)ID?!盎钴S”指的就是,啟動了但還沒提交。
數(shù)組里面事務(wù)ID的最小值記為低水位,當(dāng)前系統(tǒng)里面已經(jīng)創(chuàng)建過的事務(wù)ID的最大值加1記為高水位。
這個(gè)視圖數(shù)組和高水位,就組成了當(dāng)前事務(wù)的一致性視圖(read-view)。
而數(shù)據(jù)版本的可見性規(guī)則,就是基于數(shù)據(jù)的row trx_id和這個(gè)一致性視圖的對比結(jié)果得到的。
這個(gè)視圖數(shù)組把所有的row trx_id 分成了幾種不同的情況。
這樣,對于當(dāng)前事務(wù)的啟動瞬間來說,一個(gè)數(shù)據(jù)版本的row trx_id,有以下幾種可能:
如果落在綠色部分,表示這個(gè)版本是已提交的事務(wù)或者是當(dāng)前事務(wù)自己生成的,這個(gè)數(shù)據(jù)是可見的;
如果落在紅色部分,表示這個(gè)版本是由將來啟動的事務(wù)生成的,是肯定不可見的;
如果落在黃色部分,那就包括兩種情況
a. 若 row trx_id在數(shù)組中,表示這個(gè)版本是由還沒提交的事務(wù)生成的,不可見;
b. 若 row trx_id不在數(shù)組中,表示這個(gè)版本是已經(jīng)提交了的事務(wù)生成的,可見。
比如,對于圖2中的數(shù)據(jù)來說,如果有一個(gè)事務(wù),它的低水位是18,那么當(dāng)它訪問這一行數(shù)據(jù)時(shí),就會從V4通過U3計(jì)算出V3,所以在它看來,這一行的值是11。
你看,有了這個(gè)聲明后,系統(tǒng)里面隨后發(fā)生的更新,是不是就跟這個(gè)事務(wù)看到的內(nèi)容無關(guān)了呢?因?yàn)橹蟮母?,生成的版本一定屬于上面?或者3(a)的情況,而對它來說,這些新的數(shù)據(jù)版本是不存在的,所以這個(gè)事務(wù)的快照,就是“靜態(tài)”的了。
所以你現(xiàn)在知道了,InnoDB利用了“所有數(shù)據(jù)都有多個(gè)版本”的這個(gè)特性,實(shí)現(xiàn)了“秒級創(chuàng)建快照”的能力。
接下來,我們繼續(xù)看一下圖1中的三個(gè)事務(wù),分析下事務(wù)A的語句返回的結(jié)果,為什么是k=1。
這里,我們不妨做如下假設(shè):
事務(wù)A開始前,系統(tǒng)里面只有一個(gè)活躍事務(wù)ID是99;
事務(wù)A、B、C的版本號分別是100、101、102,且當(dāng)前系統(tǒng)里只有這四個(gè)事務(wù);
三個(gè)事務(wù)開始前,(1,1)這一行數(shù)據(jù)的row trx_id是90。
這樣,事務(wù)A的視圖數(shù)組就是[99,100], 事務(wù)B的視圖數(shù)組是[99,100,101], 事務(wù)C的視圖數(shù)組是[99,100,101,102]。
為了簡化分析,我先把其他干擾語句去掉,只畫出跟事務(wù)A查詢邏輯有關(guān)的操作:
從圖中可以看到,第一個(gè)有效更新是事務(wù)C,把數(shù)據(jù)從(1,1)改成了(1,2)。這時(shí)候,這個(gè)數(shù)據(jù)的最新版本的row trx_id是102,而90這個(gè)版本已經(jīng)成為了歷史版本。
第二個(gè)有效更新是事務(wù)B,把數(shù)據(jù)從(1,2)改成了(1,3)。這時(shí)候,這個(gè)數(shù)據(jù)的最新版本(即row trx_id)是101,而102又成為了歷史版本。
你可能注意到了,在事務(wù)A查詢的時(shí)候,其實(shí)事務(wù)B還沒有提交,但是它生成的(1,3)這個(gè)版本已經(jīng)變成當(dāng)前版本了。但這個(gè)版本對事務(wù)A必須是不可見的,否則就變成臟讀了。
好,現(xiàn)在事務(wù)A要來讀數(shù)據(jù)了,它的視圖數(shù)組是[99,100]。當(dāng)然了,讀數(shù)據(jù)都是從當(dāng)前版本讀起的。所以,事務(wù)A查詢語句的讀數(shù)據(jù)流程是這樣的:
找到(1,3)的時(shí)候,判斷出row trx_id=101,比高水位大,處于紅色區(qū)域,不可見;
接著,找到上一個(gè)歷史版本,一看row trx_id=102,比高水位大,處于紅色區(qū)域,不可見;
再往前找,終于找到了(1,1),它的row trx_id=90,比低水位小,處于綠色區(qū)域,可見。
這樣執(zhí)行下來,雖然期間這一行數(shù)據(jù)被修改過,但是事務(wù)A不論在什么時(shí)候查詢,看到這行數(shù)據(jù)的結(jié)果都是一致的,所以我們稱之為一致性讀。
這個(gè)判斷規(guī)則是從代碼邏輯直接轉(zhuǎn)譯過來的,但是正如你所見,用于人肉分析可見性很麻煩。
所以,我來給你翻譯一下。一個(gè)數(shù)據(jù)版本,對于一個(gè)事務(wù)視圖來說,除了自己的更新總是可見以外,有三種情況:
版本未提交,不可見;
版本已提交,但是是在視圖創(chuàng)建后提交的,不可見;
版本已提交,而且是在視圖創(chuàng)建前提交的,可見。
現(xiàn)在,我們用這個(gè)規(guī)則來判斷圖4中的查詢結(jié)果,事務(wù)A的查詢語句的視圖數(shù)組是在事務(wù)A啟動的時(shí)候生成的,這時(shí)候:
(1,3)還沒提交,屬于情況1,不可見;
(1,2)雖然提交了,但是是在視圖數(shù)組創(chuàng)建之后提交的,屬于情況2,不可見;
(1,1)是在視圖數(shù)組創(chuàng)建之前提交的,可見。
你看,去掉數(shù)字對比后,只用時(shí)間先后順序來判斷,分析起來是不是輕松多了。所以,后面我們就都用這個(gè)規(guī)則來分析。
細(xì)心的同學(xué)可能有疑問了:事務(wù)B的update語句,如果按照一致性讀,好像結(jié)果不對哦?
你看圖5中,事務(wù)B的視圖數(shù)組是先生成的,之后事務(wù)C才提交,不是應(yīng)該看不見(1,2)嗎,怎么能算出(1,3)來?
是的,如果事務(wù)B在更新之前查詢一次數(shù)據(jù),這個(gè)查詢返回的k的值確實(shí)是1。
但是,當(dāng)它要去更新數(shù)據(jù)的時(shí)候,就不能再在歷史版本上更新了,否則事務(wù)C的更新就丟失了。因此,事務(wù)B此時(shí)的set k=k+1是在(1,2)的基礎(chǔ)上進(jìn)行的操作。
所以,這里就用到了這樣一條規(guī)則:更新數(shù)據(jù)都是先讀后寫的,而這個(gè)讀,只能讀當(dāng)前的值,稱為“當(dāng)前讀”(current read)。
因此,在更新的時(shí)候,當(dāng)前讀拿到的數(shù)據(jù)是(1,2),更新后生成了新版本的數(shù)據(jù)(1,3),這個(gè)新版本的row trx_id是101。
所以,在執(zhí)行事務(wù)B查詢語句的時(shí)候,一看自己的版本號是101,最新數(shù)據(jù)的版本號也是101,是自己的更新,可以直接使用,所以查詢得到的k的值是3。
這里我們提到了一個(gè)概念,叫作當(dāng)前讀。其實(shí),除了update語句外,select語句如果加鎖,也是當(dāng)前讀。
所以,如果把事務(wù)A的查詢語句select * from t where id=1修改一下,加上lock in share mode 或 for update,也都可以讀到版本號是101的數(shù)據(jù),返回的k的值是3。下面這兩個(gè)select語句,就是分別加了讀鎖(S鎖,共享鎖)和寫鎖(X鎖,排他鎖)。
mysql> select k from t where id=1 lock in share mode;mysql> select k from t where id=1 for update;
再往前一步,假設(shè)事務(wù)C不是馬上提交的,而是變成了下面的事務(wù)C’,會怎么樣呢?
事務(wù)C’的不同是,更新后并沒有馬上提交,在它提交前,事務(wù)B的更新語句先發(fā)起了。前面說過了,雖然事務(wù)C’還沒提交,但是(1,2)這個(gè)版本也已經(jīng)生成了,并且是當(dāng)前的最新版本。那么,事務(wù)B的更新語句會怎么處理呢?
這時(shí)候,我們在上一篇文章中提到的“兩階段鎖協(xié)議”就要上場了。事務(wù)C’沒提交,也就是說(1,2)這個(gè)版本上的寫鎖還沒釋放。而事務(wù)B是當(dāng)前讀,必須要讀最新版本,而且必須加鎖,因此就被鎖住了,必須等到事務(wù)C’釋放這個(gè)鎖,才能繼續(xù)它的當(dāng)前讀。
到這里,我們把一致性讀、當(dāng)前讀和行鎖就串起來了。
現(xiàn)在,我們再回到文章開頭的問題:事務(wù)的可重復(fù)讀的能力是怎么實(shí)現(xiàn)的?
可重復(fù)讀的核心就是一致性讀(consistent read);而事務(wù)更新數(shù)據(jù)的時(shí)候,只能用當(dāng)前讀。如果當(dāng)前的記錄的行鎖被其他事務(wù)占用的話,就需要進(jìn)入鎖等待。
而讀提交的邏輯和可重復(fù)讀的邏輯類似,它們最主要的區(qū)別是:
在可重復(fù)讀隔離級別下,只需要在事務(wù)開始的時(shí)候創(chuàng)建一致性視圖,之后事務(wù)里的其他查詢都共用這個(gè)一致性視圖;
在讀提交隔離級別下,每一個(gè)語句執(zhí)行前都會重新算出一個(gè)新的視圖。
那么,我們再看一下,在讀提交隔離級別下,事務(wù)A和事務(wù)B的查詢語句查到的k,分別應(yīng)該是多少呢?
這里需要說明一下,“start transaction with consistent snapshot; ”的意思是從這個(gè)語句開始,創(chuàng)建一個(gè)持續(xù)整個(gè)事務(wù)的一致性快照。所以,在讀提交隔離級別下,這個(gè)用法就沒意義了,等效于普通的start transaction。
下面是讀提交時(shí)的狀態(tài)圖,可以看到這兩個(gè)查詢語句的創(chuàng)建視圖數(shù)組的時(shí)機(jī)發(fā)生了變化,就是圖中的read view框。(注意:這里,我們用的還是事務(wù)C的邏輯直接提交,而不是事務(wù)C’)
這時(shí),事務(wù)A的查詢語句的視圖數(shù)組是在執(zhí)行這個(gè)語句的時(shí)候創(chuàng)建的,時(shí)序上(1,2)、(1,3)的生成時(shí)間都在創(chuàng)建這個(gè)視圖數(shù)組的時(shí)刻之前。但是,在這個(gè)時(shí)刻:
(1,3)還沒提交,屬于情況1,不可見;
(1,2)提交了,屬于情況3,可見。
所以,這時(shí)候事務(wù)A查詢語句返回的是k=2。
顯然地,事務(wù)B查詢結(jié)果k=3。
InnoDB的行數(shù)據(jù)有多個(gè)版本,每個(gè)數(shù)據(jù)版本有自己的row trx_id,每個(gè)事務(wù)或者語句有自己的一致性視圖。普通查詢語句是一致性讀,一致性讀會根據(jù)row trx_id和一致性視圖確定數(shù)據(jù)版本的可見性。
對于可重復(fù)讀,查詢只承認(rèn)在事務(wù)啟動前就已經(jīng)提交完成的數(shù)據(jù);
對于讀提交,查詢只承認(rèn)在語句啟動前就已經(jīng)提交完成的數(shù)據(jù);
而當(dāng)前讀,總是讀取已經(jīng)提交完成的最新版本。
你也可以想一下,為什么表結(jié)構(gòu)不支持“可重復(fù)讀”?這是因?yàn)楸斫Y(jié)構(gòu)沒有對應(yīng)的行數(shù)據(jù),也沒有row trx_id,因此只能遵循當(dāng)前讀的邏輯。
當(dāng)然,MySQL 8.0已經(jīng)可以把表結(jié)構(gòu)放在InnoDB字典里了,也許以后會支持表結(jié)構(gòu)的可重復(fù)讀。
又到思考題時(shí)間了。我用下面的表結(jié)構(gòu)和初始化語句作為試驗(yàn)環(huán)境,事務(wù)隔離級別是可重復(fù)讀?,F(xiàn)在,我要把所有“字段c和id值相等的行”的c值清零,但是卻發(fā)現(xiàn)了一個(gè)“詭異”的、改不掉的情況。請你構(gòu)造出這種情況,并說明其原理。
mysql> CREATE TABLE `t` ( `id` int(11) NOT NULL, `c` int(11) DEFAULT NULL, PRIMARY KEY (`id`)) ENGINE=InnoDB;insert into t(id, c) values(1,1),(2,2),(3,3),(4,4);