我們知道事務(wù)有 4 個(gè)隔離級別方库,以及可能存在的三種異常問題,如下圖所示:
在 MySQL 中障斋,默認(rèn)的隔離級別是可重復(fù)讀纵潦,可以解決臟讀和不可重復(fù)讀的問題,但不能解決幻讀問題垃环。如果我們想要解決幻讀問題邀层,就需要采用串行化的方式,也就是將隔離級別提升到最高遂庄,但這樣一來就會大幅降低數(shù)據(jù)庫的事務(wù)并發(fā)能力寥院。
有沒有一種方式,可以不采用鎖機(jī)制涧团,而是通過樂觀鎖的方式來解決不可重復(fù)讀和幻讀問題呢只磷?實(shí)際上 MVCC 機(jī)制的設(shè)計(jì),就是用來解決這個(gè)問題的泌绣,它可以在大多數(shù)情況下替代行級鎖钮追,降低系統(tǒng)的開銷。
MVCC 是什么阿迈,解決了什么問題
MVCC 的英文全稱是 Multiversion Concurrency Control元媚,中文翻譯過來就是多版本并發(fā)控制技術(shù)。從名字中也能看出來,MVCC 是通過數(shù)據(jù)行的多個(gè)版本管理來實(shí)現(xiàn)數(shù)據(jù)庫的并發(fā)控制刊棕,簡單來說它的思想就是保存數(shù)據(jù)的歷史版本炭晒。這樣我們就可以通過比較版本號決定數(shù)據(jù)是否顯示出來(具體的規(guī)則后面會介紹到),讀取數(shù)據(jù)的時(shí)候不需要加鎖也可以保證事務(wù)的隔離效果甥角。
通過 MVCC 我們可以解決以下幾個(gè)問題:
- 讀寫之間阻塞的問題网严,通過 MVCC 可以讓讀寫互相不阻塞,即讀不阻塞寫嗤无,寫不阻塞讀震束,這樣就可以提升事務(wù)并發(fā)處理能力。
- 降低了死鎖的概率当犯。這是因?yàn)?MVCC 采用了樂觀鎖的方式垢村,讀取數(shù)據(jù)時(shí)并不需要加鎖,對于寫操作嚎卫,也只鎖定必要的行嘉栓。
- 解決一致性讀的問題。一致性讀也被稱為快照讀拓诸,當(dāng)我們查詢數(shù)據(jù)庫在某個(gè)時(shí)間點(diǎn)的快照時(shí)侵佃,只能看到這個(gè)時(shí)間點(diǎn)之前事務(wù)提交更新的結(jié)果,而不能看到這個(gè)時(shí)間點(diǎn)之后事務(wù)提交的更新結(jié)果奠支。
什么是快照讀趣钱,什么是當(dāng)前讀
那么什么是快照讀呢?快照讀讀取的是快照數(shù)據(jù)胚宦。不加鎖的簡單的 SELECT 都屬于快照讀,比如這樣:
SELECT * FROM player WHERE ...
當(dāng)前讀就是讀取最新數(shù)據(jù)燕垃,而不是歷史版本的數(shù)據(jù)枢劝。加鎖的 SELECT,或者對數(shù)據(jù)進(jìn)行增刪改都會進(jìn)行當(dāng)前讀卜壕,比如:
SELECT * FROM player LOCK IN SHARE MODE;
SELECT * FROM player FOR UPDATE;
INSERT INTO player values ...
DELETE FROM player WHERE ...
UPDATE player SET ...
這里需要說明的是您旁,快照讀就是普通的讀操作,而當(dāng)前讀包括了加鎖的讀取和 DML 操作轴捎。
上面講 MVCC 的作用鹤盒,你可能覺得有些抽象。我們用具體的例子體會一下侦副。
比如我們有個(gè)賬戶金額表 user_balance侦锯,包括三個(gè)字段,分別是 username 用戶名秦驯、balance 余額和 bankcard 卡號尺碰,具體的數(shù)據(jù)示意如下:
為了方便,我們假設(shè) user_balance 表中只有用戶 A 和 B 有余額,其他人的賬戶余額均為 0亲桥。下面我們考慮一個(gè)使用場景洛心。
用戶 A 和用戶 B 之間進(jìn)行轉(zhuǎn)賬,此時(shí)數(shù)據(jù)庫管理員想要查詢 user_balance 表中的總金額:
SELECT SUM(balance) FROM user_balance
你可以思考下题篷,如果數(shù)據(jù)庫不支持 MVCC 機(jī)制词身,而是采用自身的鎖機(jī)制來實(shí)現(xiàn)的話,可能會出現(xiàn)怎樣的情況呢番枚?
情況 1:因?yàn)樾枰捎眉有墟i的方式法严,用戶 A 給 B 轉(zhuǎn)賬時(shí)間等待很久,如下圖所示户辫。
你能看到為了保證數(shù)據(jù)的一致性渐夸,我們需要給統(tǒng)計(jì)到的數(shù)據(jù)行都加上行鎖。這時(shí)如果 A 所在的數(shù)據(jù)行加上了行鎖渔欢,就不能給 B 轉(zhuǎn)賬了墓塌,只能等到所有操作完成之后,釋放行鎖再繼續(xù)進(jìn)行轉(zhuǎn)賬奥额,這樣就會造成用戶事務(wù)處理的等待時(shí)間過長苫幢。
情況 2:當(dāng)我們讀取的時(shí)候用了加行鎖,可能會出現(xiàn)死鎖的情況垫挨,如下圖所示韩肝。比如當(dāng)我們讀到 A 有 1000 元的時(shí)候,此時(shí) B 開始執(zhí)行給 A 轉(zhuǎn)賬:
UPDATE user_balance SET balance=balance-100 WHERE username ='B'
執(zhí)行完之后馬上執(zhí)行下一步:
UPDATE user_balance SET balance=balance+100 WHERE username ='A'
我們會發(fā)現(xiàn)此時(shí) A 被鎖住了九榔,而管理員事務(wù)還需要對 B 進(jìn)行訪問哀峻,但 B 被用戶事務(wù)鎖住了,此時(shí)就發(fā)生了死鎖哲泊。
MVCC 可以解決讀寫互相阻塞的問題剩蟀,這樣提升了效率,同時(shí)因?yàn)椴捎昧藰酚^鎖的思想切威,降低了死鎖的概率育特。
InnoDB 中的 MVCC 是如何實(shí)現(xiàn)的?
我剛才講解了 MVCC 的思想和作用先朦,實(shí)際上 MVCC 沒有正式的標(biāo)準(zhǔn)缰冤,所以在不同的 DBMS 中,MVCC 的實(shí)現(xiàn)方式可能是不同的喳魏,你可以參考相關(guān)的 DBMS 文檔棉浸。今天我來講一下 InnoDB 中 MVCC 的實(shí)現(xiàn)機(jī)制。
在了解 InnoDB 中 MVCC 的實(shí)現(xiàn)方式之前截酷,我們需要了解 InnoDB 是如何存儲記錄的多個(gè)版本的涮拗。這里的多版本對應(yīng)的就是 MVCC 前兩個(gè)字母的釋義:Multi Version乾戏,我們需要了解和它相關(guān)的數(shù)據(jù)都有哪些,存儲在哪里三热。這些數(shù)據(jù)包括事務(wù)版本號鼓择、行記錄中的隱藏列和 Undo Log。
事務(wù)版本號
每開啟一個(gè)事務(wù)就漾,我們都會從數(shù)據(jù)庫中獲得一個(gè)事務(wù) ID(也就是事務(wù)版本號)呐能,這個(gè)事務(wù) ID 是自增長的,通過 ID 大小抑堡,我們就可以判斷事務(wù)的時(shí)間順序摆出。
行記錄的隱藏列
InnoDB 的葉子段存儲了數(shù)據(jù)頁,數(shù)據(jù)頁中保存了行記錄首妖,而在行記錄中有一些重要的隱藏字段偎漫,如下圖所示:
- db_row_id:隱藏的行 ID,用來生成默認(rèn)聚集索引有缆。如果我們創(chuàng)建數(shù)據(jù)表的時(shí)候沒有指定聚集索引象踊,這時(shí) InnoDB 就會用這個(gè)隱藏 ID 來創(chuàng)建聚集索引。采用聚集索引的方式可以提升數(shù)據(jù)的查找效率棚壁。
- db_trx_id:操作這個(gè)數(shù)據(jù)的事務(wù) ID杯矩,也就是最后一個(gè)對該數(shù)據(jù)進(jìn)行插入或更新的事務(wù) ID。
- db_roll_ptr:回滾指針袖外,也就是指向這個(gè)記錄的 Undo Log 信息史隆。
Undo Log
InnoDB 將行記錄快照保存在了 Undo Log 里,我們可以在回滾段中找到它們曼验,如下圖所示:
從圖中你能看到回滾指針將數(shù)據(jù)行的所有快照記錄都通過鏈表的結(jié)構(gòu)串聯(lián)了起來泌射,每個(gè)快照的記錄都保存了當(dāng)時(shí)的 db_trx_id,也是那個(gè)時(shí)間點(diǎn)操作這個(gè)數(shù)據(jù)的事務(wù) ID鬓照。這樣如果我們想要找歷史快照魄幕,就可以通過遍歷回滾指針的方式進(jìn)行查找。
Read View 是如何工作的
在 MVCC 機(jī)制中颖杏,多個(gè)事務(wù)對同一個(gè)行記錄進(jìn)行更新會產(chǎn)生多個(gè)歷史快照,這些歷史快照保存在 Undo Log 里坛芽。如果一個(gè)事務(wù)想要查詢這個(gè)行記錄留储,需要讀取哪個(gè)版本的行記錄呢?這時(shí)就需要用到 Read View 了咙轩,它幫我們解決了行的可見性問題获讳。Read View 保存了當(dāng)前事務(wù)開啟時(shí)所有活躍(還沒有提交)的事務(wù)列表,換個(gè)角度你可以理解為 Read View 保存了不應(yīng)該讓這個(gè)事務(wù)看到的其他的事務(wù) ID 列表活喊。
在 Read VIew 中有幾個(gè)重要的屬性:
- trx_ids丐膝,系統(tǒng)當(dāng)前正在活躍的事務(wù) ID 集合。
- low_limit_id,活躍的事務(wù)中最大的事務(wù) ID帅矗。
- up_limit_id偎肃,活躍的事務(wù)中最小的事務(wù) ID。
- creator_trx_id浑此,創(chuàng)建這個(gè) Read View 的事務(wù) ID累颂。
如圖所示,trx_ids 為 trx2凛俱、trx3紊馏、trx5 和 trx8 的集合,活躍的最大事務(wù) ID(low_limit_id)為 trx8蒲犬,活躍的最小事務(wù) ID(up_limit_id)為 trx2朱监。
假設(shè)當(dāng)前有事務(wù) creator_trx_id 想要讀取某個(gè)行記錄,這個(gè)行記錄的事務(wù) ID 為 trx_id原叮,那么會出現(xiàn)以下幾種情況赫编。
如果 trx_id < 活躍的最小事務(wù) ID(up_limit_id),也就是說這個(gè)行記錄在這些活躍的事務(wù)創(chuàng)建之前就已經(jīng)提交了篇裁,那么這個(gè)行記錄對該事務(wù)是可見的沛慢。
如果 trx_id > 活躍的最大事務(wù) ID(low_limit_id),這說明該行記錄在這些活躍的事務(wù)創(chuàng)建之后才創(chuàng)建达布,那么這個(gè)行記錄對當(dāng)前事務(wù)不可見团甲。
如果 up_limit_id < trx_id < low_limit_id,說明該行記錄所在的事務(wù) trx_id 在目前 creator_trx_id 這個(gè)事務(wù)創(chuàng)建的時(shí)候黍聂,可能還處于活躍的狀態(tài)躺苦,因此我們需要在 trx_ids 集合中進(jìn)行遍歷,如果 trx_id 存在于 trx_ids 集合中产还,證明這個(gè)事務(wù) trx_id 還處于活躍狀態(tài)匹厘,不可見。否則脐区,如果 trx_id 不存在于 trx_ids 集合中愈诚,證明事務(wù) trx_id 已經(jīng)提交了,該行記錄可見牛隅。
了解了這些概念之后炕柔,我們來看下當(dāng)查詢一條記錄的時(shí)候,系統(tǒng)如何通過多版本并發(fā)控制技術(shù)找到它:
- 首先獲取事務(wù)自己的版本號媒佣,也就是事務(wù) ID匕累;
- 獲取 Read View;
- 查詢得到的數(shù)據(jù)默伍,然后與 Read View 中的事務(wù)版本號進(jìn)行比較欢嘿;
- 如果不符合 ReadView 規(guī)則衰琐,就需要從 Undo Log 中獲取歷史快照;
- 最后返回符合規(guī)則的數(shù)據(jù)炼蹦。
你能看到 InnoDB 中羡宙,MVCC 是通過 Undo Log + Read View 進(jìn)行數(shù)據(jù)讀取,Undo Log 保存了歷史快照框弛,而 Read View 規(guī)則幫我們判斷當(dāng)前版本的數(shù)據(jù)是否可見辛辨。
需要說明的是,在隔離級別為讀已提交(Read Commit)時(shí)瑟枫,一個(gè)事務(wù)中的每一次 SELECT 查詢都會獲取一次 Read View斗搞。如表所示:
你能看到,在讀已提交的隔離級別下慷妙,同樣的查詢語句都會重新獲取一次 Read View僻焚,這時(shí)如果 Read View 不同,就可能產(chǎn)生不可重復(fù)讀或者幻讀的情況膝擂。
當(dāng)隔離級別為可重復(fù)讀的時(shí)候虑啤,就避免了不可重復(fù)讀,這是因?yàn)橐粋€(gè)事務(wù)只在第一次 SELECT 的時(shí)候會獲取一次 Read View架馋,而后面所有的 SELECT 都會復(fù)用這個(gè) Read View狞山,如下表所示:
InnoDB 是如何解決幻讀的
不過這里需要說明的是,在可重復(fù)讀的情況下叉寂,InnoDB 可以通過 Next-Key 鎖 +MVCC 來解決幻讀問題萍启。
在讀已提交的情況下,即使采用了 MVCC 方式也會出現(xiàn)幻讀屏鳍。如果我們同時(shí)開啟事務(wù) A 和事務(wù) B勘纯,先在事務(wù) A 中進(jìn)行某個(gè)條件范圍的查詢,讀取的時(shí)候采用排它鎖钓瞭,在事務(wù) B 中增加一條符合該條件范圍的數(shù)據(jù)驳遵,并進(jìn)行提交,然后我們在事務(wù) A 中再次查詢該條件范圍的數(shù)據(jù)山涡,就會發(fā)現(xiàn)結(jié)果集中多出一個(gè)符合條件的數(shù)據(jù)堤结,這樣就出現(xiàn)了幻讀。
出現(xiàn)幻讀的原因是在讀已提交的情況下鸭丛,InnoDB 只采用記錄鎖(Record Locking)霍殴。這里要介紹下 InnoDB 三種行鎖的方式:
- 記錄鎖:針對單個(gè)行記錄添加鎖。
- 間隙鎖(Gap Locking):可以幫我們鎖住一個(gè)范圍(索引之間的空隙)系吩,但不包括記錄本身。采用間隙鎖的方式可以防止幻讀情況的產(chǎn)生妒蔚。
- Next-Key 鎖:幫我們鎖住一個(gè)范圍穿挨,同時(shí)鎖定記錄本身月弛,相當(dāng)于間隙鎖 + 記錄鎖,可以解決幻讀的問題科盛。
在隔離級別為可重復(fù)讀時(shí)帽衙,InnoDB 會采用 Next-Key 鎖的機(jī)制,幫我們解決幻讀問題贞绵。
還是這個(gè)例子厉萝,我們能看到當(dāng)我們想要插入球員艾利克斯·倫(身高 2.16 米)的時(shí)候,事務(wù) B 會超時(shí)榨崩,無法插入該數(shù)據(jù)谴垫。這是因?yàn)椴捎昧?Next-Key 鎖,會將 height>2.08 的范圍都進(jìn)行鎖定母蛛,就無法插入符合這個(gè)范圍的數(shù)據(jù)了翩剪。然后事務(wù) A 重新進(jìn)行條件范圍的查詢,就不會出現(xiàn)幻讀的情況彩郊。
總結(jié)
今天關(guān)于 MVCC 的內(nèi)容有些多前弯,通過學(xué)習(xí)你應(yīng)該能對采用 MVCC 這種樂觀鎖的方式來保證事務(wù)的隔離效果更有體會。
我們需要記住秫逝,MVCC 的核心就是 Undo Log+ Read View恕出,“MV”就是通過 Undo Log 來保存數(shù)據(jù)的歷史版本,實(shí)現(xiàn)多版本的管理违帆,“CC”是通過 Read View 來實(shí)現(xiàn)管理浙巫,通過 Read View 原則來決定數(shù)據(jù)是否顯示。同時(shí)針對不同的隔離級別前方,Read View 的生成策略不同狈醉,也就實(shí)現(xiàn)了不同的隔離級別。
MVCC 是一種機(jī)制惠险,MySQL苗傅、Oracle、SQL Server 和 PostgreSQL 的實(shí)現(xiàn)方式均有不同班巩,我們在學(xué)習(xí)的時(shí)候渣慕,更主要的是要理解 MVCC 的設(shè)計(jì)思想。