MySQL 8.0 MVCC 源碼解析

前言

以前在 面試必問的 MySQL,你懂了嗎? 中簡單的介紹了 MVCC 的原理,掌握了這個原理其實在面試時是能夠加分很多的。html

由於如今不少人的理解仍是停留在《高性能 MySQL》書中的版本,也就是經過建立版本號刪除版本號來判斷。這個時候若是你能給出正確的理解,則會讓面試官眼前一亮,這也是咱們在面試中凸顯出「本身和其餘候選者不同的地方」,會更有利於在衆多候選者中脫穎而出。mysql

本文在此基礎上,對 MVCC 展開詳細的分析,同時修改了以前的一些不太準確的說法,但願能夠助你在面試中更好的發(zhuang)揮(bi)。c++

PS:本文的源碼基於MySQL 8.0.16,對於現階段生產環境經常使用的 5.7.* 版本,MVCC 部分的源碼基本相同,所以能夠放心參考。而 5.6.* 則有比較大的不一樣,主要是一些數據結構都改變了,可是究其核心原理仍是基本一致的。程序員

基礎概念

併發事務帶來的問題(現象)

髒讀:一個事務讀取到另外一個事務更新但還未提交的數據,若是另外一個事務出現回滾或者進一步更新,則會出現問題。面試

不可重複讀:在一個事務中兩次次讀取同一個數據時,因爲在兩次讀取之間,另外一個事務修改了該數據,因此出現兩次讀取的結果不一致。sql

幻讀:在一個事務中使用相同的 SQL 兩次讀取,第二次讀取到了其餘事務新插入的行。數據庫

要解決這些併發事務帶來的問題,一個比較簡單粗暴的方法是加鎖,可是加鎖必然會帶來性能的下降,所以 MySQL 使用了 MVCC 來提高併發事務下的性能。安全

MVCC 帶來的好處?

試想,若是沒有 MVCC,爲了保證併發事務的安全,一個比較容易想到的辦法就是加讀寫鎖,實現:讀讀不衝突、讀寫衝突、寫讀衝突,寫寫衝突,在這種狀況下,併發讀寫的性能必然會收到嚴重影響。markdown

而經過 MVCC,咱們能夠作到讀寫之間不衝突,咱們讀的時候只須要將當前記錄拷貝一份到內存中(ReadView),以後該事務的查詢就只跟 ReadView 打交道,不影響其餘事務對該記錄的寫操做。數據結構

事務隔離級別

讀未提交(Read Uncommitted):最低的隔離級別,會讀取到其餘事務還未提交的內容,存在髒讀。

讀已提交(Read Committed):讀取到的內容都是已經提交的,能夠解決髒讀,可是存在不可重複讀。

可重複讀(Repeatable Read):在一個事務中屢次讀取時看到相同的內容,能夠解決不可重複讀,可是存在幻讀。可是在 InnoDB 中不存在幻讀問題,對於快照讀,InnoDB 使用 MVCC 解決幻讀,對於當前讀,InnoDB 經過 gap locks 或 next-key locks 解決幻讀。

串行化(Serializable):最高的隔離級別,串行的執行事務,沒有併發事務問題。

InnoDB MVCC 實現

核心數據結構

trx_sys_t:事務系統中央存儲器數據結構

struct trx_sys_t {
  TrxSysMutex mutex; /*! 互斥鎖 */
​
  MVCC *mvcc;    /*!  mvcc */
​
  volatile trx_id_t max_trx_id; /*! 要分配給下一個事務的事務id*/
​
  std::atomic<trx_id_t> min_active_id; /*! 最小的活躍事務Id */
  
  // 省略...
​
  trx_id_t rw_max_trx_id; /*!< 最大讀寫事務Id */
​
  // 省略...
​
  trx_ids_t rw_trx_ids; /*! 當前活躍的讀寫事務Id列表 */
​
  Rsegs rsegs; /*!< 回滾段 */
​
  // 省略...
};
複製代碼

