mysql事務隔離級別及MVCC 原理

一、事務的隔離級別

爲了保證事務與事務之間的修改操作不會互相影響,innodb希望不同的事務是隔離的執行的,互不干擾。

兩個併發的事務在執行過程中有 讀讀、讀寫(一個事務在讀某條數據的同時另一個事務在寫這條數據)、寫讀 和 寫寫 這4種情況。

讀讀(相同的數據)的併發並不會帶來一致性問題,而後面三種情況的併發則可能帶來一致性問題。

隔離的本質就是讓多個事務對相同數據的訪問在 讀寫、寫讀和寫寫的情況下,對其排隊串行執行,比如事務A修改了行1但沒提交,事務B在修改行1時就會被阻塞,這通常是通過加鎖實現的。

當然,在實際的數據庫應用中,讀寫和寫讀不一定非要串行執行,而是可以通過MVCC來實現不同事務對同一條記錄的讀和寫是併發進行的。

需要注意:這裏的排隊串行是指寫相同數據的時候才需要,如果是對不同數據行的寫,是可以併發執行的。

下面我們討論,事務併發執行會遇到哪些一致性問題。

 

併發執行的一致性問題

髒寫:如果一個事務成功的修改了另一個未提交事務所修改過的數據,就是髒寫。

髒讀:如果一個事務成功的讀到了另一個未提交事務所修改過的數據,就是髒讀。

不可重複讀:如果一個事務A修改了另一個事務B讀取的數據(B讀先發生,A寫後發生),事務B第二次再讀這個數據的時候發現這個數據變了,就發生了不可重複讀。

幻讀:如果事務A根據某條件讀到了一些記錄(此時事務A未提交),事務B修改了一些符合這個條件的記錄(insert、update或delete),下次A再讀這些記錄發現結果和上次不同,就發生了幻讀。

幻讀和不可重複讀的相同點都是兩次讀到的數據不同,區別是幻讀強調兩次讀取(我們稱爲前讀和後讀)的結果集合的行數不同,不可重複讀強調讀取同一條記錄的內容不同。

在mysql中,幻讀強調的是事務的後讀,讀到了前讀所沒有讀到的行,這些多出來的行可能是其他事務insert或者update產生的,多出來的這些記錄稱爲幻影記錄;不可重複讀強調無法讀到前讀讀到的行,這些消失掉的行可能是由其他事務delete或update造成的,使得後讀無法復現這些行。

此外對於當前讀而言,避免不可重複讀和幻讀的方式不同,解決不可重複讀使用記錄鎖鎖住指定行即可,解決幻讀要用間隙鎖和臨鍵鎖鎖住行與間隙。(對於快照讀,避免不可重複讀和幻讀都是使用MVCC)。如果對這兩段話的一些名詞看不懂的同學,可以先忽略,後面的章節還會再介紹這些鎖機制。

一致性問題的嚴重性:髒寫>髒讀>不可重複讀>幻讀。

爲此SQL指定了幾種隔離級別

 

事務的四個隔離級別,級別從低到高爲

讀未提交【read uncommitted】(會出現髒讀、不可重複讀和幻讀的問題)

讀已提交【read committed】(會出現不可重複讀和幻讀)

可重複讀【repeatable read】(會出現幻讀)

串行化【serializable】

 

隔離級別越高,安全性越高,但是性能越低。Mysql事務的默認級別是可重複讀,而oracle的事務是讀已提交的級別。由於讀未提交的數據安全得不到保證,而串行化這個級別下併發度低,所以大多數數據庫的隔離級別都是讀已提交或可重複讀這兩種。

串行化的隔離級別(serializable)不代表事務和事務之間是串行的(如果是的話就變成表鎖了),而是指事務和事務之間如果涉及到對同一行數據的寫和讀需要串行。但串行化級別的兩個事務對不同行的寫讀和寫寫還是可以併發的。

 

例如在串行化的隔離級別下,如果事務A對行1進行了update但不提交,事務B對行1進行select會阻塞,只有當A提交了事務,B才能查詢成功,而且查到的是A的已提交數據;反過來A先對行1select但不提交,B事務再對行1進行update也會被阻塞。

