MySQL MVCC 原理機制


title: MySQL MVCC 原理機制
date: 2019-04-17 23:37:00
tags:

  • MySQL
  • MVCC
  • undo log

MySQL MVCC 原理機制

什麼是 MVCC

MVCC (Multiversion Concurrency Control) 中文全程叫多版本併發控制,是現代數據庫(包括 MySQLOraclePostgreSQL 等)引擎實現中常用的處理讀寫衝突的手段,目的在於提高數據庫高併發場景下的吞吐性能

如此一來不同的事務在併發過程中,SELECT 操作可以不加鎖而是通過 MVCC 機制讀取指定的版本歷史記錄,並通過一些手段保證保證讀取的記錄值符合事務所處的隔離級別,從而解決併發場景下的讀寫衝突。

InnoDB的MVCC實現

在InnoDB中,主要是通過使用readview的技術來實現判斷。查詢出來的每一行記錄,都會用readview來判斷一下當前這行是否可以被當前事務看到,如果可以,則輸出,否則就利用undolog來構建歷史版本,再進行判斷,知道記錄構建到最老的版本或者可見性條件滿足。

InnoDB的多版本並不是直接存儲多個版本的數據,而是所有更改操作利用行鎖做併發控制,這樣對某一行的更新操作是串行化的,然後用Undo log記錄串行化的結果。當快照讀的時候,利用Undo log重建需要讀取版本的數據,從而實現讀寫併發。

相關概念:

InnoDB行數據三個隱藏字段

事務ID(DB_TRX_ID)

表示最近一次插入或者更新該記錄的事務ID

回滾指針(DB_ROLL_PTR)

指向該記錄的回滾段rollback segment的undo log記錄,InnoDB 便是通過這個指針找到之前版本的數據。該行記錄上所有舊版本,在 undo 中都通過鏈表的形式組織

DB_ROW_ID

自動遞增,當表上沒有用戶主鍵的時候,InnoDB會自動產生聚集索引

