以前在 面試必問的 MySQL,你懂了嗎? 中簡單的介紹了 MVCC 的原理,掌握了這個原理其實在面試時是能夠加分很多的。html
由於如今不少人的理解仍是停留在《高性能 MySQL》書中的版本,也就是經過建立版本號和刪除版本號來判斷。這個時候若是你能給出正確的理解,則會讓面試官眼前一亮,這也是咱們在面試中凸顯出「本身和其餘候選者不同的地方」,會更有利於在衆多候選者中脫穎而出。mysql
本文在此基礎上,對 MVCC 展開詳細的分析,同時修改了以前的一些不太準確的說法,但願能夠助你在面試中更好的發(zhuang)揮(bi)。c++
PS:本文的源碼基於MySQL 8.0.16,對於現階段生產環境經常使用的 5.7.* 版本,MVCC 部分的源碼基本相同,所以能夠放心參考。而 5.6.* 則有比較大的不一樣,主要是一些數據結構都改變了,可是究其核心原理仍是基本一致的。程序員
髒讀:一個事務讀取到另外一個事務更新但還未提交的數據,若是另外一個事務出現回滾或者進一步更新,則會出現問題。面試
不可重複讀:在一個事務中兩次次讀取同一個數據時,因爲在兩次讀取之間,另外一個事務修改了該數據,因此出現兩次讀取的結果不一致。sql
幻讀:在一個事務中使用相同的 SQL 兩次讀取,第二次讀取到了其餘事務新插入的行。數據庫
要解決這些併發事務帶來的問題,一個比較簡單粗暴的方法是加鎖,可是加鎖必然會帶來性能的下降,所以 MySQL 使用了 MVCC 來提高併發事務下的性能。安全
試想,若是沒有 MVCC,爲了保證併發事務的安全,一個比較容易想到的辦法就是加讀寫鎖,實現:讀讀不衝突、讀寫衝突、寫讀衝突,寫寫衝突,在這種狀況下,併發讀寫的性能必然會收到嚴重影響。markdown
而經過 MVCC,咱們能夠作到讀寫之間不衝突,咱們讀的時候只須要將當前記錄拷貝一份到內存中(ReadView),以後該事務的查詢就只跟 ReadView 打交道,不影響其餘事務對該記錄的寫操做。數據結構
讀未提交(Read Uncommitted):最低的隔離級別,會讀取到其餘事務還未提交的內容,存在髒讀。
讀已提交(Read Committed):讀取到的內容都是已經提交的,能夠解決髒讀,可是存在不可重複讀。
可重複讀(Repeatable Read):在一個事務中屢次讀取時看到相同的內容,能夠解決不可重複讀,可是存在幻讀。可是在 InnoDB 中不存在幻讀問題,對於快照讀,InnoDB 使用 MVCC 解決幻讀,對於當前讀,InnoDB 經過 gap locks 或 next-key locks 解決幻讀。
串行化(Serializable):最高的隔離級別,串行的執行事務,沒有併發事務問題。
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 會進行以下操做:
加鎖:對要更新的行記錄加排他鎖
寫 undo log:將更新前的記錄寫入 undo log,並構建指向該 undo log 的回滾指針 roll_ptr
更新行記錄:更新行記錄的 DB_TRX_ID 屬性爲當前的事務Id,更新 DB_ROLL_PTR 屬性爲步驟2生成的回滾指針,將這次要更新的屬性列更新爲目標值
寫 redo log:DB_ROLL_PTR 使用步驟2生成的回滾指針,DB_TRX_ID 使用當前的事務Id,並填充更新後的屬性值
處理結束,釋放排他鎖
刪除操做:在底層實現中是使用更新來實現的,邏輯基本和更新操做同樣,幾個須要注意的點: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 方法,核心部分以下圖。
當咱們的隔離級別爲 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,這樣在訪問某條記錄時,只須要按照下邊的步驟判斷記錄的某個版本是否可見:
若是被訪問版本的 trx_id 與 ReadView 中的 m_creator_trx_id 值相同,意味着當前事務在訪問它本身修改過的記錄,因此該版本能夠被當前事務訪問。
若是被訪問版本的 trx_id 小於 ReadView 中的 m_up_limit_id(低水位),代表被訪問版本的事務在當前事務生成 ReadView 前已經提交,因此該版本能夠被當前事務訪問。
若是被訪問版本的 trx_id 大於等於 ReadView 中的 m_low_limit_id(高水位),代表被訪問版本的事務在當前事務生成 ReadView 後纔開啓,因此該版本不能夠被當前事務訪問。
若是被訪問版本的 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 來實現,核心流程以下:
獲取記錄的回滾指針 DB_ROLL_PTR、獲取記錄的事務Id
經過回滾指針拿到對應的 undo log
解析 undo log,並使用 undo log 構建用於更新向量 UPDATE
構建記錄的上一個版本:先用記錄的當前版本填充,而後使用 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,源碼以下:
面試必問的 MySQL,你懂了嗎? 只分析了走聚簇索引的狀況,本文簡單的介紹下走普通(二級)索引的狀況。
當走普通索引時,判斷邏輯以下:
判斷被訪問索引記錄所在頁的最大事務 Id 是否小於 ReadView 中的 m_up_limit_id(低水位),若是是則表明該頁的最後一次修改事務 Id 在 ReadView 建立前之前已經提交,則必然能夠訪問;若是不是,並不表明必定不能夠訪問,道理跟走聚簇索引同樣,事務 Id 大的也可能提交比較早,因此須要作進一步判斷,見步驟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 是 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 由於由於從 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 的數據,從而防止了幻讀。
SQL 標準中規定的 RR 並不能消除幻讀,可是 MySQL InnoDB 的 RR 能夠,靠的就是 Gap 鎖。在 RR 級別下,Gap 鎖是默認開啓的,而在 RC 級別下,Gap 鎖是關閉的。
解析:RR 生成 ReadView 的時機是事務第一個 select 的時候,而不是事務開始的時候。右邊的例子中,事務1在事務2提交了修改後才執行第一個 select,所以生成的 ReadView 中,a 的是 100 而不是事務1剛開始時的 50。
解析:RR 級別只在事務第一次 select 時生成一次,以後一直使用該 ReadView。而 RC 級別則在每次 select 時,都會生成一個 ReadView,因此 在第二次 select 時,讀取到了事務2對於 a 的修改值。
MySQL 的源碼主要是 c++ 寫的,所以本身看起來比較吃力,花了挺多時間學習整理的。若是你能掌握本文的內容,面試 Java 崗位,不管是哪一個公司,相信都能讓面試官眼前一亮。
如今互聯網的競爭愈來愈激烈,若是不少東西都只停留在表面,很難取得面試官的「芳心」,只有在適當的時候亮出本身的「長劍」,才能在衆多候選人中凸顯出本身的不同凡響。你須要向面試官證實,爲何是你而不是其餘人。
5 年 Java 經驗,字節、美團、快手核心部門面試總結(真題解析)
做者:程序員囧輝
連接:juejin.cn/post/694990… 來源:掘金 著做權歸做者全部。商業轉載請聯繫做者得到受權,非商業轉載請註明出處。