serializable的寫讀和讀寫之所以會串行,是因爲serialzable在事務A讀行1的時候會對行1上讀鎖(而且這個讀鎖在commit或者rollback時纔會釋放),事務A提交之前,事務B對行1進行select會由於鎖沒有釋放而阻塞。

而 可重複讀和讀已提交 在讀的時候無需加任何鎖,而是通過MVCC實現讀和寫併發。

 

這也是 serializable 和 可重複讀/讀已提交 的區別之一:前者的讀寫/寫讀是串行,後者的讀寫/寫讀是併發的。

當然,無論哪種隔離級別,寫都是要加寫鎖的,因此任何隔離級別的寫寫都是串行的,無法併發,但也是因爲這樣才避免了髒寫的發生。

 

 

二、MVCC原理

MVCC(多版本併發控制 ) 設計出來的目的是爲了在不加鎖的情況下,解決髒讀和不可重複讀的問題,從而一定程度提高 讀寫 和 寫讀 的併發。

MVCC的實現依賴於記錄undo日誌過程中,針對聚簇索引的行構建出來的版本鏈 和 事務查詢時創建的 ReadView(一致性視圖)

 

版本鏈

從上一節undo日誌的知識中我們知道:一次事務中,每次對某個聚簇索引的行進行改動的時候,行的舊版本會被寫入到undo日誌,本次事務的事務被寫入到當前行的trx_id隱藏列,undo日誌的地址會被寫入到行的roll_pointer隱藏列中。

這樣一來,多個事務對一個記錄的多次修改所產生的undo日誌就會形成一個版本鏈,如圖所示:

 

版本鏈的頭結點就是B+樹頁面的記錄。

對於串行化隔離級別的事務,innodb採用加鎖的方式來讀和寫(串行),因此根本不會出現髒讀和不可重複讀,無需用到版本鏈。

對於讀未提交 read uncommit 隔離級別,它允許出現髒讀和不可重複讀,所以可以當事務A寫的同時,事務B可以直接讀取版本鏈的頭結點的數據,也就是最新版本的行,從而實現A和B併發讀寫。

對於 讀已提交 隔離級別,需要保證讀的是已提交的數據。

對於 可重複讀 隔離級別,需要保證如果事務A在事務B提交前開始的,那麼事務A讀的是在A內的行1數據,即使B對行1的已提交,A也不能讀到B的已提交數據,這樣才能實現可重複讀。

很明顯,如果 讀已提交 和 可重複讀 隔離級別要完成自己的隔離目標 又要要求 讀寫併發,光靠版本鏈還是不夠的,還需要藉助一致性視圖。

 

 

一致性視圖 ReadView

一致性視圖(有些書叫做“快照”,所以從一致性視圖讀取數據又叫快照讀) ReadView 是在事務進行過程中產生的,可以和版本鏈結合共同實現MVCC。每一個事務在初次嘗試做讀取操作時,會生成一個屬於該事務自己的ReadView,ReadView是一個由下面4個重要內容組成的信息集合:

m_ids:在生成本 ReadView 時,當前系統未提交的讀寫事務的事務id列表。

min_trx_id:在生成本 ReadView 時,當前系統未提交的讀寫事務中的最小事務id,即 m_ids的最小值。

max_trx_id:在生成本 ReadView 時,下一個未來事務的事務id。

creator_trx_id:生成本ReadView的事務id。

 

需要提示一點:任何事務開啓後,執行DML語句(增刪改語句)前,該事務的id都是0,只有事務執行了第一條DML語句,纔會被分配事務id。

假如當前系統有 1、2、3 這3個事務,並且事務3已經提交了,事務1和2還沒提交。此時開啓了新事務4,事務4的事務id是0,事務4讀取記錄時生成的ReadView,m_ids就是1和2,min_trx_id是1,max_trx_id是4。

 

ReadView 如何配合 版本鏈 完成MVCC

根據 ReadView 判斷 版本鏈的某個版本對本事務是否可見是實現MVCC的關鍵。

現在我們只關注一行數據,從這行數據的版本鏈的頭結點(最新版本)開始作爲當前版本。

1、如果 當前版本的 trx_id(不是當前事務的 trx_id,別搞混了) == ReadView.creator_trx_id,說明當前事務在訪問自己修改過的記錄,該版本可以被訪問。

