MVCC能否解決幻讀?

 

一、什麼是MVCC

多版本控制: 指的是一種提高併發的技術。最早的數據庫系統,只有讀讀之間可以併發,讀寫,寫讀,寫寫都要阻塞。引入多版本之後,只有寫寫之間相互阻塞,其他三種操作都可以並行,這樣大幅度提高了InnoDB的併發度。

在內部實現中,InnoDB通過undo log保存每條數據的多個版本,並且能夠找回數據歷史版本提供給用戶讀,每個事務讀到的數據版本可能是不一樣的。在同一個事務中,用戶只能看到該事務創建快照之前已經提交的修改和該事務本身做的修改。

MVCC只在已提交讀(Read Committed)和可重複讀(Repeatable Read)兩個隔離級別下工作,其他兩個隔離級別和MVCC是不兼容的。因爲未提交讀,總數讀取最新的數據行,而不是讀取符合當前事務版本的數據行。而串行化(Serializable)則會對讀的所有數據多加鎖。

MVCC的實現原理主要是依賴每一行記錄中兩個隱藏字段,undo log,ReadView

 

二、MVCC相關的一些概念

這裏我們先來理解下有關MVCC相關的一些概念,這些概念都理解後,我們會通過實際例子來演示MVCC的具體工作流程是怎麼樣的。

1、事務版本號

事務每次開啓時,都會從數據庫獲得一個自增長的事務ID,可以從事務ID判斷事務的執行先後順序。這就是事務版本號。

也就是每當begin的時候,首選要做的就是從數據庫獲得一個自增長的事務ID,它也就是當前事務的事務ID。

2、隱藏字段

對於InnoDB存儲引擎,每一行記錄都有兩個隱藏列trx_idroll_pointer,如果數據表中存在主鍵或者非NULL的UNIQUE鍵時不會創建row_id,否則InnoDB會自動生成單調遞增的隱藏主鍵row_id。

列名 是否必須 描述
row_id 單調遞增的行ID,不是必需的,佔用6個字節。 這個跟MVCC關係不大
trx_id 記錄操作該行數據事務的事務ID
roll_pointer 回滾指針,指向當前記錄行的undo log信息

這裏的記錄操作,指的是insert|update|delete。對於delete操作而已,InnoDB認爲是一個update操作,不過會更新一個另外的刪除位,將行表示爲deleted,並非真正刪除。

3、undo log

undo log可以理解成回滾日誌,它存儲的是老版本數據。在表記錄修改之前,會先把原始數據拷貝到undo log裏,如果事務回滾,即可以通過undo log來還原數據。或者如果當前記錄行不可見,可以順着undo log鏈找到滿足其可見性條件的記錄行版本。

在insert/update/delete(本質也是做更新,只是更新一個特殊的刪除位字段)操作時,都會產生undo log。

在InnoDB裏,undo log分爲如下兩類:

1)insert undo log : 事務對insert新記錄時產生的undo log, 只在事務回滾時需要, 並且在事務提交後就可以立即丟棄。

2)update undo log : 事務對記錄進行delete和update操作時產生的undo log,不僅在事務回滾時需要,快照讀也需要,只有當數據庫所使用的快照中不涉及該日誌記錄,對應的回滾日誌纔會被刪除。

undo log有什麼用途呢?

1、事務回滾時,保證原子性和一致性。
2、如果當前記錄行不可見,可以順着undo log鏈找到滿足其可見性條件的記錄行版本(用於MVCC快照讀)。

4、版本鏈

多個事務並行操作某一行數據時,不同事務對該行數據的修改會產生多個版本,然後通過回滾指針(roll_pointer),連成一個鏈表,這個鏈表就稱爲版本鏈。如下:

5、快照讀和當前讀

快照讀: 讀取的是記錄數據的可見版本(有舊的版本)。不加鎖,普通的select語句都是快照讀,如:

select * from user where id = 1;

當前讀:讀取的是記錄數據的最新版本,顯式加鎖的都是當前讀

select * from user where id = 1 for update;
select * from user where id = 1 lock in share mode;

6、ReadView

ReadView是事務在進行快照讀的時候生成的記錄快照, 可以幫助我們解決可見性問題的

如果一個事務要查詢行記錄,需要讀取哪個版本的行記錄呢? ReadView 就是來解決這個問題的。 ReadView 保存了當前事務開啓時所有活躍的事務列表。換個角度,可以理解爲: ReadView 保存了不應該讓這個事務看到的其他事務 ID 列表。

