網(wǎng)站建設(shè)規(guī)范搜狗搜索引擎推廣
一、前言
在分析 MVCC 的原理之前,我們先回顧一下 MySQL 的一些內(nèi)容以及關(guān)于 MVCC 的一些簡單介紹。(注:下面沒有特別說明默認(rèn) MySQL 的引擎為 InnoDB )
1.1 數(shù)據(jù)庫的并發(fā)場景
數(shù)據(jù)庫并發(fā)場景有三種,分別是:
- 讀-讀:不存在線程安全問題,不需要并發(fā)控制。
- 讀-寫:有線程安全問題,可能會(huì)造成事務(wù)隔離性問題,可能遇到臟讀、不可重復(fù)讀、幻讀等問題。
- 寫-寫:有線程安全問題,可能會(huì)存在更新丟失的問題,比如第一類更新丟失、第二類更新丟失。
(第一類丟失更新:事務(wù)A回滾時(shí),將已經(jīng)提交的事務(wù)B的更新數(shù)據(jù)覆蓋了;第二類丟失更新:事務(wù)A提交覆蓋了事務(wù)B已經(jīng)提交的數(shù)據(jù),造成事務(wù)B所做的操作丟失)
1.2 什么是 MVCC
- MVCC全稱 Multi-Version Concurrency Control ,即多版本并發(fā)控制,MVCC 是一種并發(fā)控制的方法,一般在數(shù)據(jù)庫管理系統(tǒng)中實(shí)現(xiàn)對數(shù)據(jù)庫的并發(fā)訪問,在編程語言中實(shí)現(xiàn)事務(wù)內(nèi)存。
- 多版本控制:指的是一種提高并發(fā)的技術(shù),在最早的數(shù)據(jù)庫系統(tǒng)中只有讀-讀之間可以并發(fā),讀-寫、寫-寫之間都要阻塞。引入多版本并發(fā)控制之后,只有寫-寫之間相互阻塞,其他三種操作都可以并行,這樣大幅的提高了 InnoDB 的并發(fā)度。在內(nèi)部實(shí)現(xiàn)中,InnoDB 是通過 undo log 實(shí)現(xiàn)的,通過 undo log 可以找回?cái)?shù)據(jù)的歷史版本。找回的歷史版本可以提供給用戶讀(按照隔離級別的定義,有些讀請求只能看到比較老的數(shù)據(jù)版本),也可以在回滾的時(shí)候覆蓋數(shù)據(jù)頁上的數(shù)據(jù)。在 InnoDB 內(nèi)部中,會(huì)記錄一個(gè)全局的活躍讀寫事務(wù)數(shù)組,其主要用來判斷事務(wù)的可見行。
一句話概述,MVCC 在 MySQL InnoDB 中的實(shí)現(xiàn)主要是為了提高數(shù)據(jù)庫并發(fā)性能,用更好的方式去處理讀寫沖突,做到即使有讀寫沖突時(shí),也能做到不加鎖,做到非阻塞并發(fā)讀。
1.3 當(dāng)前讀和快照讀
當(dāng)前讀
select xxx lock in share mode; # 共享鎖
#排它鎖
select xxx for update;
update xxx;
delete xxx;
insert xxx;像上面的這些操作就是一種當(dāng)前讀,因?yàn)樗x取的是數(shù)據(jù)的最新版本,讀取時(shí)還要保證其他事務(wù)不能修改當(dāng)前記錄,會(huì)對記錄進(jìn)行加鎖。
快照讀
不加鎖的 select 就是快照讀,即不加鎖的非阻塞讀。(快照讀的前提是隔離級別不是 serializable,serializable 的隔離級別下快照讀會(huì)退化成當(dāng)前讀) 之所以出現(xiàn)快照讀的情況,是基于提高并發(fā)性能的考慮,快照讀的實(shí)現(xiàn)是基于 MVCC 的,可以認(rèn)為 MVCC 是行鎖的一個(gè)變種,但是它在很多情況下避免了加鎖操作,降低了開銷。既然是基于多版本,所以快照讀可能讀到的不一定是數(shù)據(jù)的最新版本,而有可能是之前的歷史版本。
1.4 當(dāng)前讀和快照讀與 MVCC 的關(guān)系
準(zhǔn)確的說,MVCC 指的是**"維護(hù)一個(gè)數(shù)據(jù)的多個(gè)版本,使得讀寫操作沒有沖突"這么一個(gè)概念,僅僅是一個(gè)理想狀態(tài)。而在 MySQL 中,實(shí)現(xiàn)這么一個(gè) MVCC 理想概念,我們就需要 MySQL 提供具體的功能去實(shí)現(xiàn),而快照讀**就是 MySQL 為我們實(shí)現(xiàn) MVCC 理想模型的其中一個(gè)具體非阻塞讀功能。而相對而言,當(dāng)前讀就是一個(gè)悲觀鎖的具體功能實(shí)現(xiàn),而要說的在細(xì)致一點(diǎn),快照讀本身也是一個(gè)抽象概念,在深入研究,MVCC 模型在 MySQL 中的具體實(shí)現(xiàn)則是由三個(gè)隱式字段、undo log 、Read View 等去完成的。
1.5 MVCC 能解決什么問題
MVCC 是一種解決讀寫沖突的無鎖并發(fā)控制手段,也就是為事務(wù)分配單向增長的時(shí)間戳,為每個(gè)修改保存一個(gè)版本,版本與事務(wù)時(shí)間戳關(guān)聯(lián),讀操作只讀該事務(wù)開始前的數(shù)據(jù)庫的快照,所以 MVCC 可以為數(shù)據(jù)庫解決以下問題:
- 在并發(fā)讀數(shù)據(jù)庫時(shí),可以做到在讀操作時(shí)不用阻塞寫操作,寫操作也不用阻塞讀操作,提高了數(shù)據(jù)庫并發(fā)讀寫性能。
- 可以解決臟讀、不可重復(fù)讀、幻讀等事務(wù)隔離問題(不能解決更新丟失的問題)。
所以說 MVCC 就是開發(fā)人員不滿意只讓數(shù)據(jù)庫采用悲觀鎖(加鎖)這樣性能不佳的形式去解決讀-寫的問題而提出的解決方案,所以在數(shù)據(jù)庫中,因?yàn)橛辛?MVCC ,所以我們可以形成兩個(gè)組合:
- MVCC + 悲觀鎖
MVCC解決讀寫沖突,悲觀鎖解決寫-寫沖突
- MVCC + 樂觀鎖
MVCC解決讀寫沖突,樂觀鎖解決寫-寫沖突。
二、MVCC實(shí)現(xiàn)原理
2.1 隱式字段
在一張表中,除了我們自定義的字段,實(shí)際上 MySQL 會(huì)隱式的定義一些字段。
DB_TRX_ID
6byte,最近修改(修改/插入)事務(wù)ID:記錄創(chuàng)建這條記錄/最后一次修改該記錄的事務(wù)ID
DB_ROLL_PTR
7byte,回滾指針,指向這條記錄的上一個(gè)版本(存儲于 rollback segment 里)
DB_ROW_ID
6byte,隱含的自增ID(隱藏主鍵),如果數(shù)據(jù)表沒有主鍵,InnoDB 會(huì)自動(dòng)以 DB_ROW_ID 產(chǎn)生一個(gè)聚簇索引
實(shí)際還有一個(gè)刪除 flag 隱藏字段, 即記錄被更新或刪除并不代表真的刪除,而是刪除 flag 變了
例如下面是 person 表的某條記錄,如下圖,DB_ROW_ID 是數(shù)據(jù)庫為改行記錄生產(chǎn)的唯一隱式主鍵,DB_TRX_ID 是當(dāng)前操作該記錄的事務(wù)ID,而 DB_ROLL_PTR 是一個(gè)回滾指針,用于配合 undo log 日志,指向上一個(gè)版本。