2、如果 當前版本的 trx_id >= ReadView.max_trx_id,說明當前事務訪問的當前版本是在當前開啓之後,又有新事務開啓並修改了該行而產生的版本,因此該版本對當前事務不可見,不可訪問。

3、如果 當前版本的 trx_id < ReadView.min_trx_id,說明當前事務訪問的當前版本是以前已提交的事務更改所生成的版本,該版本可以被訪問。

4、如果 當前版本的 trx_id 在 [min_trx_id, max_trx_id]之間,則需要判斷 當前版本的trx_id是否命中 m_ids列表的 trx_id,如果命中,說明當前版本是未提交事務對行1更改而產生的版本,該版本不可訪問;否則,可以訪問。

 

如果某個版本對當前事務不可見(不可訪問),則順着版本鏈找到下一個更早的版本,並繼續執行上面的流程,直到找到可見的版本 或者 到達版本鏈最早的一個版本都沒有找到可見的版本才結束。如果是最後一個版本都不可見,說明查詢結果不包含該記錄。

讀已提交 和 可重複讀 隔離級別之間非常大的區別就是它們生成 ReadView 的時機不同 。讀已提交會在每次讀取數據前都生成一個ReadView,可重複讀在第一次讀取數據時生成一個ReadView。

 

栗子:假設現在表 hero 中只有一條由事務id 爲 80 的事務插入的記錄:

 

現在系統中有2個事務id爲100和200的事務正在執行。

 

 

此時有個使用 READ COMMITED 隔離級別的新事務trx_id=300開始執行一條select:

# 使用read commited隔離級別的事務
begin;

#select1:transaction 100,200未提交
select * from hero where number=1;

 

在 select 語句執行前,系統就會生成一個 ReadView,m_ids是[100,200],max_trx_id=201,creator_trx_id = 0。

根據上面的規則,該事務只能讀到 劉備 這條記錄。

 

之後把 事務id = 100 的事務提交,再在 事務id = 200 的事務中修改行1:

# transaction 200
begin;
update hero set name=’趙雲’ where number = 1;
update hero set name=’諸葛亮’ where number = 1; 

 

事務300又執行一條select 行1的操作,事務300就會再生成一個 ReadView覆蓋之前的ReadView,由於它的 m_ids 爲 [200]。所以按照規則,會查詢到 張飛 這條數據。

 

我們模擬上面一模一樣的場景,但換成可重複讀的隔離級別,那麼可重複讀的事務300只會在第一次select 時生成一個 ReadView,m_ids 是 [100, 200],min_trx_id=100, creator_trx_id = 0。

之後不會再生成新的ReadView,因此第一次select 查到劉備,事務100提交後,事務300第二次select 查到的還是劉備,因爲 m_ids 還是[100, 200]。

所以能做到 重複讀 就是因爲可重複讀隔離級別的一個事務只生成一次 ReadView。

 

 

二級索引和MVCC

如果某個查詢語句的查詢字段只有二級索引,那麼系統只會讀取二級索引的頁的記錄,不會回表去讀取聚簇索引的頁記錄。但是,版本鏈的頭結點在聚簇索引中,不在二級索引中,通過二級索引的記錄無法直接找到版本鏈。在這種情況下如何使用MVCC?

二級索引頁的頭部有一個 page_max_trx_id 表示修改過該頁的最大事務id。

執行select時命中該頁,如果 ReadView 的 min_trx_id 比該頁的 page_max_trx_id 大,說明這個二級索引頁修改的事務已經提交,該頁的所有記錄對本事務的本次查詢可見。

否則,就要對“在二級索引頁找到的匹配條件的記錄”進行回表操作,在聚簇索引對應的記錄中按照之前所說的規則找到可見版本。

 

考慮一種情況,如果原本有3條滿足 where key1 = "a" 的二級索引記錄(id分別是 1、2、3),事務1將id=1的"a"改成了"b",將一條id=4的 "c" 改成了"a",並且事務1沒有提交。此時新事務查詢 where key1 = "a" 應該查詢到 id爲 1、2、3這三條記錄。問題在於id爲1的行已經把索引 "a"改成"b"了,innodb怎麼能根據 key1="a" 這個條件拿到 id=1 進行回表呢?