ReadView是如何保證可見性判斷的呢?我們先看看 ReadView 的幾個重要屬性

  • trx_ids: 當前系統中那些活躍(未提交)的讀寫事務ID, 它數據結構爲一個List。(重點注意:這裏的trx_ids中的活躍事務,不包括當前事務自己和已提交的事務,這點非常重要)

  • low_limit_id: 目前出現過的最大的事務ID+1,即下一個將被分配的事務ID。

  • up_limit_id: 活躍事務列表trx_ids中最小的事務ID,如果trx_ids爲空,則up_limit_id 爲 low_limit_id。

  • creator_trx_id: 表示生成該 ReadView 的事務的 事務id

訪問某條記錄的時候如何判斷該記錄是否可見,具體規則如下:

  • 如果被訪問版本的 事務ID = creator_trx_id,那麼表示當前事務訪問的是自己修改過的記錄,那麼該版本對當前事務可見;
  • 如果被訪問版本的 事務ID < up_limit_id,那麼表示生成該版本的事務在當前事務生成 ReadView 前已經提交,所以該版本可以被當前事務訪問。
  • 如果被訪問版本的 事務ID > low_limit_id 值,那麼表示生成該版本的事務在當前事務生成 ReadView 後纔開啓,所以該版本不可以被當前事務訪問。
  • 如果被訪問版本的 事務ID在 up_limit_id和m_low_limit_id 之間,那就需要判斷一下版本的事務ID是不是在 trx_ids 列表中,如果在,說明創建 ReadView 時生成該版本的事務還是活躍的,該版本不可以被訪問;
    如果不在,說明創建 ReadView 時生成該版本的事務已經被提交,該版本可以被訪問。

畫張圖來理解下

這裏需要思考的一個問題就是 何時創建ReadView?

上面說過,ReadView是來解決一個事務需要讀取哪個版本的行記錄的問題的。那麼說明什麼?只有在select的時候纔會創建ReadView。但在不同的隔離級別是有區別的:

在RC隔離級別下,是每個select都會創建最新的ReadView;而在RR隔離級別下,則是當事務中的第一個select請求才創建ReadView(下面會詳細舉例說明)。

那insert/update/delete操作呢?

這樣操作不會創建ReadView。但是這些操作在事務開啓(begin)且其未提交的時候,那麼它的事務ID,會存在在其它存在查詢事務的ReadView記錄中,也就是trx_ids中。

 

三、MVCC實現原理分析

1、如何查詢一條記錄

  1. 獲取事務自己事務ID,即trx_id。(這個也不是select的時候獲取的,而是這個事務開啓的時候獲取的 也就是begin的時候)
  2. 獲取ReadView(這個纔是select的時候纔會生成的)
  3. 數據庫表中如果查詢到數據,那就到ReadView中的事務版本號進行比較。
  4. 如果不符合ReadView的可見性規則, 即就需要Undo log中歷史快照,直到返回符合規則的數據;

InnoDB 實現MVCC,是通過ReadView+ Undo Log 實現的,Undo Log 保存了歷史快照,ReadView可見性規則幫助判斷當前版本的數據是否可見。

2、MVCC是如何實現讀已提交和可重複讀的呢?

其實其它流程都是一樣的,讀已提交和可重複讀唯一的區別在於:在RC隔離級別下,是每個select都會創建最新的ReadView;而在RR隔離級別下,則是當事務中的第一個select請求才創建ReadView。

看完下面這個例子你應該就明白了。

四、經典面試題:MVCC能否解決了幻讀問題呢?

有關這個問題查了很多資料,有的說能解決,有的說不能解決,也有人說能解決部分幻讀場景。這裏部分解決指的是能解決快照讀的幻讀問題,不能解決當前讀的幻讀問題。

 

先說我的結論:

MVCC能解決不可重複讀問題,但是不能解決幻讀問題,不論是快照讀和當前讀都不能解決。RR級別解決幻讀靠的是鎖機制,而不是MVCC機制。

既然網上那麼多人說,MVCC解決能解決快照讀下的幻讀問題, 那這裏通過舉示例來說明,MVCC解決不了快照讀的幻讀問題。

假設有張用戶表,這張表的 id 是主鍵。表中一開始有4條數據。

這裏是在RR級別下研究(可重複讀)。

