面試官:什麼是MySQL 事務與 MVCC 原理?

小林coding發表於2022-04-01

作者:小林coding

圖解計算機基礎網站https://xiaolincoding.com/

大家好,我是小林。

之前寫過一篇 MySQL 的 MVCC 的工作原理,最近有讀者在網站上學習的時候,評論區指出了一些問題。

而這個知識點很重要,面試太常問了,所以,我就重寫了這篇文章!開車!

正文

這是我的錢包,共有 100 萬元。

今天我心情好,我決定給你的轉賬 100 萬,最後的結果肯定是我的餘額變為 0 元,你的餘額多了 100 萬元,是不是想到就很開心?

轉賬這一動作在程式裡會涉及到一系列的操作,假設我向你轉賬 100 萬的過程是有下面這幾個步驟組成的:

可以看到這個轉賬的過程涉及到了兩次修改資料庫的操作。

假設在執行第三步驟之後,伺服器忽然掉電了,就會發生一個蛋疼的事情,我的賬戶扣了 100 萬,但是錢並沒有到你的賬戶上,也就是說這 100 萬消失了!

要解決這個問題,就要保證轉賬業務裡的所有資料庫的操作是不可分割的,要麼全部執行成功 ,要麼全部失敗,不允許出現中間狀態的資料。