2.2 undo log日志
undo log 類型
insert undo log
是指在 insert 操作中產(chǎn)生的 undo log。因?yàn)?amp;nbsp;insert 操作的記錄,只對當(dāng)前事務(wù)本身可見,對其他事務(wù)不可見(這是事務(wù)隔離性的要求),因此這種 undo log 可以在事務(wù)提交后直接刪除。不需要進(jìn)行 purge 操作
update undo log
是對 delete 和 update 操作產(chǎn)生的 undo log。該 undo log 可能需要提供給 MVCC 機(jī)制使用,因此不能在事務(wù)提交時(shí)就進(jìn)行刪除,提交時(shí)放入 undo log 鏈表,等待 purge 線程進(jìn)行最后的刪除。
purge 線程
為了實(shí)現(xiàn) InnoDB 的 MVCC 機(jī)制,更新或者刪除操作都只是設(shè)置一下老記錄的 deleted_bit(即前面提到的刪除 flag ),并不真正將過時(shí)的記錄刪除。為了節(jié)省磁盤空間,InnoDB 有專門的 purge 線程來清理 deleted_bit 為 true 的記錄。為了不影響 MVCC 的正常工作,purge 線程自己也維護(hù)了一個(gè) Read View(這個(gè) Read View 相當(dāng)于系統(tǒng)中最老活躍事務(wù)的 Read View ),如果某個(gè)記錄的 deleted_bit 為true,并且 DB_TRX_ID 相對于 purge 線程的 Read View 可見,那么這條記錄一定是可以被安全清除的。
對 MVCC 有幫助的實(shí)質(zhì)是 update undo log ,undo log 存在于 rollback segment 中舊記錄鏈,它的執(zhí)行流程如下:
- 一個(gè)事務(wù)插入 person 表插入了一條新記錄,記錄如下,name 為 Jerry, age 為24,隱式主鍵是1,事務(wù)ID和回滾指針我們假設(shè)為NULL。