1、事務A,查詢是否存在 id=5 的記錄,沒有則插入,這是我們期望的正常業務邏輯。

2、這個時候 事務B 新增的一條 id=5 的記錄,並提交事務。

3、事務A,再去查詢 id=5 的時候,發現還是沒有記錄。

上面的文章是這樣來舉例說明,事務A第一次和第二次讀到的是一樣的,所以認爲解決了幻讀。我不認爲這個是解決了幻讀,而是解決了不可能重複讀。它保證了第一次和第二次所讀到的結果是一樣的。

解決幻讀了嗎?顯然沒有,因爲這個時候如果事務A執行一條插入操作

INSERT INTO `user` (`id`, `name`, `pwd`) VALUES (5, '田七', 'fff');

最終 事務A 提交事務,發現報錯了。這就很奇怪,查的時候明明沒有這條記錄,但插入的時候 卻告訴我 主鍵衝突,這就好像幻覺一樣。這纔是幻讀問題。

所以說MVCC是不能解決的,要想解決還是需要鎖。

這裏事務A能正常的插入的前提就是其它事務不能插入id=5並提交成功。要解決這個問題也很簡單,就是事務A先獲得id=5這個排它鎖。

我們可以在事務A第一次查詢的時候加一個排他鎖

select *  from `user` where id = 5 for update

那麼事務B的插入動作永遠屬於堵塞狀態,直到事務A插入成功,並提交。那麼最終是事務B報主鍵衝突而回滾。但事務A不會因爲查詢的時候沒有這條記錄,插入失敗。也就解決了幻讀問題。

所以說 RR級別下解決幻讀問題靠的是鎖機制,而不是MVCC機制。

 

幻讀是什麼?

  “幻讀”指,同一個事務裏面連續執行兩次同樣的sql語句,可能導致不同結果的問題,第二次sql語句可能會返回之前不存在的行。

可以解決的情況
mysql裏面實際上有兩種讀,一種是“快照讀”,比如我們使用select進行查詢,就是快照讀,在“快照讀"的情況下是可以解決“幻讀”的問題的。使用的就是MVCC,就是mvcc利用歷史版本信息(快照)來控制他能讀取的數據的範圍。

另外一種讀是:“當前讀”。對於會對數據修改的操作(update、insert、delete)都是採用當前讀的模式,此外,下面兩個語句也是當前讀:

1、select * from table where ? lock in share mode; (加共享鎖)

2、select * from table where ? for update; (加排它鎖)

因此總結一下,下面幾個語句都是當前讀,都會讀取最新的快照數據,都會加鎖(除了第一個加共享鎖,其他都是互斥鎖):

select * from table where ? lock in share mode; 
select * from table where ? for update; 
insert; 
update; 
delete;


在執行這幾個操作時會讀取最新的記錄,即使是別的事務提交的數據也可以查詢到。比如要update一條記錄,但是在另一個事務中已經delete掉這條數據並且commit了,如果update就會產生衝突,所以在update的時候需要知道最新的數據。讀取的是最新的數據,並且需要加鎖(排它鎖或者共享鎖)。

 

 MVCC下的幻讀

幻讀是⼀個事務按照某個相同條件多次讀取記錄時,後讀取時讀到了之前沒有讀到的記錄,⽽這個記錄來⾃另⼀個事務添加的新記錄,也就是說幻讀是指新插⼊的⾏。
在 REPEATABLE READ 隔離級別下,事務 A 第⼀次執⾏普通的 SELECT 語句時⽣成了⼀個 ReadView(且在 RR 下只會⽣成⼀個RV),之後事務 B 向 user 表中新插⼊⼀條記錄並提交。
ReadView 並不能阻⽌事務 A 執⾏ UPDATE 或者 DELETE 語句來改動這個新插⼊的記錄(由於事務 B 已經提交,因此改動該記錄並不會造成阻塞),但是這樣⼀來,這條新記錄的 trx_id 隱藏列的值就變成了事務 A 的事務 id。之後 A 再使⽤普通的 SELECT 語句去查詢這條記錄時就可以看到這條記錄了,也就可以把這條記錄返回給客戶端。
因爲這個特殊現象的存在,我們也可以認爲 MVCC 並不能完全禁⽌幻讀。

解決幻讀問題