MVCC:MVCC 讀取視圖管理器

class MVCC {
 public:
  // 省略...
​
  /** 建立一個視圖 */
  void view_open(ReadView *&view, trx_t *trx);
​
  /** 關閉一個視圖 */
  void view_close(ReadView *&view, bool own_mutex);
​
  /** 釋放一個視圖 */
  void view_release(ReadView *&view);
​
 // 省略...
​
  /** 判斷視圖是否處於活動和有效狀態 */
  static bool is_view_active(ReadView *view) {
    ut_a(view != reinterpret_cast<ReadView *>(0x1));
​
    return (view != NULL && !(intptr_t(view) & 0x1));
  }
​
 // 省略...
​
 private:
  typedef UT_LIST_BASE_NODE_T(ReadView) view_list_t;
​
  /** 空閒能夠被重用的視圖*/
  view_list_t m_free;
​
  /**  活躍或者已經關閉的 Read View 的鏈表 */
  view_list_t m_views;
};
複製代碼

ReadView:視圖,某一時刻的一個事務快照

class ReadView {
​
  // 省略...
​
 private:
  /** 高水位,大於等於這個ID的事務均不可見*/
  trx_id_t m_low_limit_id;
​
  /** 低水位:小於這個ID的事務都可見 */
  trx_id_t m_up_limit_id;
​
  /** 建立該 Read View 的事務ID*/
  trx_id_t m_creator_trx_id;
​
  /** 建立視圖時的活躍事務id列表*/
  ids_t m_ids;
​
  /** 配合purge,標識該視圖不須要小於m_low_limit_no的UNDO LOG,
   * 若是其餘視圖也不須要,則能夠刪除小於m_low_limit_no的UNDO LOG*/
  trx_id_t m_low_limit_no;
​
  /** 標記視圖是否被關閉*/
  bool m_closed;
​
  // 省略...
};
複製代碼

增長隱藏字段

爲了實現 MVCC,InnoDB 會向數據庫中的每行記錄增長三個字段:

DB_ROW_ID:行ID,6字節,隨着插入新行而單調遞增,若是有主鍵,則不會包含該列。

DB_TRX_ID:事務ID,6字節,記錄插入或更新該行的最後一個事務的事務標識,也就是事務ID。

DB_ROLL_PTR:回滾指針,7字節,指向寫入回滾段的 undo log 記錄。每次對某條記錄進行更新時,會經過 undo log 記錄更新前的行內容,更新後的行記錄會經過 DB_ROLL_PTR 指向該 undo log 。當某條記錄被屢次修改時,該行記錄會存在多個版本,經過DB_ROLL_PTR 連接造成一個相似版本鏈的概念,大體以下圖所示。

源碼分析

在源碼中,添加這3個字段的方法在:/storage/innobase/dict/dict0dict.cc 的 dict_table_add_system_columns 方法中,核心部分以下圖。

增刪改的底層操做

當咱們更新一條數據,InnoDB 會進行以下操做:

  1. 加鎖:對要更新的行記錄加排他鎖

  2. 寫 undo log:將更新前的記錄寫入 undo log,並構建指向該 undo log 的回滾指針 roll_ptr

  3. 更新行記錄:更新行記錄的 DB_TRX_ID 屬性爲當前的事務Id,更新 DB_ROLL_PTR 屬性爲步驟2生成的回滾指針,將這次要更新的屬性列更新爲目標值

  4. 寫 redo log:DB_ROLL_PTR 使用步驟2生成的回滾指針,DB_TRX_ID 使用當前的事務Id,並填充更新後的屬性值

  5. 處理結束,釋放排他鎖

刪除操做:在底層實現中是使用更新來實現的,邏輯基本和更新操做同樣,幾個須要注意的點:1)寫 undo log 中,會經過 type_cmpl 來標識是刪除仍是更新,而且不記錄列的舊值;2)這邊不會直接刪除,只會給行記錄的 info_bits 打上刪除標識(REC_INFO_DELETED_FLAG),以後會由專門的 purge 線程來執行真正的刪除操做。