資料庫中的「事務(Transaction」就能達到這樣的效果。

我們在轉賬操作前先開啟事務,等所有資料庫操作執行完成後,才提交事務,對於已經提交的事務來說,該事務對資料庫所做的修改將永久生效,如果中途發生發生中斷或錯誤,那麼該事務期間對資料庫所做的修改將會被回滾到沒執行該事務之前的狀態。


事務有哪些特性?

事務是由 MySQL 的引擎來實現的,我們常見的 InnoDB 引擎它是支援事務的。

不過並不是所有的引擎都能支援事務,比如 MySQL 原生的 MyISAM 引擎就不支援事務,也正是這樣,所以大多數 MySQL 的引擎都是用 InnoDB。

事務看起來感覺簡單,但是要實現事務必須要遵守 4 個特性,分別如下:

  • 原子性(Atomicity):一個事務中的所有操作,要麼全部完成,要麼全部不完成,不會結束在中間某個環節,而且事務在執行過程中發生錯誤,會被回滾到事務開始前的狀態,就像這個事務從來沒有執行過一樣;
  • 一致性(Consistency):資料庫的完整性不會因為事務的執行而受到破壞,比如表中有一個欄位為姓名,它有唯一約束,也就是表中姓名不能重複,如果一個事務對姓名欄位進行了修改,但是在事務提交後,表中的姓名變得非唯一性了,這就破壞了事務的一致性要求,這時資料庫就要撤銷該事務,返回初始化的狀態。
  • 隔離性(Isolation):資料庫允許多個併發事務同時對其資料進行讀寫和修改的能力,隔離性可以防止多個事務併發執行時由於交叉執行而導致資料的不一致。
  • 永續性(Durability):事務處理結束後,對資料的修改就是永久的,即便系統故障也不會丟失。

InnoDB 引擎通過什麼技術來保證事務的這四個特性的呢?

  • 永續性是通過 redo log (重做日誌)來保證的;
  • 原子性是通過 undo log(回滾日誌) 來保證的;
  • 隔離性是通過 MVCC(多版本併發控制) 或鎖機制來保證的;
  • 一致性則是通過永續性+原子性+隔離性來保證;

這次將重點介紹事務的隔離性,這也是面試時最常問的知識的點。

為什麼事務要有隔離性,我們就要知道併發事務時會引發什麼問題。

並行事務會引發什麼問題?

MySQL 服務端是允許多個客戶端連線的,這意味著 MySQL 會出現同時處理多個事務的情況。

那麼在同時處理多個事務的時候,就可能出現髒讀(dirty read)、不可重複讀(non-repeatable read)、幻讀(phantom read)的問題

接下來,通過舉例子給大家說明,這些問題是如何發生的。

髒讀

如果一個事務「讀到」了另一個「未提交事務修改過的資料」,就意味著發生了「髒讀」現象。

舉個例子。

假設有 A 和 B 這兩個事務同時在處理,事務 A 先開始從資料庫中讀取小林的餘額資料,然後再執行更新操作,如果此時事務 A 還沒有提交事務,而此時正好事務 B 也從資料庫中讀取小林的餘額資料,那麼事務 B 讀取到的餘額資料是剛才事務 A 更新後的資料,即使沒有提交事務。

因為事務 A 是還沒提交事務的,也就是它隨時可能發生回滾操作,如果在上面這種情況事務 A 發生了回滾,那麼事務 B 剛才得到的資料就是過期的資料,這種現象就被稱為髒讀。

不可重複讀

在一個事務內多次讀取同一個資料,如果出現前後兩次讀到的資料不一樣的情況,就意味著發生了「不可重複讀」現象。

舉個例子。

假設有 A 和 B 這兩個事務同時在處理,事務 A 先開始從資料庫中讀取小林的餘額資料,然後繼續執行程式碼邏輯處理,在這過程中如果事務 B 更新了這條資料,並提交了事務,那麼當事務 A 再次讀取該資料時,就會發現前後兩次讀到的資料是不一致的,這種現象就被稱為不可重複讀。

幻讀

在一個事務內多次查詢某個符合查詢條件的「記錄數量」,如果出現前後兩次查詢到的記錄數量不一樣的情況,就意味著發生了「幻讀」現象。

舉個例子。

假設有 A 和 B 這兩個事務同時在處理,事務 A 先開始從資料庫查詢賬戶餘額大於 100 萬的記錄,發現共有 5 條,然後事務 B 也按相同的搜尋條件也是查詢出了 5 條記錄。

接下來,事務 A 插入了一條餘額超過 100 萬的賬號,並提交了事務,此時資料庫超過 100 萬餘額的賬號個數就變為 6。

然後事務 B 再次查詢賬戶餘額大於 100 萬的記錄,此時查詢到的記錄數量有 6 條,發現和前一次讀到的記錄數量不一樣了,就感覺發生了幻覺一樣,這種現象就被稱為幻讀。

事務的隔離級別有哪些?

前面我們提到,當多個事務併發執行時可能會遇到「髒讀、不可重複讀、幻讀」的現象,這些現象會對事務的一致性產生不同程式的影響。

  • 髒讀:讀到其他事務未提交的資料;
  • 不可重複讀:前後讀取的資料不一致;
  • 幻讀:前後讀取的記錄數量不一致。

這三個現象的嚴重性排序如下:

SQL 標準提出了四種隔離級別來規避這些現象,隔離級別越高,效能效率就越低,這四個隔離級別如下:

  • 讀未提交(read uncommitted,指一個事務還沒提交時,它做的變更就能被其他事務看到;
  • 讀提交(read committed,指一個事務提交之後,它做的變更才能被其他事務看到;
  • 可重複讀(repeatable read,指一個事務執行過程中看到的資料,一直跟這個事務啟動時看到的資料是一致的,MySQL InnoDB 引擎的預設隔離級別
  • 序列化(serializable;會對記錄加上讀寫鎖,在多個事務對這條記錄進行讀寫操作時,如果發生了讀寫衝突的時候,後訪問的事務必須等前一個事務執行完成,才能繼續執行;

按隔離水平高低排序如下:

針對不同的隔離級別,併發事務時可能發生的現象也會不同。

也就是說:

  • 在「讀未提交」隔離級別下,可能發生髒讀、不可重複讀和幻讀現象;
  • 在「讀提交」隔離級別下,可能發生不可重複讀和幻讀現象,但是不可能發生髒讀現象;
  • 在「可重複讀」隔離級別下,可能發生幻讀現象,但是不可能髒讀和不可重複讀現象;
  • 在「序列化」隔離級別下,髒讀、不可重複讀和幻讀現象都不可能會發生。

所以,要解決髒讀現象,就要升級到「讀提交」以上的隔離級別;要解決不可重複讀現象,就要升級到「可重複讀」的隔離級別

不過,要解決幻讀現象不建議將隔離級別升級到「序列化」,因為這樣會導致資料庫在併發事務時效能很差。

InnoDB 引擎的預設隔離級別雖然是「可重複讀」,但是它通過next-key lock 鎖(行鎖和間隙鎖的組合)來鎖住記錄之間的“間隙”和記錄本身,防止其他事務在這個記錄之間插入新的記錄,這樣就避免了幻讀現象。

接下里,舉個具體的例子來說明這四種隔離級別,有一張賬戶餘額表,裡面有一條記錄:

然後有兩個併發的事務,事務 A 只負責查詢餘額,事務 B 則會將我的餘額改成 200 萬,下面是按照時間順序執行兩個事務的行為:

在不同隔離級別下,事務 A 執行過程中查詢到的餘額可能會不同:

  • 在「讀未提交」隔離級別下,事務 B 修改餘額後,雖然沒有提交事務,但是此時的餘額已經可以被事務 A 看見了,於是事務 A 中餘額 V1 查詢的值是 200 萬,餘額 V2、V3 自然也是 200 萬了;
  • 在「讀提交」隔離級別下,事務 B 修改餘額後,因為沒有提交事務,所以事務 A 中餘額 V1 的值還是 100 萬,等事務 B 提交完後,最新的餘額資料才能被事務 A 看見,因此額 V2、V3 都是 200 萬;
  • 在「可重複讀」隔離級別下,事務 A 只能看見啟動事務時的資料,所以餘額 V1、餘額 V2 的值都是 100 萬,當事務 A 提交事務後,就能看見最新的餘額資料了,所以餘額 V3 的值是 200 萬;
  • 在「序列化」隔離級別下,事務 B 在執行將餘額 100 萬修改為 200 萬時,由於此前事務 A 執行了讀操作,這樣就發生了讀寫衝突,於是就會被鎖住,直到事務 A 提交後,事務 B 才可以繼續執行,所以從 A 的角度看,餘額 V1、V2 的值是 100 萬,餘額 V3 的值是 200萬。

這四種隔離級別具體是如何實現的呢?

  • 對於「讀未提交」隔離級別的事務來說,因為可以讀到未提交事務修改的資料,所以直接讀取最新的資料就好了;
  • 對於「序列化」隔離級別的事務來說,通過加讀寫鎖的方式來避免並行訪問;
  • 對於「讀提交」和「可重複讀」隔離級別的事務來說,它們是通過 Read View 來實現的,它們的區別在於建立 Read View 的時機不同,大家可以把 Read View 理解成一個資料快照,就像相機拍照那樣,定格某一時刻的風景。「讀提交」隔離級別是在「每個語句執行前」都會重新生成一個 Read View,而「可重複讀」隔離級別是「啟動事務時」生成一個 Read View,然後整個事務期間都在用這個 Read View

注意,執行「開始事務」命令,並不意味著啟動了事務。在 MySQL 有兩種開啟事務的命令,分別是:

  • 第一種:begin/start transaction 命令;
  • 第二種:start transaction with consistent snapshot 命令;

這兩種開啟事務的命令,事務的啟動時機是不同的:

  • 執行了 begin/start transaction 命令後,並不代表事務啟動了。只有在執行這個命令後,執行了增刪查改操作的 SQL 語句,才是事務真正啟動的時機;
  • 執行了 start transaction with consistent snapshot 命令,就會馬上啟動事務。

接下來詳細說下,Read View 在 MVCC 裡如何工作的?

Read View 在 MVCC 裡如何工作的?

我們需要了解兩個知識:

  • Read View 中四個欄位作用;
  • 聚簇索引記錄中兩個跟事務有關的隱藏列;

那 Read View 到底是個什麼東西?

Read View 有四個重要的欄位:

  • m_ids :指的是在建立 Read View 時,當前資料庫中「活躍事務」的事務 id 列表,注意是一個列表,“活躍事務”指的就是,啟動了但還沒提交的事務
  • min_trx_id :指的是在建立 Read View 時,當前資料庫中「活躍事務」中事務 id 最小的事務,也就是 m_ids 的最小值。
  • max_trx_id :這個並不是 m_ids 的最大值,而是建立 Read View 時當前資料庫中應該給下一個事務的 id 值,也就是全域性最大的事務 id + 1。
  • creator_trx_id :指的是建立該 Read View 的事務的事務 id

知道了 Read View 的欄位,我們還需要了解聚簇索引記錄中的兩個隱藏列。

假設在賬戶餘額表插入一條小林餘額為 100 萬的記錄,然後我把這兩個隱藏列也畫出來,該記錄的整個示意圖如下:

對於使用 InnoDB 儲存引擎的資料庫表,它的聚簇索引記錄中都包含下面兩個隱藏列:

  • trx_id,當一個事務對某條聚簇索引記錄進行改動時,就會把該事務的事務 id 記錄在 trx_id 隱藏列裡
  • roll_pointer,每次對某條聚簇索引記錄進行改動時,都會把舊版本的記錄寫入到 undo 日誌中,然後這個隱藏列是個指標,指向每一箇舊版本記錄,於是就可以通過它找到修改前的記錄。

在建立 Read View 後,我們可以將記錄中的 trx_id 劃分這三種情況:

一個事務去訪問記錄的時候,除了自己的更新記錄總是可見之外,還有這幾種情況:

  • 如果記錄的 trx_id 值小於 Read View 中的 min_trx_id 值,表示這個版本的記錄是在建立 Read View 已經提交的事務生成的,所以該版本的記錄對當前事務可見
  • 如果記錄的 trx_id 值大於等於 Read View 中的 max_trx_id 值,表示這個版本的記錄是在建立 Read View 才啟動的事務生成的,所以該版本的記錄對當前事務不可見
  • 如果記錄的 trx_id 值在 Read View 的 min_trx_idmax_trx_id 之間,需要判斷 trx_id 是否在 m_ids 列表中:
    • 如果記錄的 trx_id m_ids 列表中,表示生成該版本記錄的活躍事務依然活躍著(還沒提交事務),所以該版本的記錄對當前事務不可見
    • 如果記錄的 trx_id 不在 m_ids 列表中,表示生成該版本記錄的活躍事務已經被提交,所以該版本的記錄對當前事務可見

這種通過「版本鏈」來控制併發事務訪問同一個記錄時的行為就叫 MVCC(多版本併發控制)。

可重複讀是如何工作的?

可重複讀隔離級別是啟動事務時生成一個 Read View,然後整個事務期間都在用這個 Read View

假設事務 A (事務 id 為51)啟動後,緊接著事務 B (事務 id 為52)也啟動了,那這兩個事務建立的 Read View 如下:

事務 A 和 事務 B 的 Read View 具體內容如下:

  • 在事務 A 的 Read View 中,它的事務 id 是 51,由於它是第一個啟動的事務,所以此時活躍事務的事務 id 列表就只有 51,活躍事務的事務 id 列表中最小的事務 id 是事務 A 本身,下一個事務 id 則是 52。
  • 在事務 B 的 Read View 中,它的事務 id 是 52,由於事務 A 是活躍的,所以此時活躍事務的事務 id 列表是 51 和 52,活躍的事務 id 中最小的事務 id 是事務 A,下一個事務 id 應該是 53。

接著,在可重複讀隔離級別下,事務 A 和事務 B 按順序執行了以下操作:

  • 事務 B 讀取小林的賬戶餘額記錄,讀到餘額是 100 萬;
  • 事務 A 將小林的賬戶餘額記錄修改成 200 萬,並沒有提交事務;
  • 事務 B 讀取小林的賬戶餘額記錄,讀到餘額還是 100 萬;
  • 事務 A 提交事務;
  • 事務 B 讀取小林的賬戶餘額記錄,讀到餘額依然還是 100 萬;

接下來,跟大傢俱體分析下。

事務 B 第一次讀小林的賬戶餘額記錄,在找到記錄後,它會先看這條記錄的 trx_id,此時發現 trx_id 為 50,比事務 B 的 Read View 中的 min_trx_id 值(51)還小,這意味著修改這條記錄的事務早就在事務 B 啟動前提交過了,所以該版本的記錄對事務 B 可見的,也就是事務 B 可以獲取到這條記錄。

接著,事務 A 通過 update 語句將這條記錄修改了(還未提交事務),將小林的餘額改成 200 萬,這時 MySQL 會記錄相應的 undo log,並以連結串列的方式串聯起來,形成版本鏈,如下圖:

你可以在上圖的「記錄的欄位」看到,由於事務 A 修改了該記錄,以前的記錄就變成舊版本記錄了,於是最新記錄和舊版本記錄通過連結串列的方式串起來,而且最新記錄的 trx_id 是事務 A 的事務 id(trx_id = 51)。

然後事務 B 第二次去讀取該記錄,發現這條記錄的 trx_id 值為 51,在事務 B 的 Read View 的 min_trx_id 和 max_trx_id 之間,則需要判斷 trx_id 值是否在 m_ids 範圍內,判斷的結果是在的,那麼說明這條記錄是被還未提交的事務修改的,這時事務 B 並不會讀取這個版本的記錄。而是沿著 undo log 鏈條往下找舊版本的記錄,直到找到 trx_id 「小於」事務 B 的 Read View 中的 min_trx_id 值的第一條記錄,所以事務 B 能讀取到的是 trx_id 為 50 的記錄,也就是小林餘額是 100 萬的這條記錄。

最後,當事物 A 提交事務後,由於隔離級別時「可重複讀」,所以事務 B 再次讀區記錄時,還是基於啟動事務時建立的 Read View 來判斷當前版本的記錄是否可見。所以,即使事物 A 將小林餘額修改為 200 萬並提交了事務, 事務 B 第三次讀取記錄時,讀到的記錄都是小林餘額是 100 萬的這條記錄

就是通過這樣的方式實現了,「可重複讀」隔離級別下在事務期間讀到的記錄都是事務啟動前的記錄。

讀提交是如何工作的?

讀提交隔離級別是在每次讀取資料時,都會生成一個新的 Read View

也意味著,事務期間的多次讀取同一條資料,前後兩次讀的資料可能會出現不一致,因為可能這期間另外一個事務修改了該記錄,並提交了事務。

那讀提交隔離級別是怎麼工作呢?我們還是以前面的例子來聊聊。

假設事務 A (事務 id 為51)啟動後,緊接著事務 B (事務 id 為52)也啟動了,接著按順序執行了以下操作:

  • 事務 B 讀取資料(建立 Read View),小林的賬戶餘額為 100 萬;
  • 事務 A 修改資料(還沒提交事務),將小林的賬戶餘額從 100 萬修改成了 200 萬;
  • 事務 B 讀取資料(建立 Read View),小林的賬戶餘額為 100 萬;
  • 事務 A 提交事務;
  • 事務 B 讀取資料(建立 Read View),小林的賬戶餘額為 200 萬;

那具體怎麼做到的呢?我們重點看事務 B 每次讀取資料時建立的 Read View。前兩次 事務 B 讀取資料時建立的 Read View 如下圖:

我們來分析下為什麼事務 B 第二次讀資料時,讀不到事務 A (還未提交事務)修改的資料?

事務 B 在找到小林這條記錄時,會看這條記錄的 trx_id 是 51,在事務 B 的 Read View 的 min_trx_id 和 max_trx_id 之間,接下來需要判斷 trx_id 值是否在 m_ids 範圍內,判斷的結果是在的,那麼說明這條記錄是被還未提交的事務修改的,這時事務 B 並不會讀取這個版本的記錄。而是,沿著 undo log 鏈條往下找舊版本的記錄,直到找到 trx_id 「小於」事務 B 的 Read View 中的 min_trx_id 值的第一條記錄,所以事務 B 能讀取到的是 trx_id 為 50 的記錄,也就是小林餘額是 100 萬的這條記錄。

我們來分析下為什麼事務 A 提交後,事務 B 就可以讀到事務 A 修改的資料?

在事務 A 提交後,由於隔離級別是「讀提交」,所以事務 B 在每次讀資料的時候,會重新建立 Read View,此時事務 B 第三次讀取資料時建立的 Read View 如下:

在這裡插入圖片描述在這裡插入圖片描述

事務 B 在找到小林這條記錄時,會發現這條記錄的 trx_id 是 51,比事務 B 的 Read View 中的 min_trx_id 值(52)還小,這意味著修改這條記錄的事務早就在建立 Read View 前提交過了,所以該版本的記錄對事務 B 是可見的

正是因為在讀提交隔離級別下,事務每次讀資料時都重新建立 Read View,那麼在事務期間的多次讀取同一條資料,前後兩次讀的資料可能會出現不一致,因為可能這期間另外一個事務修改了該記錄,並提交了事務。

總結

事務是在 MySQL 引擎層實現的,我們常見的 InnoDB 引擎是支援事務的,事務的四大特性是原子性、一致性、隔離性、永續性,我們這次主要講的是隔離性。

當多個事務併發執行的時候,會引發髒讀、不可重複讀、幻讀這些問題,那為了避免這些問題,SQL 提出了四種隔離級別,分別是讀未提交、讀已提交、可重複讀、序列化,從左往右隔離級別順序遞增,隔離級別越高,意味著效能越差,InnoDB 引擎的預設隔離級別是可重複讀。

要解決髒讀現象,就要將隔離級別升級到讀已提交以上的隔離級別,要解決不可重複讀現象,就要將隔離級別升級到可重複讀以上的隔離級別。

而對於幻讀現象,不建議將隔離級別升級為序列化,因為這會導致資料庫併發時效能很差。InnoDB 引擎的預設隔離級別雖然是「可重複讀」,但是它通過 next-key lock 鎖(行鎖+間隙鎖的組合)來鎖住記錄之間的“間隙”和記錄本身,防止其他事務在這個記錄之間插入新的記錄,這樣就避免了幻讀現象。

對於「讀提交」和「可重複讀」隔離級別的事務來說,它們是通過 **Read View **來實現的,它們的區別在於建立 Read View 的時機不同:

  • 「讀提交」隔離級別是在每個 select 都會生成一個新的 Read View,也意味著,事務期間的多次讀取同一條資料,前後兩次讀的資料可能會出現不一致,因為可能這期間另外一個事務修改了該記錄,並提交了事務。
  • 「可重複讀」隔離級別是啟動事務時生成一個 Read View,然後整個事務期間都在用這個 Read View,這樣就保證了在事務期間讀到的資料都是事務啟動前的記錄。

這兩個隔離級別實現是通過「事務的 Read View 裡的欄位」和「記錄中的兩個隱藏列」的比對,來控制併發事務訪問同一個記錄時的行為,這就叫 MVCC(多版本併發控制)。

在可重複讀隔離級別中,普通的 select 語句就是基於 MVCC 實現的快照讀,也就是不會加鎖的。而 select .. for update 語句就不是快照讀了,而是當前讀了,也就是每次讀都是拿到最新版本的資料,但是它會對讀到的記錄加上 next-key lock 鎖。

相關文章