什么是MVCC
Multi-Version Concurrency Control 多版本并發控制,MVCC 是一種并發控制的方法,一般在數據庫管理系統中,實現對數據庫的并發訪問;在編程語言中實現事務內存。
MVCC有什么用
如何控制并發是數據庫領域中非常重要的問題之一,不過到今天為止事務并發的控制已經有了很多成熟的解決方案,而這些方案的原理就是這篇文章想要介紹的內容,文章中會介紹最為常見的三種并發控制機制:

分別是悲觀并發控制、樂觀并發控制和多版本并發控制,其中悲觀并發控制其實是最常見的并發控制機制,也就是鎖;而樂觀并發控制其實也有另一個名字:樂觀鎖,樂觀鎖其實并不是一種真實存在的鎖,我們會在文章后面的部分中具體介紹;最后就是多版本并發控制(MVCC)了,與前兩者對立的命名不同,MVCC 可以與前兩者中的任意一種機制結合使用,以提高數據庫的讀性能。
悲觀并發控制
控制不同的事務對同一份數據的獲取是保證數據庫的一致性的最根本方法,如果我們能夠讓事務在同一時間對同一資源有著獨占的能力,那么就可以保證操作同一資源的不同事務不會相互影響。

最簡單的、應用最廣的方法就是使用鎖來解決,當事務需要對資源進行操作時需要先獲得資源對應的鎖,保證其他事務不會訪問該資源后,在對資源進行各種操作;在悲觀并發控制中,數據庫程序對于數據被修改持悲觀的態度,在數據處理的過程中都會被鎖定,以此來解決競爭的問題。
讀寫鎖
為了最大化數據庫事務的并發能力,數據庫中的鎖被設計為兩種模式,分別是共享鎖和互斥鎖。當一個事務獲得共享鎖之后,它只可以進行讀操作,所以共享鎖也叫讀鎖;而當一個事務獲得一行數據的互斥鎖時,就可以對該行數據進行讀和寫操作,所以互斥鎖也叫寫鎖。

共享鎖和互斥鎖除了限制事務能夠執行的讀寫操作之外,它們之間還有『共享』和『互斥』的關系,也就是多個事務可以同時獲得某一行數據的共享鎖,但是互斥鎖與共享鎖和其他的互斥鎖并不兼容,我們可以很自然地理解這么設計的原因:多個事務同時寫入同一數據難免會發生各種詭異的問題。

如果當前事務沒有辦法獲取該行數據對應的鎖時就會陷入等待的狀態,直到其他事務將當前數據對應的鎖釋放才可以獲得鎖并執行相應的操作。
樂觀并發控制
除了悲觀并發控制機制 - 鎖之外,我們其實還有其他的并發控制機制,樂觀并發控制(Optimistic Concurrency Control)。樂觀并發控制也叫樂觀鎖,但是它并不是真正的鎖,很多人都會誤以為樂觀鎖是一種真正的鎖,然而它只是一種并發控制的思想。

在這一節中,我們將會先介紹基于時間戳的并發控制機制,然后在這個協議的基礎上進行擴展,實現樂觀的并發控制機制。
基于時間戳的協議
鎖協議按照不同事務對同一數據項請求的時間依次執行,因為后面執行的事務想要獲取的數據已將被前面的事務加鎖,只能等待鎖的釋放,所以基于鎖的協議執行事務的順序與獲得鎖的順序有關。在這里想要介紹的基于時間戳的協議能夠在事務執行之前先決定事務的執行順序。
每一個事務都會具有一個全局唯一的時間戳,它即可以使用系統的時鐘時間,也可以使用計數器,只要能夠保證所有的時間戳都是唯一并且是隨時間遞增的就可以。

基于時間戳的協議能夠保證事務并行執行的順序與事務按照時間戳串行執行的效果完全相同;每一個數據項都有兩個時間戳,讀時間戳和寫時間戳,分別代表了當前成功執行對應操作的事務的時間戳。
該協議能夠保證所有沖突的讀寫操作都能按照時間戳的大小串行執行,在執行對應的操作時不需要關注其他的事務只需要關心數據項對應時間戳的值就可以了:

無論是讀操作還是寫操作都會從左到右依次比較讀寫時間戳的值,如果小于當前值就會直接被拒絕然后回滾,數據庫系統會給回滾的事務添加一個新的時間戳并重新執行這個事務。
基于驗證的協議
樂觀并發控制其實本質上就是基于驗證的協議,因為在多數的應用中只讀的事務占了絕大多數,事務之間因為寫操作造成沖突的可能非常小,也就是說大多數的事務在不需要并發控制機制也能運行的非常好,也可以保證數據庫的一致性;而并發控制機制其實向整個數據庫系統添加了很多的開銷,我們其實可以通過別的策略降低這部分開銷。
而驗證協議就是我們找到的解決辦法,它根據事務的只讀或者更新將所有事務的執行分為兩到三個階段:

在讀階段,數據庫會執行事務中的全部讀操作和寫操作,并將所有寫后的值存入臨時變量中,并不會真正更新數據庫中的內容;在這時候會進入下一個階段,數據庫程序會檢查當前的改動是否合法,也就是是否有其他事務在 RAED PHASE 期間更新了數據,如果通過測試那么直接就進入 WRITE PHASE 將所有存在臨時變量中的改動全部寫入數據庫,沒有通過測試的事務會直接被終止。
為了保證樂觀并發控制能夠正常運行,我們需要知道一個事務不同階段的發生時間,包括事務開始時間、驗證階段的開始時間以及寫階段的結束時間;通過這三個時間戳,我們可以保證任意沖突的事務不會同時寫入數據庫,一旦由一個事務完成了驗證階段就會立即寫入,其他讀取了相同數據的事務就會回滾重新執行。
作為樂觀的并發控制機制,它會假定所有的事務在最終都會通過驗證階段并且執行成功,而鎖機制和基于時間戳排序的協議是悲觀的,因為它們會在發生沖突時強制事務進行等待或者回滾,哪怕有不需要鎖也能夠保證事務之間不會沖突的可能。
多版本并發控制
到目前為止我們介紹的并發控制機制其實都是通過延遲或者終止相應的事務來解決事務之間的競爭條件(Race condition)來保證事務的可串行化;雖然前面的兩種并發控制機制確實能夠從根本上解決并發事務的可串行化的問題,但是在實際環境中數據庫的事務大都是只讀的,讀請求是寫請求的很多倍,如果寫請求和讀請求之前沒有并發控制機制,那么最壞的情況也是讀請求讀到了已經寫入的數據,這對很多應用完全是可以接受的。

在這種大前提下,數據庫系統引入了另一種并發控制機制 - 多版本并發控制(Multiversion Concurrency Control),每一個寫操作都會創建一個新版本的數據,讀操作會從有限多個版本的數據中挑選一個最合適的結果直接返回;在這時,讀寫操作之間的沖突就不再需要被關注,而管理和快速挑選數據的版本就成了 MVCC 需要解決的主要問題。
MVCC 并不是一個與樂觀和悲觀并發控制對立的東西,它能夠與兩者很好的結合以增加事務的并發量,在目前最流行的 SQL 數據庫 MySQL 和 PostgreSQL 中都對 MVCC 進行了實現;但是由于它們分別實現了悲觀鎖和樂觀鎖,所以 MVCC 實現的方式也不同。
MySQL 與 MVCC
MySQL 中實現的多版本兩階段鎖協議(Multiversion 2PL)將 MVCC 和 2PL 的優點結合了起來,每一個版本的數據行都具有一個唯一的時間戳,當有讀事務請求時,數據庫程序會直接從多個版本的數據項中具有最大時間戳的返回。

更新操作就稍微有些復雜了,事務會先讀取最新版本的數據計算出數據更新后的結果,然后創建一個新版本的數據,新數據的時間戳是目前數據行的最大版本 +1:

數據版本的刪除也是根據時間戳來選擇的,MySQL 會將版本最低的數據定時從數據庫中清除以保證不會出現大量的遺留內容。
PostgreSQL 與 MVCC
與 MySQL 中使用悲觀并發控制不同,PostgreSQL 中都是使用樂觀并發控制的,這也就導致了 MVCC 在于樂觀鎖結合時的實現上有一些不同,最終實現的叫做多版本時間戳排序協議(Multiversion Timestamp Ordering),在這個協議中,所有的的事務在執行之前都會被分配一個唯一的時間戳,每一個數據項都有讀寫兩個時間戳:

當 PostgreSQL 的事務發出了一個讀請求,數據庫直接將最新版本的數據返回,不會被任何操作阻塞,而寫操作在執行時,事務的時間戳一定要大或者等于數據行的讀時間戳,否則就會被回滾。
這種 MVCC 的實現保證了讀事務永遠都不會失敗并且不需要等待鎖的釋放,對于讀請求遠遠多于寫請求的應用程序,樂觀鎖加 MVCC 對數據庫的性能有著非常大的提升;雖然這種協議能夠針對一些實際情況做出一些明顯的性能提升,但是也會導致兩個問題,一個是每一次讀操作都會更新讀時間戳造成兩次的磁盤寫入,第二是事務之間的沖突是通過回滾解決的,所以如果沖突的可能性非常高或者回滾代價巨大,數據庫的讀寫性能還不如使用傳統的鎖等待方式。
一個例子
MVCC是通過在每行記錄后面保存兩個隱藏的列來實現的。這兩個列,一個保存了行的創建時間,一個保存行的過期時間(或刪除時間)。當然存儲的并不是實際的時間值,而是系統版本號(system version number)。每開始一個新的事務,系統版本號都會自動遞增。事務開始時刻的系統版本號會作為事務的版本號,用來和查詢到的每行記錄的版本號進行比較。
下面看一下在REPEATABLE READ隔離級別下,MVCC具體是如何操作的。
- SELECT
- InnoDB會根據以下兩個條件檢查每行記錄:
- InnoDB只查找版本早于當前事務版本的數據行(也就是,行的系統版本號小于或等于事務的系統版本號),這樣可以確保事務讀取的行,要么是在事務開始前已經存在的,要么是事務自身插入或者修改過的。
- 行的刪除版本要么未定義,要么大于當前事務版本號。這可以確保事務讀取到的行,在事務開始之前未被刪除。
- 只有符合上述兩個條件的記錄,才能返回作為查詢結果
- INSERT
- InnoDB為新插入的每一行保存當前系統版本號作為行版本號。
- DELETE
- InnoDB為刪除的每一行保存當前系統版本號作為行刪除標識。
- UPDATE
- InnoDB為插入一行新記錄,保存當前系統版本號作為行版本號,同時保存當前系統版本號到原來的行作為行刪除標識。
- 保存這兩個額外系統版本號,使大多數讀操作都可以不用加鎖。這樣設計使得讀數據操作很簡單,性能很好,并且也能保證只會讀取到符合標準的行,不足之處是每行記錄都需要額外的存儲空間,需要做更多的行檢查工作,以及一些額外的維護工作
舉例說明
create table mvcctest(
id int primary key auto_increment,
name varchar(20));
transaction 1:
start transaction; insert into mvcctest values(NULL,'mi'); insert into mvcctest values(NULL,'kong'); commit;
假設系統初始事務ID為1;
IDNAME創建時間過期時間1mi1undefined2kong1undefined
transaction 2:
start transaction; select * from mvcctest; //(1) select * from mvcctest; //(2) commit
SELECT
假設當執行事務2的過程中,準備執行語句(2)時,開始執行事務3:
transaction 3:
start transaction;
insert into mvcctest values(NULL,'qu');
commit;
IDNAME創建時間過期時間1mi1undefined2kong1undefined3qu3undefined
事務3執行完畢,開始執行事務2 語句2,由于事務2只能查詢創建時間小于等于2的,所以事務3新增的記錄在事務2中是查不出來的,這就通過樂觀鎖的方式避免了幻讀的產生
UPDATE
假設當執行事務2的過程中,準備執行語句(2)時,開始執行事務4:
transaction session 4:
start transaction; update mvcctest set name = 'fan' where id = 2; commit;
InnoDB執行UPDATE,實際上是新插入了一行記錄,并保存其創建時間為當前事務的ID,同時保存當前事務ID到要UPDATE的行的刪除時間
IDNAME創建時間過期時間1mi1undefined2kong142fan4undefined
事務4執行完畢,開始執行事務2 語句2,由于事務2只能查詢創建時間小于等于2的,所以事務修改的記錄在事務2中是查不出來的,這樣就保證了事務在兩次讀取時讀取到的數據的狀態是一致的
DELETE
假設當執行事務2的過程中,準備執行語句(2)時,開始執行事務5:
transaction session 5:
start transaction; delete from mvcctest where id = 2; commit;
IDNAME創建時間過期時間1mi1undefined2kong15
事務5執行完畢,開始執行事務2 語句2,由于事務2只能查詢創建時間小于等于2、并且過期時間大于等于2,所以id=2的記錄在事務2 語句2中,也是可以查出來的,這樣就保證了事務在兩次讀取時讀取到的數據的狀態是一致的