插入操做:相比於更新操做比較簡單,就是新增一條記錄,DB_TRX_ID 使用當前的事務Id,一樣會有 undo log 和 redo log。

源碼分析

更新行記錄的核心源碼在:/storage/innobase/btr/btr0cur.cc/btr_cur_update_in_place 方法,核心部分以下圖。

構建一致性讀取視圖(ReadView)

當咱們的隔離級別爲 RR 時:每開啓一個事務,系統會給該事務會分配一個事務 Id,在該事務執行第一個 select 語句的時候,會生成一個當前時間點的事務快照 ReadView,核心屬性以下:

  • m_ids:建立 ReadView 時當前系統中活躍的事務 Id 列表,能夠理解爲生成 ReadView 那一刻還未執行提交的事務,而且該列表是個升序列表。

  • m_up_limit_id:低水位,取 m_ids 列表的第一個節點,由於 m_ids 是升序列表,所以也就是 m_ids 中事務 Id 最小的那個。

  • m_low_limit_id:高水位,生成 ReadView 時系統將要分配給下一個事務的 Id 值。

  • m_creator_trx_id:建立該 ReadView 的事務的事務 Id。

源碼分析

MVCC 模式下的普通查詢主方法入口在:/storage/innobase/row/row0sel.cc 的 row_search_mvcc 方法中,以後的全部源碼分析基本都在該方法內。

具體建立視圖的方法在 ReadView::prepare,調用鏈以下:

row_search_mvcc -> trx_assign_read_view -> MVCC::view_open ->

ReadView::prepare,源碼以下:

最後,會將這個建立的 ReadView 添加到 MVCC 的 m_views 中。

視圖可見性判斷:SQL 查詢走聚簇索引

有了這個 ReadView,這樣在訪問某條記錄時,只須要按照下邊的步驟判斷記錄的某個版本是否可見:

  1. 若是被訪問版本的 trx_id 與 ReadView 中的 m_creator_trx_id 值相同,意味着當前事務在訪問它本身修改過的記錄,因此該版本能夠被當前事務訪問。

  2. 若是被訪問版本的 trx_id 小於 ReadView 中的 m_up_limit_id(低水位),代表被訪問版本的事務在當前事務生成 ReadView 前已經提交,因此該版本能夠被當前事務訪問。

  3. 若是被訪問版本的 trx_id 大於等於 ReadView 中的 m_low_limit_id(高水位),代表被訪問版本的事務在當前事務生成 ReadView 後纔開啓,因此該版本不能夠被當前事務訪問。

  4. 若是被訪問版本的 trx_id 屬性值在 ReadView 的 m_up_limit_id 和 m_low_limit_id 之間,那就須要判斷 trx_id 屬性值是否是在 m_ids 列表中,這邊會經過二分法查找。若是在,說明建立 ReadView 時生成該版本的事務仍是活躍的,該版本不能夠被訪問;若是不在,說明建立 ReadView 時生成該版本的事務已經被提交,該版本能夠被訪問。

在進行判斷時,首先會拿記錄的最新版原本比較,若是該版本沒法被當前事務看到,則經過記錄的 DB_ROLL_PTR 找到上一個版本,從新進行比較,直到找到一個能被當前事務看到的版本。

而對於刪除,其實就是一種特殊的更新,InnoDB 在 info_bits 中用一個標記位 delete_flag 標識是否刪除。當咱們在進行判斷時,會檢查下 delete_flag 是否被標記,若是是,則會根據狀況進行處理:1)若是索引是聚簇索引,而且具備惟一特性(主鍵、惟一索引等),則返回 DB_RECORD_NOT_FOUND;2)不然,會尋找下一條記錄繼續流程。