我們知道數據庫的讀操作分爲當前讀和快照讀,⽽在 RR 隔離級別下,MVCC 解決了在快照讀的情況下的幻讀,⽽在實際場景中,我們可能需要讀取實時的數據,⽐如在銀⾏業務等特殊場景下,必須是需要讀取到實時的數據,此時就不能快照讀。
毫⽆疑問,在併發場景下,我們可以通過加鎖的⽅式來實現當前讀,⽽在 MySQL 中則是通過Next-Key Locks來解決幻讀的問題。
Next-Key Locks包含兩部分:記錄鎖(⾏鎖,Record Lock),間隙鎖(Gap Locks)。記錄鎖是加在索引上的鎖,間隙鎖是加在索引之間的。
Record Lock記錄鎖,單條索引記錄上加鎖。
Record Lock 鎖住的永遠是索引,不包括記錄本⾝,即使該表上沒有任何索引,那麼innodb會在後臺創建⼀個隱藏的聚集主鍵索引,那麼鎖住的就是這個隱藏的聚集主鍵索引。
記錄鎖是有 S 鎖和 X 鎖之分的,當⼀個事務獲取了⼀條記錄的 S 型記錄鎖後,其他事務也可以繼續獲取該記錄的 S 型記錄鎖,但不可以繼續獲取 X 型記錄鎖;當⼀個事務獲取了⼀條記錄的 X 型記錄鎖後,其他事務既不可以繼續獲取該記錄的 S 型記錄鎖,也不可以繼續獲取 X型記鎖。
Gap Locks間隙鎖,對索引前後的間隙上鎖,不對索引本⾝上鎖。前開後開區間。
MySQL 在 REPEATABLE READ 隔離級別下是可以解決幻讀問題的,解決⽅案有兩種。

 可以使⽤ MVCC ⽅案解決
也可以採⽤加鎖⽅案解決(間隙鎖)。
但是在使⽤加鎖⽅案解決時有問題,就是事務在第⼀次執⾏讀取操作時,那些幻影記錄尚不存在,我們⽆法給這些幻影記錄加上記錄鎖。所以我們可以使⽤間隙鎖對其鎖。
索引對間隙鎖會產⽣什麼影響?

1 對主鍵或唯⼀索引,如果當前讀時,where 條件全部精確命中(=或in),這種場景本⾝就不會出現幻讀,所以只會加鎖,也就是說間隙鎖會退化爲⾏鎖(記錄鎖)。
2 ⾮唯⼀索引列,如果 where 條件部分命中(>、<、like等)或者全未命中,則會加附近間隙鎖。例如,某表數據如下,⾮唯⼀索引2,6,9,9,11,15。如下語句要操作⾮唯⼀索引列 9 的數據,間隙鎖將會鎖定的列是(6,11],該區間內⽆法插⼊據。
3  對於沒有索引的列,當前讀操作時,會加全表間隙鎖,⽣產環境要注意。

 

小結:

MySQL InnoDB的可重複讀並不保證避免幻讀,需要應⽤使⽤加鎖讀來保證。⽽這個加鎖讀使⽤到的機制就是next-key locks。Read Committed隔離級別:每次select都⽣成⼀個快照讀。
Read Repeatable隔離級別:開啓事務後第⼀個select語句纔是快照讀的地⽅,⽽不是⼀開啓事務就快照讀。
在RR級別下,快照讀是通過MVVC(多版本控制)和undo log來實現的,當前讀是通過加record lock(記錄鎖)和gap lock(間隙鎖)來實現的。
在mysql中,提供了兩種事務隔離技術,第⼀個是mvcc,第⼆個是next-key技術。這個在使⽤不同的語句的時候可以動態選擇。不加lock inshare mode之類的快照讀就使⽤mvcc。否則當前讀使⽤next-key。

mvcc的優勢是不加鎖,併發性⾼。缺點是不是實時數據。
next-key的優勢是獲取實時數據,但是需要加鎖。

在rr級別下,mvcc完全解決了重複讀,但並不能真正的完全避免幻讀,只是在部分場景下利⽤歷史數據規避了幻讀
要完全避免幻讀,需要⼿動加鎖將快照讀調整爲當前讀(mysql不會⾃動加鎖),然後mysql使⽤next-key locks完全避免了幻讀,⽐如rr下,鎖1(0,2,3,4),另⼀個線程的insert 3即被阻塞,在rc下,另⼀個線程仍然可以⼤搖⼤擺的插⼊,如本線程再次查詢⽐如count,則會不⼀致。

 

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