另外,每條記錄的頭信息(record header)裏都有一個專門的 bitdeleted_flag)來表示當前記錄是否已經被刪除

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-ePu3Ss1P-1587110222899)(https://i.imgur.com/GOStr4h.png)]

Undo log

如圖所示,undo log中記錄之前修改該行數據的事務ID以及被修改的歷史數據

整個MVCC的機制都是通過DB_TRX_ID,DB_ROLL_PTR這2個隱藏字段來實現的

執行過程
  • 用排他鎖鎖定該行
  • 記錄redo log
  • 記錄undo log
  • 修改當前行的值,修改行的事務ID爲當前事務ID
  • 回滾指針指向undo log剛剛記錄的位置

ReadView (MySQL中等同 快照、snapshot)

在事務開始的時候會根據上面的事務鏈表構造一個ReadView,初始化方法如下

Read View

// readview 初始化
// m_low_limit_id = trx_sys->max_trx_id; 
// m_up_limit_id = !m_ids.empty() ? m_ids.front() : m_low_limit_id;
ReadView::ReadView()
    :
    m_low_limit_id(),
    m_up_limit_id(),
    m_creator_trx_id(),
    m_ids(),
    m_low_limit_no()
{
    ut_d(::memset(&m_view_list, 0x0, sizeof(m_view_list)));
}
  • ReadView主要結構
    • m_low_limit_id。 事務ID大於等於該值的數據修改不可見
    • m_up_limit_id. 事務ID小於該值的數據修改可見。
    • m_creator_trx_id。創建該ReadView的事務,該事務ID的數據修改可見。
    • m_ids。當快照創建時的活躍讀寫事務列表。
    • m_low_limit_no。事務number,上一節介紹Undo log時候,事務提交時候獲取同時寫入Undo log中的值。事務number小於該值的對該ReadView不可見。利用該信息可以Purge不需要的Undo。
    • m_closed。 標記該ReadView closed,用於優化減少trx_sys->mutex這把大鎖的使用。

可見性判斷

設要讀取的行的最後提交事務id(即當前數據行的穩定事務id)爲 trx_id_current
當前新開事務id爲 new_id
當前新開事務創建的快照read view 中最早的事務id爲up_limit_id, 最遲的事務id爲low_limit_id(注意這個low_limit_id=未開啓的事務id=當前最大事務id+1)
比較:

  • 1.trx_id_current < up_limit_id, 這種情況比較好理解, 表示, 新事務在讀取該行記錄時, 該行記錄的穩定事務ID是小於, 系統當前所有活躍的事務, 所以當前行穩定數據對新事務可見, 跳到步驟5.
  • 2.trx_id_current >= trx_id_last, 這種情況也比較好理解, 表示, 該行記錄的穩定事務id是在本次新事務創建之後纔開啓的, 但是卻在本次新事務執行第二個select前就commit了,所以該行記錄的當前值不可見, 跳到步驟4。
  • 3.trx_id_current <= trx_id_current <= trx_id_last, 表示: 該行記錄所在事務在本次新事務創建的時候處於活動狀態,從up_limit_id到low_limit_id進行遍歷,如果trx_id_current等於他們之中的某個事務id的話,那麼不可見, 調到步驟4,否則表示可見。
  • 4.從該行記錄的 DB_ROLL_PTR 指針所指向的回滾段中取出最新的undo-log的版本號, 將它賦值該 trx_id_current,然後跳到步驟1重新開始判斷。
  • 5.將該可見行的值返回。
// 判斷數據對應的聚簇索引中的事務id在這個readview中是否可見
bool changes_visible(
        trx_id_t        id, // 當前記錄行的穩定事務id
        const table_name_t& name) const
        MY_ATTRIBUTE((warn_unused_result))
    {
        ut_ad(id > 0);
    	//如果記錄上的trx_id小於read_view_t->up_limit_id,則說明這條記錄的最後修改在readview創建之前,因此這條記錄可以被看見。
        // 或者當前記錄trx_id等於創建該readview的id就是它自己,那麼也是可見的
        if (id < m_up_limit_id || id == m_creator_trx_id) {
            return(true);
        }

        check_trx_id_sanity(id, name);
        // 如果記錄上的trx_id大於等於read_view_t->low_limit_id,即大於事務鏈表中的最大值,則說明這條記錄的最後修改在readview創建之後,那麼不可見
        if (id >= m_low_limit_id) {

            return(false);
        // 如果事務鏈表是空的,那也是可見的
        } else if (m_ids.empty()) {

            return(true);
        }

        const ids_t::value_type*    p = m_ids.data();
    	//如果記錄上的trx_id在up_limit_id和low_limit_id之間,且trx_id在read_view_t->descriptors之中,則表示這條記錄的最後修改是在readview創建之時,被另外一個活躍事務所修改,所以這條記錄也不可以被看見,需要查找 Undo Log 鏈得到上一個版本,然後根據該版本的 DB_TRX_ID 再從頭計算一次可見性;如果不在,說明創建 ReadView 時生成該版本的事務已經被提交,該版本可以被訪問。如果trx_id不在read_view_t->descriptors之中,則表示這條記錄的最後修改在readview創建之前,所以可以看到。
        return(!std::binary_search(p, p + m_ids.size(), id));
    }

img

read view快照的生成時機

正是因爲生成時機的不同, 造成了RC,RR兩種隔離級別的不同可見性;

  • 在innodb中(默認repeatable read級別), 事務在begin/start transaction之後的第一條select讀操作後, 會創建一個快照(read view), 將當前系統中活躍的其他事務記錄記錄起來;事務執行過程中,數據的可見性不會變,所以在事務內部不會出現不一致的情況。
  • 在innodb中(默認repeatable committed級別), 事務中每條select語句都會創建一個快照(read view);

多版本控制MVCC

數據庫需要做好版本控制,防止不該被事務看到的數據(例如還沒提交的事務修改的數據)被看到。在InnoDB中,主要是通過使用readview的技術來實現判斷。查詢出來的每一行記錄,都會用readview來判斷一下當前這行是否可以被當前事務看到,如果可以,則輸出,否則就利用undolog來構建歷史版本,再進行判斷,知道記錄構建到最老的版本或者可見性條件滿足。

在trx_sys中,一直維護這一個全局的活躍的讀寫事務id(trx_sys->descriptors),id按照從小到大排序,表示在某個時間點,數據庫中所有的活躍(已經開始但還沒提交)的讀寫(必須是讀寫事務,只讀事務不包含在內)事務。當需要一個一致性讀的時候(即創建新的readview時),會把全局讀寫事務id拷貝一份到readview本地(read_view_t->descriptors),當做當前事務的快照。read_view_t->up_limit_id是read_view_t->descriptors這數組中最小的值,read_view_t->low_limit_id是創建readview時的max_trx_id,即一定大於read_view_t->descriptors中的最大值。當查詢出一條記錄後(記錄上有一個trx_id,表示這條記錄最後被修改時的事務id),可見性判斷的邏輯如下(lock_clust_rec_cons_read_sees):

如果記錄上的trx_id小於read_view_t->up_limit_id,則說明這條記錄的最後修改在readview創建之前,因此這條記錄可以被看見。

如果記錄上的trx_id大於等於read_view_t->low_limit_id,則說明這條記錄的最後修改在readview創建之後,因此這條記錄肯定不可以被看家。

如果記錄上的trx_id在up_limit_id和low_limit_id之間,且trx_id在read_view_t->descriptors之中,則表示這條記錄的最後修改是在readview創建之時,被另外一個活躍事務所修改,所以這條記錄也不可以被看見。如果trx_id不在read_view_t->descriptors之中,則表示這條記錄的最後修改在readview創建之前,所以可以看到。

基於上述判斷,如果記錄不可見,則嘗試使用undo去構建老的版本(row_vers_build_for_consistent_read),直到找到可以被看見的記錄或者解析完所有的undo。

針對RR隔離級別,在第一次創建readview後,這個readview就會一直持續到事務結束,也就是說在事務執行過程中,數據的可見性不會變,所以在事務內部不會出現不一致的情況。針對RC隔離級別,事務中的每個查詢語句都單獨構建一個readview,所以如果兩個查詢之間有事務提交了,兩個查詢讀出來的結果就不一樣。從這裏可以看出,在InnoDB中,RR隔離級別的效率是比RC隔離級別的高。此外,針對RU隔離級別,由於不會去檢查可見性,所以在一條SQL中也會讀到不一致的數據。針對串行化隔離級別,InnoDB是通過鎖機制來實現的,而不是通過多版本控制的機制,所以性能很差。

由於readview的創建涉及到拷貝全局活躍讀寫事務id,所以需要加上trx_sys->mutex這把大鎖,爲了減少其對性能的影響,關於readview有很多優化。例如,如果前後兩個查詢之間,沒有產生新的讀寫事務,那麼前一個查詢創建的readview是可以被後一個查詢複用的。

案例

事務對某行記錄的簡化更新過程:

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-Wnvzcj9x-1587110222912)(https://i.imgur.com/MLFnrkq.png)]

[外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-52ExnBg9-1587110222915)(https://i.imgur.com/vtr5itr.png)]

Purge清理操作

舊版本數據不再被任何view訪問就可以被刪除了。5.6以上版本支持獨立purge線程,用戶可以通過參數Innodb_purge_threads設置purge線程個數。

有兩類purge線程:

  • coordinator thread:srv_purge_coordinator_thread,全局只有1個
  • worker thread:srv_worker_thread,系統有innodb_purge_threads - 1個

coordinator thread負責啓動worker thread參與到purge工作中。

增加purge線程的策略是:trx_sys->rseg_history_len比上次循環變大了或者rseg_history_len超過某一閾值,需要引進更多的worker thread。

減少purge線程的策略是:如果之前使用多個purge 線程,trx_sys->rseg_history_len並沒有變大,可能需要減少worker thread。

在進行purge之前,首先要確定purge線程要做哪些工作,也就是說哪些undo log可以被purged。

purge也是通過read view來確定工作範圍,被稱爲purge view。如果系統有活躍read view,就選取最老的read view作爲purge view。

如果不存在就給trx_sys的狀態打個snapshot,作爲purge view,可以被purge的undo log其trx_no一定是小於系統中所有已提交事務的trx->no。

這裏插一句,在事務commit時,會把產生的trx->no加入到trx_sys->serialisation_list鏈表,這個鏈表是按照trx->no升序次序排列,也就是維護了trx commit順序。

InnoDB初始化的時候會初始化purge_sys數據結構,其中一個工作就是創建purge graph。

這是總共3層結構的圖:

  • 第1層是fork節點
  • 第2次是thrd節點(表示purge thread)
  • 第3層是node節點(表示purge task)

所有的thrd節點被鏈入到fork->thrs鏈表中;fork地址存儲在purge_sys->query,可以通過purge_sys直接訪問。

執行purge的時候總是遍歷purge_sys->query->thrs鏈表,給每個purge線程分配purge任務(trx_purge_attach_undo_recs)。

解析undo log的調用路徑如下:

srv_purge_coordinator_thread -> srv_do_purge -> trx_purge ->
        trx_purge_attach_undo_recs -> trx_purge_fetch_next_rec -> 
               trx_purge_get_next_rec

purge_sys->next_stored爲FALSE時,表示rseg_iter當前指向的rseg無效,需要把rseg_iter移到下一個有效的rseg(TrxUndoRsegsIterator::set_next)。

purge_sys->purge_queue維護了一個最小堆,每次pop最頂元素,可以得到trx_no最小的rollback segment(TrxUndoRsegsIterator::set_next)。

5.7支持臨時表的noredo的rollback segment,set_next遇到redo rollback segment和noredo rollback segment同時存在的情況會一股腦把這兩個rollback segment都pop出來加入到 purge_sys->rseg_iter->m_trx_undo_rsegs數組中,也在TrxUndoRsegsIterator::set_next實現。

如果沒有rollback segment需要purge話,purge_sys->rseg設置爲NULL,purge線程會去睡眠(trx_purge_choose_next_log)。

一般情況下都是有rollback segment需要處理的,purge_sys->rseg更新成purge_sys->rseg_iter->m_trx_undo_rsegs的第1項(至多2項)。

purge_sys中的相應成員也要更新,指向當前rseg上次purge到的位置(TrxUndoRsegsIterator::set_next)。

update undo的del_marks域正常情況下都是TRUE,因爲update/delete操作都需要對old value進行標記刪除。

如果purge_sys->rseg->last_del_marks是FALSE的話,表示這是一個dummy的undo log,不需要做物理刪除。這種情況下,把purge_sys->offset設置成0,做個標記表示這個undo log不需要被purged(trx_purge_read_undo_rec)。

正常情況下purge_sys->rseg->last_del_marks是TRUE,可以通過<purge_sys->rseg->space, purge_sys->hdr_page_no, purge_sys->hdr_offset>讀取undo log記錄(trx_purge_read_undo_rec)。

並把purge_sys以下四個域設置成undo log記錄相應的信息(trx_purge_read_undo_rec)。

    purge_sys->offset = offset; /* undo log記錄的offset */
    purge_sys->page_no = page_no; /* undo log記錄的pageno */
    purge_sys->iter.undo_no = undo_no; /* undo log記錄的undo_no,trx內部undo的序列號 */
    purge_sys->iter.undo_rseg_space = undo_rseg_space; /* undo log的tablespace */

爲了保證purge_sys以上4個域一定是指向下一個有效undo log,每次讀取undo log時都會捎帶着讀取下一個undo log,並把上面這四個域更新爲下一個undo log的信息,方面後續訪問(trx_purge_get_next_rec)。

如果是dummy undo,trx_purge_get_next_rec會去讀prev_undo(trx_purge_rseg_get_next_history_log),用prev_log信息更新rseg中下一個purge信息。

在此之後,還會把rseg->last_trx_no壓入最小堆,待後面繼續處理這個rseg。 然後調用trx_purge_choose_next_log選擇下一個處理的rseg,並讀取第一個undo log(trx_purge_get_next_rec)。

就這樣挨個讀取undo log,trx_purge_attach_undo_recs中有一個大循環,每次調用trx_purge_fetch_next_rec讀到一個undo log後,把它存放到purge節點(purge graph的第三級節點) node->undo_recs數組裏面,循環下一次執行切換到下一個thr(purge 線程)。

循環的結束條件是:

  • 沒有新的undo log
  • 處理過的undo log達到batch size(一般是300)

達到循環結束條件後,trx_purge_attach_undo_recs返回。如果n_purge_threads > 1 (需要worker線程參與purge),coordinator線程會以round-robin方式啓動n_purge_threads - 1個worker線程。

不管有沒有worker線程參與purge,coordinator線程都會調用que_run_threads(在trx_purge上下文)去處理purge任務。

purge任務如何處理呢?通俗的說purge就是刪除被標記delete marker的記錄項。

大致過程如下:

srv_purge_coordinator_thread -> srv_do_purge -> trx_purge ->
        que_run_threads -> que_run_threads_low -> que_thr_step
               row_purge_step -> row_purge -> row_purge_record ->
                       row_purge_del_mark -> row_purge_remove_sec_if_poss

一般刪除的原則是先刪除二級索引再刪除clustered索引(row_purge_del_mark)。

另一種情況是聚集索引in-place更新了,但二級索引上的記錄順序可能發生變化,而二級索引的更新總是標記刪除 + 插入,因此需要根據回滾段記錄去檢查二級索引記錄序是否發生變化,並執行清理操作(row_purge_upd_exist_or_extern)。

前面提到過在parse undo log時,可能遇到dummy undo log。返回到row_purge執行時需要判讀是否是dummy undo,如果是就什麼也不做。

truncate undo space

trx_purge在處理完一個batch(通常是300)之後,調用trx_purge_truncate_historypurge_sys對每一個rseg嘗試釋放undo log(trx_purge_truncate_rseg_history)。

大致過程是:把每個purge過的undo log從history list移除,如果undo segment中所有的undo log都被釋放,可以嘗試釋放undo segment,這裏隱式釋放file segment到達釋放存儲空間的目的。

思考

  1. 一般我們認爲MVCC有下面幾個特點:
    • 每行數據都存在一個版本,每次數據更新時都更新該版本
    • 修改時Copy出當前版本, 然後隨意修改,各個事務之間無干擾
    • 保存時比較版本號,如果成功(commit),則覆蓋原記錄, 失敗則放棄copy(rollback)
    • 就是每行都有版本號,保存時根據版本號決定是否成功,聽起來含有樂觀鎖的味道, 因爲這看起來正是,在提交的時候才能知道到底能否提交成功
  2. 而InnoDB實現MVCC的方式是:
    • 事務以排他鎖的形式修改原始數據
    • 把修改前的數據存放於undo log,通過回滾指針與主數據關聯
    • 修改成功(commit)啥都不做,失敗則恢復undo log中的數據(rollback)
  3. 二者最本質的區別是: 當修改數據時是否要排他鎖定,如果鎖定了還算不算是MVCC?
  • Innodb的實現真算不上MVCC, 因爲並沒有實現核心的多版本共存, undo log 中的內容只是串行化的結果, 記錄了多個事務的過程, 不屬於多版本共存。但理想的MVCC是難以實現的, 當事務僅修改一行記錄使用理想的MVCC模式是沒有問題的, 可以通過比較版本號進行回滾, 但當事務影響到多行數據時, 理想的MVCC就無能爲力了。
  • 比如, 如果事務A執行理想的MVCC, 修改Row1成功, 而修改Row2失敗, 此時需要回滾Row1, 但因爲Row1沒有被鎖定, 其數據可能又被事務B所修改, 如果此時回滾Row1的內容,則會破壞事務B的修改結果,導致事務B違反ACID。 這也正是所謂的 第一類更新丟失 的情況。
  • 也正是因爲InnoDB使用的MVCC中結合了排他鎖, 不是純的MVCC, 所以第一類更新丟失是不會出現了, 一般說更新丟失都是指第二類丟失更新。

參考資料:

MySQL · 源碼分析 · InnoDB的read view,回滾段和purge過程簡介:http://mysql.taobao.org/monthly/2018/03/01/

MySQL · 引擎特性 · InnoDB MVCC 相關實現:http://mysql.taobao.org/monthly/2018/11/04/

MySQL · 引擎特性 · InnoDB 事務系統:http://mysql.taobao.org/monthly/2017/12/01/

MVCC原理探究及MySQL源碼實現分析:https://blog.csdn.net/woqutechteam/article/details/68486652

碼分析 · InnoDB的read view,回滾段和purge過程簡介:http://mysql.taobao.org/monthly/2018/03/01/

MySQL · 引擎特性 · InnoDB MVCC 相關實現:http://mysql.taobao.org/monthly/2018/11/04/

MySQL · 引擎特性 · InnoDB 事務系統:http://mysql.taobao.org/monthly/2017/12/01/

MVCC原理探究及MySQL源碼實現分析:https://blog.csdn.net/woqutechteam/article/details/68486652

MySQL-InnoDB-MVCC多版本併發控制:https://segmentfault.com/a/1190000012650596

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