其實很容易理解,若是是惟一索引查詢,必然只有一條記錄,若是被刪除了則直接返回空,而若是是普通索引,可能存在多個相同值的行記錄,該行不存在,則繼續查找下一條。

以上內容是對於 RR 級別來講,而對於 RC 級別,其實整個過程幾乎同樣,惟一不一樣的是生成 ReadView 的時機,RR 級別只在事務第一次 select 時生成一次,以後一直使用該 ReadView。而 RC 級別則在每次 select 時,都會生成一個 ReadView。

源碼分析

走聚簇索引的核心流程在 row_search_mvcc 方法,以下:

視圖可見性判斷在方法:changes_visible,調用鏈以下:

row_search_mvcc -> lock_clust_rec_cons_read_sees ->

changes_visible,源碼以下:

判斷記錄是否被打上 delete_flag 標的方法在:/storage/innobase/include/rem0rec.ic 的 rec_get_deleted_flag 方法中,以下圖。

獲取記錄的上一個版本

獲取記錄的上一個版本,主要是經過 DB_ROLL_PTR 來實現,核心流程以下:

  1. 獲取記錄的回滾指針 DB_ROLL_PTR、獲取記錄的事務Id

  2. 經過回滾指針拿到對應的 undo log

  3. 解析 undo log,並使用 undo log 構建用於更新向量 UPDATE

  4. 構建記錄的上一個版本:先用記錄的當前版本填充,而後使用 UPDATE(undo log)進行覆蓋。

源碼解析

構建記錄的上一個版本:trx_undo_prev_version_build,調用鏈以下:

row_search_mvcc -> row_sel_build_prev_vers_for_mysql -> row_vers_build_for_consistent_read -> trx_undo_prev_version_build,源碼以下:

視圖可見性判斷:SQL 查詢走普通(二級)索引

面試必問的 MySQL,你懂了嗎? 只分析了走聚簇索引的狀況,本文簡單的介紹下走普通(二級)索引的狀況。

當走普通索引時,判斷邏輯以下:

  1. 判斷被訪問索引記錄所在頁的最大事務 Id 是否小於 ReadView 中的 m_up_limit_id(低水位),若是是則表明該頁的最後一次修改事務 Id 在 ReadView 建立前之前已經提交,則必然能夠訪問;若是不是,並不表明必定不能夠訪問,道理跟走聚簇索引同樣,事務 Id 大的也可能提交比較早,因此須要作進一步判斷,見步驟2。

  2. 使用 ICP(Index Condition Pushdown)根據索引信息來判斷搜索條件是否知足,這邊主要是在使用聚簇索引判斷前先進行過濾,這邊有三種狀況:a)ICP 判斷不知足條件但沒有超出掃描範圍,則獲取下一條記錄繼續查找;b)若是不知足條件而且超出掃描返回,則返回 DB_RECORD_NOT_FOUND;c)若是 ICP 判斷符合條件,則會獲取對應的聚簇索引來進行可見性判斷。

源碼分析

普通(非聚簇)索引的視圖可見性判斷在方法:lock_sec_rec_cons_read_sees,調用鏈以下:

row_search_mvcc -> lock_sec_rec_cons_read_sees,源碼以下:

擴展理解

ICP(Index Condition Pushdown)

ICP 是 MySQL 5.6 引入的一個優化,根據官方的說法:ICP 能夠減小存儲引擎訪問基表的次數 和 MySQL 訪問存儲引擎的次數,這邊涉及到 MySQL 底層的處理邏輯,不是本文重點,這邊不進行細講。

這邊用官方的例子簡單介紹下,咱們有張 people 表,索引定義爲:INDEX (zipcode, lastname, firstname),對於如下這個 SQL:

SELECT * FROM people
  WHERE zipcode='95054'
  AND lastname LIKE '%etrunia%'
  AND address LIKE '%Main Street%';
複製代碼

