事前準(zhǔn)備
為了故事的順利發(fā)展,我們需要創(chuàng)建一個表:
CREATE TABLE t ( id INT PRIMARY KEY, c VARCHAR(100)) Engine=InnoDB CHARSET=utf8;
然后向這個表里插入一條數(shù)據(jù):
INSERT INTO t VALUES(1, '劉備');
現(xiàn)在表里的數(shù)據(jù)就是這樣的:
mysql> SELECT * FROM t;+----+--------+| id | c |+----+--------+| 1 | 劉備 |+----+--------+1 row in set (0.01 sec)
隔離級別
MySQL是一個服務(wù)器/客戶端架構(gòu)的軟件,對于同一個服務(wù)器來說,可以有若干個客戶端與之連接,每個客戶端與服務(wù)器連接上之后,就可以稱之為一個會話(Session)。我們可以同時在不同的會話里輸入各種語句,這些語句可以作為事務(wù)的一部分進(jìn)行處理。不同的會話可以同時發(fā)送請求,也就是說服務(wù)器可能同時在處理多個事務(wù),這樣子就會導(dǎo)致不同的事務(wù)可能同時訪問到相同的記錄。我們前邊說過事務(wù)有一個特性稱之為隔離性,理論上在某個事務(wù)對某個數(shù)據(jù)進(jìn)行訪問時,其他事務(wù)應(yīng)該進(jìn)行排隊,當(dāng)該事務(wù)提交之后,其他事務(wù)才可以繼續(xù)訪問這個數(shù)據(jù)。但是這樣子的話對性能影響太大,所以設(shè)計數(shù)據(jù)庫的大叔提出了各種隔離級別,來最大限度的提升系統(tǒng)并發(fā)處理事務(wù)的能力,但是這也是以犧牲一定的隔離性來達(dá)到的。
未提交讀(READ UNCOMMITTED)
如果一個事務(wù)讀到了另一個未提交事務(wù)修改過的數(shù)據(jù),那么這種隔離級別就稱之為未提交讀(英文名:READ UNCOMMITTED),示意圖如下:
如上圖,Session A和Session B各開啟了一個事務(wù),Session B中的事務(wù)先將id為1的記錄的列c更新為'關(guān)羽',然后Session A中的事務(wù)再去查詢這條id為1的記錄,那么在未提交讀的隔離級別下,查詢結(jié)果就是'關(guān)羽',也就是說某個事務(wù)讀到了另一個未提交事務(wù)修改過的記錄。但是如果Session B中的事務(wù)稍后進(jìn)行了回滾,那么Session A中的事務(wù)相當(dāng)于讀到了一個不存在的數(shù)據(jù),這種現(xiàn)象就稱之為臟讀,就像這個樣子:
臟讀違背了現(xiàn)實世界的業(yè)務(wù)含義,所以這種READ UNCOMMITTED算是十分不安全的一種隔離級別。
已提交讀(READ COMMITTED)
如果一個事務(wù)只能讀到另一個已經(jīng)提交的事務(wù)修改過的數(shù)據(jù),并且其他事務(wù)每對該數(shù)據(jù)進(jìn)行一次修改并提交后,該事務(wù)都能查詢得到最新值,那么這種隔離級別就稱之為已提交讀(英文名:READ COMMITTED),如圖所示:
從圖中可以看到,第4步時,由于Session B中的事務(wù)尚未提交,所以Session A中的事務(wù)查詢得到的結(jié)果只是'劉備',而第6步時,由于Session B中的事務(wù)已經(jīng)提交,所以Session B中的事務(wù)查詢得到的結(jié)果就是'關(guān)羽'了。
對于某個處在在已提交讀隔離級別下的事務(wù)來說,只要其他事務(wù)修改了某個數(shù)據(jù)的值,并且之后提交了,那么該事務(wù)就會讀到該數(shù)據(jù)的最新值,比方說:
我們在Session B中提交了幾個隱式事務(wù),這些事務(wù)都修改了id為1的記錄的列c的值,每次事務(wù)提交之后,Session A中的事務(wù)都可以查看到最新的值。這種現(xiàn)象也被稱之為不可重復(fù)讀。
可重復(fù)讀(REPEATABLE READ)
在一些業(yè)務(wù)場景中,一個事務(wù)只能讀到另一個已經(jīng)提交的事務(wù)修改過的數(shù)據(jù),但是第一次讀過某條記錄后,即使其他事務(wù)修改了該記錄的值并且提交,該事務(wù)之后再讀該條記錄時,讀到的仍是第一次讀到的值,而不是每次都讀到不同的數(shù)據(jù)。那么這種隔離級別就稱之為可重復(fù)讀(英文名:REPEATABLE READ),如圖所示:
從圖中可以看出來,Session A中的事務(wù)在第一次讀取id為1的記錄時,列c的值為'劉備',之后雖然Session B中隱式提交了多個事務(wù),每個事務(wù)都修改了這條記錄,但是Session A中的事務(wù)讀到的列c的值仍為'劉備',與第一次讀取的值是相同的。
串行化(SERIALIZABLE)
以上3種隔離級別都允許對同一條記錄進(jìn)行讀-讀、讀-寫、寫-讀的并發(fā)操作,如果我們不允許讀-寫、寫-讀的并發(fā)操作,可以使用SERIALIZABLE隔離級別,示意圖如下:
如圖所示,當(dāng)Session B中的事務(wù)更新了id為1的記錄后,之后Session A中的事務(wù)再去訪問這條記錄時就被卡住了,直到Session B中的事務(wù)提交之后,Session A中的事務(wù)才可以獲取到查詢結(jié)果。
版本鏈
對于使用InnoDB存儲引擎的表來說,它的聚簇索引記錄中都包含兩個必要的隱藏列(row_id并不是必要的,我們創(chuàng)建的表中有主鍵或者非NULL唯一鍵時都不會包含row_id列):
trx_id:每次對某條聚簇索引記錄進(jìn)行改動時,都會把對應(yīng)的事務(wù)id賦值給trx_id隱藏列。roll_pointer:每次對某條聚簇索引記錄進(jìn)行改動時,都會把舊的版本寫入到undo日志中,然后這個隱藏列就相當(dāng)于一個指針,可以通過它來找到該記錄修改前的信息。
比方說我們的表t現(xiàn)在只包含一條記錄:
mysql> SELECT * FROM t;+----+--------+| id | c |+----+--------+| 1 | 劉備 |+----+--------+1 row in set (0.01 sec)
假設(shè)插入該記錄的事務(wù)id為80,那么此刻該條記錄的示意圖如下所示:
假設(shè)之后兩個id分別為100、200的事務(wù)對這條記錄進(jìn)行UPDATE操作,操作流程如下:
小貼士:能不能在兩個事務(wù)中交叉更新同一條記錄呢?哈哈,這是不可以滴,第一個事務(wù)更新了某條記錄后,就會給這條記錄加鎖,另一個事務(wù)再次更新時就需要等待第一個事務(wù)提交了,把鎖釋放之后才可以繼續(xù)更新。本篇文章不是討論鎖的,有關(guān)鎖的更多細(xì)節(jié)我們之后再說。
每次對記錄進(jìn)行改動,都會記錄一條undo日志,每條undo日志也都有一個roll_pointer屬性(INSERT操作對應(yīng)的undo日志沒有該屬性,因為該記錄并沒有更早的版本),可以將這些undo日志都連起來,串成一個鏈表,所以現(xiàn)在的情況就像下圖一樣:

對該記錄每次更新后,都會將舊值放到一條undo日志中,就算是該記錄的一個舊版本,隨著更新次數(shù)的增多,所有的版本都會被roll_pointer屬性連接成一個鏈表,我們把這個鏈表稱之為版本鏈,版本鏈的頭節(jié)點(diǎn)就是當(dāng)前記錄最新的值。另外,每個版本中還包含生成該版本時對應(yīng)的事務(wù)id,這個信息很重要,我們稍后就會用到。
ReadView
對于使用READ UNCOMMITTED隔離級別的事務(wù)來說,直接讀取記錄的最新版本就好了,對于使用SERIALIZABLE隔離級別的事務(wù)來說,使用加鎖的方式來訪問記錄。對于使用READ COMMITTED和REPEATABLE READ隔離級別的事務(wù)來說,就需要用到我們上邊所說的版本鏈了,核心問題就是:需要判斷一下版本鏈中的哪個版本是當(dāng)前事務(wù)可見的。所以設(shè)計InnoDB的大叔提出了一個ReadView的概念,這個ReadView中主要包含當(dāng)前系統(tǒng)中還有哪些活躍的讀寫事務(wù),把它們的事務(wù)id放到一個列表中,我們把這個列表命名為為m_ids。這樣在訪問某條記錄時,只需要按照下邊的步驟判斷記錄的某個版本是否可見:
如果被訪問版本的
trx_id屬性值小于m_ids列表中最小的事務(wù)id,表明生成該版本的事務(wù)在生成ReadView前已經(jīng)提交,所以該版本可以被當(dāng)前事務(wù)訪問。如果被訪問版本的
trx_id屬性值大于m_ids列表中最大的事務(wù)id,表明生成該版本的事務(wù)在生成ReadView后才生成,所以該版本不可以被當(dāng)前事務(wù)訪問。如果被訪問版本的
trx_id屬性值在m_ids列表中最大的事務(wù)id和最小事務(wù)id之間,那就需要判斷一下trx_id屬性值是不是在m_ids列表中,如果在,說明創(chuàng)建ReadView時生成該版本的事務(wù)還是活躍的,該版本不可以被訪問;如果不在,說明創(chuàng)建ReadView時生成該版本的事務(wù)已經(jīng)被提交,該版本可以被訪問。
如果某個版本的數(shù)據(jù)對當(dāng)前事務(wù)不可見的話,那就順著版本鏈找到下一個版本的數(shù)據(jù),繼續(xù)按照上邊的步驟判斷可見性,依此類推,直到版本鏈中的最后一個版本,如果最后一個版本也不可見的話,那么就意味著該條記錄對該事務(wù)不可見,查詢結(jié)果就不包含該記錄。
在MySQL中,READ COMMITTED和REPEATABLE READ隔離級別的的一個非常大的區(qū)別就是它們生成ReadView的時機(jī)不同,我們來看一下。
READ COMMITTED --- 每次讀取數(shù)據(jù)前都生成一個ReadView
比方說現(xiàn)在系統(tǒng)里有兩個id分別為100、200的事務(wù)在執(zhí)行:
# Transaction 100BEGIN;UPDATE t SET c = '關(guān)羽' WHERE id = 1;UPDATE t SET c = '張飛' WHERE id = 1;
# Transaction 200BEGIN;# 更新了一些別的表的記錄...
小貼士:事務(wù)執(zhí)行過程中,只有在第一次真正修改記錄時(比如使用INSERT、DELETE、UPDATE語句),才會被分配一個單獨(dú)的事務(wù)id,這個事務(wù)id是遞增的。
此刻,表t中id為1的記錄得到的版本鏈表如下所示:
假設(shè)現(xiàn)在有一個使用READ COMMITTED隔離級別的事務(wù)開始執(zhí)行:
# 使用READ COMMITTED隔離級別的事務(wù)BEGIN;# SELECT1:Transaction 100、200未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值為'劉備'
這個SELECT1的執(zhí)行過程如下:
在執(zhí)行
SELECT語句時會先生成一個ReadView,ReadView的m_ids列表的內(nèi)容就是[100, 200]。然后從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列
c的內(nèi)容是'張飛',該版本的trx_id值為100,在m_ids列表內(nèi),所以不符合可見性要求,根據(jù)roll_pointer跳到下一個版本。下一個版本的列
c的內(nèi)容是'關(guān)羽',該版本的trx_id值也為100,也在m_ids列表內(nèi),所以也不符合要求,繼續(xù)跳到下一個版本。下一個版本的列
c的內(nèi)容是'劉備',該版本的trx_id值為80,小于m_ids列表中最小的事務(wù)id100,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列c為'劉備'的記錄。
之后,我們把事務(wù)id為100的事務(wù)提交一下,就像這樣:
# Transaction 100BEGIN;UPDATE t SET c = '關(guān)羽' WHERE id = 1;UPDATE t SET c = '張飛' WHERE id = 1;COMMIT;
然后再到事務(wù)id為200的事務(wù)中更新一下表t中id為1的記錄:
# Transaction 200BEGIN;# 更新了一些別的表的記錄...UPDATE t SET c = '趙云' WHERE id = 1;UPDATE t SET c = '諸葛亮' WHERE id = 1;
此刻,表t中id為1的記錄的版本鏈就長這樣:
然后再到剛才使用READ COMMITTED隔離級別的事務(wù)中繼續(xù)查找這個id為1的記錄,如下:
# 使用READ COMMITTED隔離級別的事務(wù)BEGIN;# SELECT1:Transaction 100、200均未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值為'劉備'# SELECT2:Transaction 100提交,Transaction 200未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值為'張飛'
這個SELECT2的執(zhí)行過程如下:
在執(zhí)行
SELECT語句時會先生成一個ReadView,ReadView的m_ids列表的內(nèi)容就是[200](事務(wù)id為100的那個事務(wù)已經(jīng)提交了,所以生成快照時就沒有它了)。然后從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列
c的內(nèi)容是'諸葛亮',該版本的trx_id值為200,在m_ids列表內(nèi),所以不符合可見性要求,根據(jù)roll_pointer跳到下一個版本。下一個版本的列
c的內(nèi)容是'趙云',該版本的trx_id值為200,也在m_ids列表內(nèi),所以也不符合要求,繼續(xù)跳到下一個版本。下一個版本的列
c的內(nèi)容是'張飛',該版本的trx_id值為100,比m_ids列表中最小的事務(wù)id200還要小,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列c為'張飛'的記錄。
以此類推,如果之后事務(wù)id為200的記錄也提交了,再此在使用READ COMMITTED隔離級別的事務(wù)中查詢表t中id值為1的記錄時,得到的結(jié)果就是'諸葛亮'了,具體流程我們就不分析了??偨Y(jié)一下就是:使用READ COMMITTED隔離級別的事務(wù)在每次查詢開始時都會生成一個獨(dú)立的ReadView。
REPEATABLE READ ---在第一次讀取數(shù)據(jù)時生成一個ReadView
對于使用REPEATABLE READ隔離級別的事務(wù)來說,只會在第一次執(zhí)行查詢語句時生成一個ReadView,之后的查詢就不會重復(fù)生成了。我們還是用例子看一下是什么效果。
比方說現(xiàn)在系統(tǒng)里有兩個id分別為100、200的事務(wù)在執(zhí)行:
# Transaction 100BEGIN;UPDATE t SET c = '關(guān)羽' WHERE id = 1;UPDATE t SET c = '張飛' WHERE id = 1;
# Transaction 200BEGIN;# 更新了一些別的表的記錄...
此刻,表t中id為1的記錄得到的版本鏈表如下所示:

假設(shè)現(xiàn)在有一個使用REPEATABLE READ隔離級別的事務(wù)開始執(zhí)行:
# 使用REPEATABLE READ隔離級別的事務(wù)BEGIN;# SELECT1:Transaction 100、200未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值為'劉備'
這個SELECT1的執(zhí)行過程如下:
在執(zhí)行
SELECT語句時會先生成一個ReadView,ReadView的m_ids列表的內(nèi)容就是[100, 200]。然后從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列
c的內(nèi)容是'張飛',該版本的trx_id值為100,在m_ids列表內(nèi),所以不符合可見性要求,根據(jù)roll_pointer跳到下一個版本。下一個版本的列
c的內(nèi)容是'關(guān)羽',該版本的trx_id值也為100,也在m_ids列表內(nèi),所以也不符合要求,繼續(xù)跳到下一個版本。下一個版本的列
c的內(nèi)容是'劉備',該版本的trx_id值為80,小于m_ids列表中最小的事務(wù)id100,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列c為'劉備'的記錄。
之后,我們把事務(wù)id為100的事務(wù)提交一下,就像這樣:
# Transaction 100BEGIN;UPDATE t SET c = '關(guān)羽' WHERE id = 1;UPDATE t SET c = '張飛' WHERE id = 1;COMMIT;
然后再到事務(wù)id為200的事務(wù)中更新一下表t中id為1的記錄:
# Transaction 200BEGIN;# 更新了一些別的表的記錄...UPDATE t SET c = '趙云' WHERE id = 1;UPDATE t SET c = '諸葛亮' WHERE id = 1;
此刻,表t中id為1的記錄的版本鏈就長這樣:
然后再到剛才使用REPEATABLE READ隔離級別的事務(wù)中繼續(xù)查找這個id為1的記錄,如下:
# 使用REPEATABLE READ隔離級別的事務(wù)BEGIN;# SELECT1:Transaction 100、200均未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值為'劉備'# SELECT2:Transaction 100提交,Transaction 200未提交SELECT * FROM t WHERE id = 1; # 得到的列c的值仍為'劉備'
這個SELECT2的執(zhí)行過程如下:
因為之前已經(jīng)生成過
ReadView了,所以此時直接復(fù)用之前的ReadView,之前的ReadView中的m_ids列表就是[100, 200]。然后從版本鏈中挑選可見的記錄,從圖中可以看出,最新版本的列
c的內(nèi)容是'諸葛亮',該版本的trx_id值為200,在m_ids列表內(nèi),所以不符合可見性要求,根據(jù)roll_pointer跳到下一個版本。下一個版本的列
c的內(nèi)容是'趙云',該版本的trx_id值為200,也在m_ids列表內(nèi),所以也不符合要求,繼續(xù)跳到下一個版本。下一個版本的列
c的內(nèi)容是'張飛',該版本的trx_id值為100,而m_ids列表中是包含值為100的事務(wù)id的,所以該版本也不符合要求,同理下一個列c的內(nèi)容是'關(guān)羽'的版本也不符合要求。繼續(xù)跳到下一個版本。下一個版本的列
c的內(nèi)容是'劉備',該版本的trx_id值為80,80小于m_ids列表中最小的事務(wù)id100,所以這個版本是符合要求的,最后返回給用戶的版本就是這條列c為'劉備'的記錄。
也就是說兩次SELECT查詢得到的結(jié)果是重復(fù)的,記錄的列c值都是'劉備',這就是可重復(fù)讀的含義。如果我們之后再把事務(wù)id為200的記錄提交了,之后再到剛才使用REPEATABLE READ隔離級別的事務(wù)中繼續(xù)查找這個id為1的記錄,得到的結(jié)果還是'劉備',具體執(zhí)行過程大家可以自己分析一下。
MVCC總結(jié)
從上邊的描述中我們可以看出來,所謂的MVCC(Multi-Version Concurrency Control ,多版本并發(fā)控制)指的就是在使用READ COMMITTD、REPEATABLE READ這兩種隔離級別的事務(wù)在執(zhí)行普通的SEELCT操作時訪問記錄的版本鏈的過程,這樣子可以使不同事務(wù)的讀-寫、寫-讀操作并發(fā)執(zhí)行,從而提升系統(tǒng)性能。READ COMMITTD、REPEATABLE READ這兩個隔離級別的一個很大不同就是生成ReadView的時機(jī)不同,READ COMMITTD在每一次進(jìn)行普通SELECT操作前都會生成一個ReadView,而REPEATABLE READ只在第一次進(jìn)行普通SELECT操作前生成一個ReadView,之后的查詢操作都重復(fù)這個ReadView就好了。