其實前面介紹undo日誌時說過這種情況,如果update語句修改的是主鍵或者索引字段,會先在頁中假刪除對應的主鍵值或索引值的記錄(記錄的deleted_flag字段置爲1,但不會放到垃圾鏈表中),再按照新值在對應頁中新增一條記錄。

由於是假刪除,所以新事務其實仍然可以在頁中找到 被未提交事務刪除或更改的那條"a"記錄。之所以假刪除就是考慮到MVCC。

 

 

Purge

insert undo日誌在事務提交後可以釋放掉,而update undo日誌需要爲MVCC服務,因此不能立刻刪除掉。

在介紹undo日誌那一節的時候我們知道一個事務會產生至少1組undo日誌(insert undo一組,update undo一組),1組undo日誌對應一條undo頁鏈表。

當事務提交後,update undo鏈表的頭結點會被添加到一個History鏈表的頭部,通過history鏈表就能找到提交事務後還未釋放的undo日誌鏈表,正式因爲undo日誌鏈表沒有被釋放,因此版本鏈中的undo日誌行纔會繼續存在。

history鏈表(的基節點)存放在回滾段中,一個回滾段存放一個history鏈表。

 

問題來了:update undo日誌是否會永遠存在,一直不釋放?如果釋放,那麼釋放的時機是什麼?答案肯定是要釋放的,不然不斷增長的undo日誌會佔用很多磁盤空間。

我們假設系統目前只有事務id爲 1、2、 3、 4、 5這五個事務,隔離級別爲 repeatable read。trx1修改了行1並提交,2~5在1提交之後依次同時開啓,2修改了行1並提交後,3 修改了行1不提交,5修改行1不提交,4查詢行1。

此時 行1 的版本鏈應該是 trx5(最新版本,放在數據頁)->trx3(undo日誌)->trx2(undo日誌)->trx1(undo日誌)。

由於trx2 和 trx1都已經提交,trx4生成的ReadView的m_ids列表是 [3, 5],max_trx_id = 6。

明顯,trx1對應的undo日誌可以刪除回收,因爲trx4生成的ReadView可見的最晚的版本是 trx2的版本。

 

所以結論是:

系統中仍處於活躍狀態的最早的那個ReadView不再訪問的那些update undo日誌可以回收。

什麼是活躍狀態的ReadView?

例如,對於 read committed 級別,事務1執行了第一次查詢,會生成一個ReadView1,只要該事務不執行第二次查詢,那麼ReadView1就是活躍的ReadView;如果執行第二次查詢,就會生成ReadView2覆蓋ReadView1。此時ReadView1就不是活躍的ReadView,ReadView2纔是。

對於 repeatable read  級別,事務1執行了第一次查詢,會生成一個ReadView1,執行第二次查詢不會生成新的ReadView,因此ReadView1就是活躍的ReadView。

 

回到正題,一個事務提交時,會爲其生成一個名爲事務no的值來表示事務提交的順序(事務id則是事務開啓的順序),事務no會記錄到undo鏈表的頭結點。已提交的undo日誌組的鏈表頭結點就是按照事務提交的順序放入到history鏈表的。Readview也會包含一個事務no屬性,在創建的ReadView時候會保存當前系統最大的事務no+1給這個屬性。

系統中所有活躍的ReadView會按照創建時間連成一個鏈表。

purge線程做的事情就是遍歷所有history鏈表的所有undo鏈表頭結點的事務no 與 活躍的最早的ReadView的事務no對比,如果一組undo日誌的事務no小於當前系統最早的活躍ReadView的事務no,就可以將這組undo日誌從History鏈表移除並釋放這些undo頁的佔用空間。並將這些undo日誌對應的deleted_flag爲1但仍在頁內正常記錄鏈表的記錄移到垃圾鏈表中。

 

注意:如果某個事務是 repeatable read  級別,該事務會一直複用最初產生的ReadView,如果這個事務運行很久都沒有commit,則該ReadView會一直處於活躍狀態,系統中很早的update undo日誌和打了刪除標籤的記錄會越來越多不會被釋放,導致表空間越來越大,版本鏈越來越長,影響性能。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章