2.現(xiàn)在來了一個(gè) 事務(wù)1 對記錄的 name 進(jìn)行了修改,改為了 Tom,執(zhí)行過程如下:
- 在事務(wù)1修改該行數(shù)據(jù)時(shí),數(shù)據(jù)會(huì)先對這行記錄加排它鎖。
- 然后把該行數(shù)據(jù)拷貝到 undo log 中,作為舊記錄,即在 undo log 中有當(dāng)前行的拷貝副本。
- 拷貝完畢后,修改該行的 name 為 tom,并且修改隱藏字段的事務(wù)ID為當(dāng)前事務(wù)1的ID,我們默認(rèn)從1開始,之后遞增,回滾指針指向拷貝到 undo log 的副本記錄,即表示我的上一個(gè)版本就是它。
- 事務(wù)提交后,釋放排它鎖。

3.又來了個(gè)事務(wù)2修改 person 表的同一個(gè)記錄,將 age 修改為30歲,執(zhí)行過程如下:
- 在事務(wù)2修改該行數(shù)據(jù)時(shí),數(shù)據(jù)庫也先為該行加鎖
- 然后把該行數(shù)據(jù)拷貝到 undo log 中,作為舊記錄,發(fā)現(xiàn)該行記錄已經(jīng)有 undo log 了,那么最新的舊數(shù)據(jù)作為鏈表的表頭,插在該行記錄的 undo log 最前面
- 修改該行 age 為30歲,并且修改隱藏字段的事務(wù)ID為當(dāng)前事務(wù)2的ID, 那就是2,回滾指針指向剛剛拷貝到 undo log 的副本記錄
- 事務(wù)提交,釋放鎖