當沒有使用 ICP 時:此查詢會使用該索引,可是必須掃描 people 表全部符合 zipcode='95054' 條件的記錄。

當使用 ICP 時:不只會使用 zipcode 的條件來進行過濾,還會使用 (lastname LIKE '%etrunia%')來進行過濾,這樣能夠避免掃描符合 zipcode 條件而不符合 lastname 條件匹配的記錄行 。

ICP 的官方文檔:dev.mysql.com/doc/refman/…

當前讀和快照讀

當前讀:官方叫作 Locking Reads(鎖定讀取),讀取數據的最新版本。常見的 update/insert/delete、還有 select ... for update、select ... lock in share mode 都是當前讀。

官方文檔:dev.mysql.com/doc/refman/…

快照讀:官方叫作 Consistent Nonlocking Reads(一致性非鎖定讀取,也叫一致性讀取),讀取快照版本,也就是 MVCC 生成的 ReadView。用於普通的 select 的語句。

官方文檔:dev.mysql.com/doc/refman/…

MVCC 解決了幻讀了沒有?

MVCC 解決了部分幻讀,但並無徹底解決幻讀。

對於快照讀,MVCC 由於由於從 ReadView 讀取,因此必然不會看到新插入的行,因此自然就解決了幻讀的問題。

而對於當前讀的幻讀,MVCC 是沒法解決的。須要使用 Gap Lock 或 Next-Key Lock(Gap Lock + Record Lock)來解決。

其實原理也很簡單,用上面的例子稍微修改下以觸發當前讀:select * from user where id < 10 for update,當使用了 Gap Lock 時,Gap 鎖會鎖住 id < 10 的整個範圍,所以其餘事務沒法插入 id < 10 的數據,從而防止了幻讀。

Repeatable Read 解決了幻讀是什麼狀況?

SQL 標準中規定的 RR 並不能消除幻讀,可是 MySQL InnoDB 的 RR 能夠,靠的就是 Gap 鎖。在 RR 級別下,Gap 鎖是默認開啓的,而在 RC 級別下,Gap 鎖是關閉的。

幾個例子

例子1:RR(RC) 真正生成 ReadView 的時機

解析:RR 生成 ReadView 的時機是事務第一個 select 的時候,而不是事務開始的時候。右邊的例子中,事務1在事務2提交了修改後才執行第一個 select,所以生成的 ReadView 中,a 的是 100 而不是事務1剛開始時的 50。

例子2:RR 和 RC 生成 ReadView 的區別

解析:RR 級別只在事務第一次 select 時生成一次,以後一直使用該 ReadView。而 RC 級別則在每次 select 時,都會生成一個 ReadView,因此 在第二次 select 時,讀取到了事務2對於 a 的修改值。

最後

MySQL 的源碼主要是 c++ 寫的,所以本身看起來比較吃力,花了挺多時間學習整理的。若是你能掌握本文的內容,面試 Java 崗位,不管是哪一個公司,相信都能讓面試官眼前一亮。

如今互聯網的競爭愈來愈激烈,若是不少東西都只停留在表面,很難取得面試官的「芳心」,只有在適當的時候亮出本身的「長劍」,才能在衆多候選人中凸顯出本身的不同凡響。你須要向面試官證實,爲何是你而不是其餘人。

推薦閱讀

921天,從小廠到入職阿里

兩年Java開發工做經驗面試總結

4 年 Java 經驗,阿里網易拼多多面試總結、心得體會

5 年 Java 經驗,字節、美團、快手核心部門面試總結(真題解析)

面試阿里,HashMap 這一篇就夠了

面試必問的CAS,你懂了嗎?

複習2個月拿下美團offer,我都作了些啥

面試必問的線程池,你懂了嗎?

面試必問的 MySQL,你懂了嗎?

面試必問的 Spring,你懂了嗎?

做者:程序員囧輝
連接:juejin.cn/post/694990… 來源:掘金 著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。

相關文章
相關標籤/搜索