MySQL探祕(六):InnoDB一致性非鎖定讀
一致性非鎖定讀(consistent nonlocking read)是指InnoDB儲存引擎通過多版本控制(MVVC)讀取當前資料庫中行資料的方式。如果讀取的行正在執行DELETE或UPDATE操作,這時讀取操作不會因此去等待行上鎖的釋放。相反地,InnoDB會去讀取行的一個快照。
上圖直觀地展現了InnoDB一致性非鎖定讀的機制。之所以稱其為非鎖定讀,是因為不需要等待行上排他鎖的釋放。快照資料是指該行的之前版本的資料,每行記錄可能有多個版本,一般稱這種技術為行多版本技術。由此帶來的併發控制,稱之為多版本併發控制(Multi Version Concurrency Control, MVVC)。InnoDB是通過undo log來實現MVVC。undo log本身用來在事務中回滾資料,因此快照資料本身是沒有額外開銷。此外,讀取快照資料是不需要上鎖的,因為沒有事務需要對歷史的資料進行修改操作。
一致性非鎖定讀是InnoDB預設的讀取方式,即讀取不會佔用和等待行上的鎖。但是並不是在每個事務隔離級別下都是採用此種方式。此外,即使都是使用一致性非鎖定讀,但是對於快照資料的定義也各不相同。
在事務隔離級別READ COMMITTED和REPEATABLE READ下,InnoDB使用一致性非鎖定讀。然而,對於快照資料的定義卻不同。在READ COMMITTED事務隔離級別下,一致性非鎖定讀總是讀取被鎖定行的最新一份快照資料。而在REPEATABLE READ事務隔離級別下,則讀取事務開始時的行資料版本。
我們下面舉個例子來詳細說明一下上述的情況。
# session A
mysql> BEGIN;
mysql> SELECT * FROM test WHERE id = 1;
複製程式碼
我們首先在會話A中顯示地開啟一個事務,然後讀取test表中的id為1的資料,但是事務並沒有結束。於此同時,使用者在開啟另一個會話B,這樣可以模擬併發的操作,然後對會話B做出如下的操作:
# session B
mysql> BEGIN;
mysql> UPDATE test SET id = 3 WHERE id = 1;
複製程式碼
在會話B的事務中,將test表中id為1的記錄修改為id=3,但是事務同樣也沒有提交,這樣id=1的行其實加了一個排他鎖。由於InnoDB在READ COMMITTED和REPEATABLE READ事務隔離級別下使用一致性非鎖定讀,這時如果會話A再次讀取id為1的記錄,仍然能夠讀取到相同的資料。此時,READ COMMITTED和REPEATABLE READ事務隔離級別沒有任何區別。
如上圖所示,當會話B提交事務後,會話A再次執行SELECT * FROM test WHERE id = 1
的SQL語句時,兩個事務隔離級別下得到的結果就不一樣了。
對於READ COMMITTED的事務隔離級別,它總是讀取行的最新版本,如果行被鎖定了,則讀取該行版本的最新一個快照。因為會話B的事務已經提交,所以在該隔離級別下上述SQL語句的結果集是空的。
對於REPEATABLEREAD的事務隔離級別,總是讀取事務開始時的行資料,因此,在該隔離級別下,上述SQL語句仍然會獲得相同的資料。
MVVC
我們首先來看一下wiki上對MVVC的定義:
Multiversion concurrency control (MCC or MVCC), is a concurrency control method commonly used by database management systems to provide concurrent access to the database and in programming languages to implement transactional memory.
由定義可知,MVVC是用於資料庫提供併發訪問控制的併發控制技術。 資料庫的併發控制機制有很多,最為常見的就是鎖機制。鎖機制一般會給競爭資源加鎖,阻塞讀或者寫操作來解決事務之間的競爭條件,最終保證事務的可序列化。而MVVC則引入了另外一種併發控制,它讓讀寫操作互不阻塞,每一個寫操作都會建立一個新版本的資料,讀操作會從有限多個版本的資料中挑選一個最合適的結果直接返回,由此解決了事務的競爭條件。
考慮一個現實場景。管理者要查詢所有使用者的存款總額,假設除了使用者A和使用者B之外,其他使用者的存款總額都為0,A、B使用者各有存款1000,所以所有使用者的存款總額為2000。但是在查詢過程中,使用者A會向用戶B進行轉賬操作。轉賬操作和查詢總額操作的時序圖如下圖所示。
如果沒有任何的併發控制機制,查詢總額事務先讀取了使用者A的賬戶存款,然後轉賬事務改變了使用者A和使用者B的賬戶存款,最後查詢總額事務繼續讀取了轉賬後的使用者B的賬號存款,導致最終統計的存款總額多了100元,發生錯誤。
使用鎖機制可以解決上述的問題。查詢總額事務會對讀取的行加鎖,等到操作結束後再釋放所有行上的鎖。因為使用者A的存款被鎖,導致轉賬操作被阻塞,直到查詢總額事務提交併將所有鎖都釋放。
但是這時可能會引入新的問題,當轉賬操作是從使用者B向用戶A進行轉賬時會導致死鎖。轉賬事務會先鎖住使用者B的資料,等待使用者A資料上的鎖,但是查詢總額的事務卻先鎖住了使用者A資料,等待使用者B的資料上的鎖。使用MVVC機制也可以解決這個問題。查詢總額事務先讀取了使用者A的賬戶存款,然後轉賬事務會修改使用者A和使用者B賬戶存款,查詢總額事務讀取使用者B存款時不會讀取轉賬事務修改後的資料,而是讀取本事務開始時的資料副本(在REPEATABLE READ隔離等級下)。
MVCC使得資料庫讀不會對資料加鎖,普通的SELECT請求不會加鎖,提高了資料庫的併發處理能力。藉助MVCC,資料庫可以實現READ COMMITTED,REPEATABLE READ等隔離級別,使用者可以檢視當前資料的前一個或者前幾個歷史版本,保證了ACID中的I特性(隔離性)
InnoDB的MVVC實現
多版本併發控制僅僅是一種技術概念,並沒有統一的實現標準, 其的核心理念就是資料快照,不同的事務訪問不同版本的資料快照,從而實現不同的事務隔離級別。雖然字面上是說具有多個版本的資料快照,但這並不意味著資料庫必須拷貝資料,儲存多份資料檔案,這樣會浪費大量的儲存空間。InnoDB通過事務的undo日誌巧妙地實現了多版本的資料快照。
資料庫的事務有時需要進行回滾操作,這時就需要對之前的操作進行undo。因此,在對資料進行修改時,InnoDB會產生undo log。當事務需要進行回滾時,InnoDB可以利用這些undo log將資料回滾到修改之前的樣子。
根據行為的不同 undo log 分為兩種 insert undo log和update undo log。
insert undo log 是在 insert 操作中產生的 undo log。因為 insert 操作的記錄只對事務本身可見,對於其它事務此記錄是不可見的,所以 insert undo log 可以在事務提交後直接刪除而不需要進行 purge 操作。
update undo log 是 update 或 delete 操作中產生的 undo log,因為會對已經存在的記錄產生影響,為了提供 MVCC機制,因此 update undo log 不能在事務提交時就進行刪除,而是將事務提交時放到入 history list 上,等待 purge 執行緒進行最後的刪除操作。
為了保證事務併發操作時,在寫各自的undo log時不產生衝突,InnoDB採用回滾段的方式來維護undo log的併發寫入和持久化。回滾段實際上是一種 Undo 檔案組織方式。
InnoDB行記錄有三個隱藏欄位:分別對應該行的rowid、事務號db_trx_id和回滾指標db_roll_ptr,其中db_trx_id表示最近修改的事務的id,db_roll_ptr指向回滾段中的undo log。如下圖所示。
當事務2使用UPDATE語句修改該行資料時,會首先使用排他鎖鎖定改行,將該行當前的值複製到undo log中,然後再真正地修改當前行的值,最後填寫事務ID,使用回滾指標指向undo log中修改前的行。如下圖所示。
當事務3進行修改與事務2的處理過程類似,如下圖所示。
REPEATABLE READ隔離級別下事務開始後使用MVVC機制進行讀取時,會將當時活動的事務id記錄下來,記錄到Read View中。READ COMMITTED隔離級別下則是每次讀取時都建立一個新的Read View。
Read View是InnoDB中用於判斷記錄可見性的資料結構,記錄了一些用於判斷可見性的屬性。
- low_limit_id:某行記錄的db_trx_id < 該值,則該行對於當前Read View是一定可見的
- up_limit_id:某行記錄的db_trx_id >= 該值,則該行對於當前read view是一定不可見的
- low_limit_no:用於purge操作的判斷
- rw_trx_ids:讀寫事務陣列
Read View建立後,事務再次進行讀操作時比較記錄的db_trx_id和Read View中的low_limit_id,up_limit_id和讀寫事務陣列來判斷可見性。
如果該行中的db_trx_id等於當前事務id,說明是事務內部發生的更改,直接返回該行資料。否則的話,如果db_trx_id小於up_limit_id,說明是事務開始前的修改,則該記錄對當前Read View是可見的,直接返回該行資料。
如果db_trx_id大於或者等於low_limit_id,則該記錄對於該Read View一定是不可見的。如果db_trx_id位於[up_limit_id, low_limit_id)範圍內,需要在活躍讀寫事務陣列(rw_trx_ids)中查詢db_trx_id是否存在,如果存在,記錄對於當前Read View是不可見的。
如果記錄對於Read View不可見,需要通過記錄的DB_ROLL_PTR指標遍歷undo log,構造對當前Read View可見版本資料。
簡單來說,Read View記錄讀開始時及其之後,所有的活動事務,這些事務所做的修改對於Read View是不可見的。除此之外,所有其他的小於建立Read View的事務號的所有記錄均可見。
後記
我們後續還會學習InnoDB的鎖的相關的知識,請大家持續關注。
- Mysql探索(一):B-Tree索引
- 資料庫內部儲存結構探索
- MySQL探祕(二):SQL語句執行過程詳解
- MySQL探祕(三):InnoDB的記憶體結構和特性
- MySQL探祕(四):InnoDB的磁碟檔案及落盤機制
- MySQL探祕(五):InnoDB鎖的型別和狀態查詢
參考文章
- mysql.taobao.org/monthly/201…
- liuzhengyang.github.io/2017/04/18/…
- hedengcheng.com/?p=148
- 《唐成-2016PG大會-資料庫多版本實現內幕.pdf》