從上面我們可以看出,不同事務(wù)或者相同事務(wù)對同一記錄的修改,會(huì)導(dǎo)致該記錄的 undo log 稱為一條記錄版本線性表,即鏈表,undo log 的表頭就是最新的舊記錄,(當(dāng)然就像之前說的該 undo log 的節(jié)點(diǎn)可能會(huì)被 purge 線程清除掉,像圖中的第一條 insert undo log ,其實(shí)在事務(wù)提交之后可能就被刪除丟失了,不過這里為了演示,所以還放在這里)
2.3 Read View
什么是 Read View ?
Read View 是事務(wù)進(jìn)行快照讀操作時(shí)產(chǎn)生的讀視圖,在該事務(wù)執(zhí)行快照讀的那一刻,會(huì)生成數(shù)據(jù)庫系統(tǒng)的當(dāng)前的一個(gè)快照,記錄并維護(hù)當(dāng)前活躍事務(wù)的ID(當(dāng)每個(gè)事務(wù)開啟時(shí),都會(huì)被分配一個(gè) ID,這個(gè) ID 是自增的,所以最新的事務(wù),ID 值越大)
可見行判斷
所以我們知道 Read View 主要是用來做可見性判斷的,即當(dāng)我們某個(gè)事務(wù)執(zhí)行快照讀的時(shí)候,對該記錄創(chuàng)建一個(gè) Read View 讀視圖,把它比作條件來判斷當(dāng)前事務(wù)能夠看到哪個(gè)版本的數(shù)據(jù),既可能是當(dāng)前最新的數(shù)據(jù),也有可能是該行記錄的 undo log 里面的某個(gè)版本的數(shù)據(jù)。
Read View 遵循一個(gè)可見性算法,主要是將要被修改的數(shù)據(jù)的最新記錄的 DB_TRX_ID(即當(dāng)前事務(wù)ID),與系統(tǒng)當(dāng)前其他活躍事務(wù)的ID去對比(由 Read View 維護(hù)),如果 DB_TRX_ID 跟 Read View 的屬性做了某些對比,不符合可見性,那么就由 DB_ROLL_PTR 回滾指針去取出undo log中的 DB_TRX_ID 再比較,即遍歷鏈表的 DB_TRX_ID(從鏈表頭到尾,即從最近的一次修改查起),直到找到滿足特定條件的 DB_TRX_ID,那么這個(gè) DB_TRX_ID 所在的舊記錄就是當(dāng)前事務(wù)能看見的最新的老版本。
判斷條件是什么?
我們可以將 Read View 簡單的理解為三個(gè)全局屬性
trx_list 一個(gè)數(shù)值列表,用來維護(hù) Read View 生成時(shí)刻此時(shí)系統(tǒng)正活躍的事務(wù)ID
up_limit_id 記錄 trx_list 中的最小的事務(wù) ID
low_limit_id 在 Read View 生成時(shí)刻系統(tǒng)尚未分配的下一個(gè)事務(wù) ID,即目前(不一定是 Read View 中)已經(jīng)出現(xiàn)過的事務(wù) ID 最大值+1
比較步驟
- 首先比較 DB_TRX_ID < up_limit_id, 如果小于,則當(dāng)前事務(wù)能看到 DB_TRX_ID 所在的記錄,如果大于等于進(jìn)入下一個(gè)判斷。
- 接下來判斷 DB_TRX_ID 大于等于 low_limit_id , 如果大于等于則代表 DB_TRX_ID 所在的記錄在 Read View 生成后才出現(xiàn)的,那對當(dāng)前事務(wù)肯定不可見,如果小于則進(jìn)入下一個(gè)判斷。
- 判斷 DB_TRX_ID 是否在活躍事務(wù)之中,trx_list.contains( DB_TRX_ID ),如果在,則代表我 Read View 生成時(shí)刻,你這個(gè)事務(wù)還在活躍,還沒有 Commit,你修改的數(shù)據(jù),我當(dāng)前事務(wù)也是看不見的;如果不在,則說明,你的這個(gè)事務(wù)在 Read View 生成之前就已經(jīng) Commit了,你修改的結(jié)果,我當(dāng)前事務(wù)是能看見的。
2.4 實(shí)現(xiàn)流程
我們在了解了隱式字段、undo log 以及 Read View 的概念之后,我們模擬一下 MVCC 實(shí)現(xiàn)的整體流程。
假設(shè)當(dāng)前有四個(gè)事務(wù),當(dāng)事務(wù)2對某行數(shù)據(jù)執(zhí)行了快照讀,數(shù)據(jù)庫為該行數(shù)據(jù)生成一個(gè) Read View 讀視圖,假設(shè)當(dāng)前事務(wù)ID為2,此時(shí)還有事務(wù)1和事務(wù)3在活躍中,事務(wù)4在事務(wù)2快照讀前一刻提交更新了,所以 Read View 記錄了系統(tǒng)當(dāng)前活躍事務(wù)1,3的ID,維護(hù)在一個(gè)列表上,假設(shè)我們稱為 trx_list 。
事務(wù)1事務(wù)2事務(wù)3事務(wù)4事務(wù)開始事務(wù)開始事務(wù)開始事務(wù)開始………修改且已提交進(jìn)行中快照讀進(jìn)行中
………Read View 不僅僅會(huì)通過一個(gè)列表 trx_list 來維護(hù)事務(wù)2執(zhí)行快照讀那刻系統(tǒng)正活躍的事務(wù)ID,還會(huì)有兩個(gè)屬性 up_limit_id(記錄 trx_list 列表中事務(wù)ID最小的ID),low_limit_id(記錄 trx_list 列表中事務(wù)ID最大的ID,也有人說快照讀那刻系統(tǒng)尚未分配的下一個(gè)事務(wù)ID也就是目前已出現(xiàn)過的事務(wù)ID的最大值+1,所以在這里例子中 up_limit_id 就是1,low_limit_id 就是4 + 1 = 5,trx_list 集合的值是1,3,Read View 如下圖
我們的例子中,只有事務(wù)4修改過該行記錄,并在事務(wù)2執(zhí)行快照讀前,就提交了事務(wù),所以當(dāng)前該行當(dāng)前數(shù)據(jù)的 undo log 如下圖所示;我們的事務(wù)2在快照讀該行記錄的時(shí)候,就會(huì)拿該行記錄的 DB_TRX_ID 去跟 up_limit_id , low_limit_id 和活躍事務(wù)ID列表( trx_list )進(jìn)行比較,判斷當(dāng)前事務(wù)2能看到該記錄的版本是哪個(gè)。
所以先拿該記錄 DB_TRX_ID 字段記錄的事務(wù)ID 4去跟 Read View 的的 up_limit_id 比較,看4是否小于 up_limit_id(1),所以不符合條件,繼續(xù)判斷 4 是否大于等于 low_limit_id(5),也不符合條件,最后判斷4是否處于 trx_list 中的活躍事務(wù), 最后發(fā)現(xiàn)事務(wù)ID為4的事務(wù)不在當(dāng)前活躍事務(wù)列表中, 符合可見性條件,所以事務(wù)4修改后提交的最新結(jié)果對事務(wù)2快照讀時(shí)是可見的,所以事務(wù)2能讀到的最新數(shù)據(jù)記錄是事務(wù)4所提交的版本,而事務(wù)4提交的版本也是全局角度上最新的版本。
流程圖

2.5 RC/RR級別快照讀有什么不同
生成 Read View 的時(shí)機(jī)不同,從而造成 RC RR 級別下快照讀的結(jié)果的不同。
- 在RR級別下的某個(gè)事務(wù)對某條記錄進(jìn)行的第一次快照讀會(huì)創(chuàng)建一個(gè)快照 Read View,此后在調(diào)用快照讀的時(shí)候,使用的還是同一個(gè)ReadView,所以只要當(dāng)前事務(wù)在其他事務(wù)提交更新之前使用過快照讀,那么之后的快照讀使用的都是同一個(gè)Read View,所以對之后的修改不可見.
- 而在 RC 隔離級別下,事務(wù)中每次快照都會(huì)生成一個(gè)快照和 ReadView,這就是我們在 RC 級別下的事務(wù)中可以看到別的事務(wù)提交更新的原因。
總之在 RC 隔離級別下,每次快照讀都會(huì)生成最新的 ReadView;而在 RR 級別下,則是同一個(gè)事務(wù)中的第一個(gè)快照讀才會(huì)創(chuàng)建ReadView,之后的快照讀獲取的都是同一個(gè) ReadView。所以說 RR 在 RC 的基礎(chǔ)上通過生成 Read View 的時(shí)機(jī)不同從而解決了不可重復(fù)讀的問題
總結(jié)
本文講解了 MySQL 中的隱式字段、undo log 日志和 Read View 的原理以及 MVCC 的實(shí)現(xiàn)流程,對于我們在日常的開發(fā)過程中對于數(shù)據(jù)庫的并發(fā)操作以及 MySQL 的各種隔離級別有了清晰的認(rèn)識。