關於MySQL的InnoDB的MVCC原理,很多朋友都能說個大概:
每行記錄都含有兩個隱藏列,分別是記錄的創建時間與刪除時間
每次開啟事務都會產生一個全局自增ID
在RR隔離級別下
INSERT -> 記錄的創建時間 = 當前事務ID,刪除時間 = NULL
DELETE -> 記錄的創建時間不動,刪除時間 = 當前事務ID
UPDATE -> 將記錄復制一次
老記錄的創建時間不動,刪除時間 = 當前事務ID
新記錄的創建時間 = 當前事務ID,刪除時間 = NULL
SELECT -> 返回的記錄需要滿足兩個條件:
創建時間 <= 當前事務ID (記錄是在當前事務之前或者由當前事務創建的)
刪除時間 == NULL || 刪除時間 > 當前事務ID (記錄是在當前事務之后被刪除的)
但實際上,這個描述是很不嚴格的,問題有以下幾點:
1. 每條記錄含有的隱藏列不是兩個而是三個
它們分別是:
DB_TRX_ID, 6byte, 創建這條記錄/最后一次更新這條記錄的事務ID
DB_ROLL_PTR, 7byte,回滾指針,指向這條記錄的上一個版本(存儲於rollback segment里)
DB_ROW_ID, 6byte,隱含的自增ID,如果數據表沒有主鍵,InnoDB會自動以DB_ROW_ID產生一個聚簇索引
另外,每條記錄的頭信息(record header)里都有一個專門的bit(deleted_flag)來表示當前記錄是否已經被刪除
2. 記錄的歷史版本是放在專門的rollback segment里(undo log)
UPDATE非主鍵語句的效果是
老記錄被復制到rollback segment中形成undo log,DB_TRX_ID和DB_ROLL_PTR不動
新記錄的DB_TRX_ID = 當前事務ID,DB_ROLL_PTR指向老記錄形成的undo log
這樣就能通過DB_ROLL_PTR找到這條記錄的歷史版本。如果對同一行記錄執行連續的update操作,新記錄與undo log會組成一個鏈表,遍歷這個鏈表可以看到這條記錄的變遷)
3. MySQL的一致性讀,是通過一個叫做read view的結構來實現的
read_view中維護了系統中活躍事務集合的快照,這些活躍事務ID的最小值為up_limit_id,最大值為low_limit_id(不要搞反了!!!)
附上源碼注釋以便於理解
trx_id_t low_limit_id; // The read should not see any transaction with trx id >= this value. In other words, this is the "high water mark".
trx_id_t up_limit_id; // The read should see all trx ids which are strictly smaller (<) than this value. In other words, this is the "low water mark".
SELECT操作返回結果的可見性是由以下規則決定的:
DB_TRX_ID < up_limit_id -> 此記錄的最后一次修改在read_view創建之前,可見
DB_TRX_ID > low_limit_id -> 此記錄的最后一次修改在read_view創建之后,不可見 -> 需要用DB_ROLL_PTR查找undo log(此記錄的上一次修改),然后根據undo log的DB_TRX_ID再計算一次可見性
up_limit_id <= DB_TRX_ID <= low_limit_id -> 需要進一步檢查read_view中是否含有DB_TRX_ID
DB_TRX_ID ∉ read_view -> 此記錄的最后一次修改在read_view創建之前,可見
DB_TRX_ID ∈ read_view -> 此記錄的最后一次修改在read_view創建時尚未保存,不可見 -> 需要用DB_ROLL_PTR查找undo log(此記錄的上一次修改),然后根據undo log的DB_TRX_ID再從頭計算一次可見性
經過上述規則的決議,我們得到了這條記錄相對read_view來說,可見的結果。
此時,如果這條記錄的delete_flag為true,說明這條記錄已被刪除,不返回。
如果delete_flag為false,說明此記錄可以安全返回給客戶端
4. 用MVCC這一種手段可以同時實現RR與RC隔離級別
它們的不同之處在於:
RR:read view是在first touch read時創建的,也就是執行事務中的第一條SELECT語句的瞬間,后續所有的SELECT都是復用這個read view,所以能保證每次讀取的一致性(可重復讀的語義)
RC:每次讀取,都會創建一個新的read view。這樣就能讀取到其他事務已經COMMIT的內容。
所以對於InnoDB來說,RR雖然比RC隔離級別高,但是開銷反而相對少。
補充:RU的實現就簡單多了,不使用read view,也不需要管什么DB_TRX_ID和DB_ROLL_PTR,直接讀取最新的record即可。
5. 二級索引與MVCC
MySQL的索引分為聚簇索引(clustered index)與二級索引(secondary index)兩種。
剛才講的內容是基於聚簇索引的,只有聚簇索引中含有DB_TRX_ID與DB_ROLL_PTR隱藏列,可以比較容易的實現MVCC
但是二級索引中並不含有這幾個隱藏列,只含有1個bit的deleted flag,咋辦?
好辦,如果UPDATE語句涉及到二級索引的鍵值,將老的二級索引的deleted flag標記為true,然后創建一條新的二級索引記錄即可。
但是如果想根據二級索引來做查詢,這可就麻煩了。因為二級索引不維護版本信息,無法判斷二級索引中記錄的可見性。
所以還是需要回到聚簇索引中來:
根據二級索引維護的主鍵值去聚簇索引中查找記錄(使用MVCC規則)
如果查出來的結果跟二級索引里維護的結果相同 -> 返回,如果不同 -> 丟棄
如果對於一條查詢語句,二級索引中有很多條滿足條件的結果(連續多次更新,導致二級索引中有很多條記錄),那上面這個流程就比較低效了。所以InnoDB的作者搞了個機智的小優化:
在二級索引中,用一個額外的名為MAX_TRX_ID的變量來記錄最后一次更新二級索引的事務的ID
那么,如果當前語句關聯的read_view的 up_limit_id > MAX_TRX_ID,說明在創建read_view時最后一次更新二級索引的事務已經結束,也就是說二級索引里的所有記錄對於當前查詢都是可見的,此時可以直接根據二級索引的deleted flag來確定記錄是否應該被返回。
小結一下:二級索引的MVCC可見性判斷在MAX_TRX_ID失效的情況下需要依賴聚簇索引才能完成。
6. purge
從前面的分析可以看出,為了實現InnoDB的MVCC機制,更新或者刪除操作都只是設置一下老記錄的deleted_bit,並不真正將過時的記錄刪除。
為了節省磁盤空間,InnoDB有專門的purge線程來清理deleted_bit為true的記錄。
為了不影響MVCC的正常工作,purge線程自己也維護了一個read view(這個read view相當於系統中最老活躍事務的read view)
如果某個記錄的deleted_bit為true,並且DB_TRX_ID相對於purge線程的read view可見,那么這條記錄一定是可以被安全清除的。
參考文獻
InnoDB多版本(MVCC)實現簡要分析(水平很高,分析深入,必須要看,但可能不太好理解)