Innodb 鎖子系統

請給我的愛人一杯mojito發表於2020-12-16

背景

寫在前面
本篇摘錄自,資料庫核心月報 - 2017 / 12《MySQL · 引擎特性 · Innodb 鎖子系統淺析》
link

record_lock_type 對於 LOCK_TABLE 型別來說都是空的,對於 LOCK_REC 目前值有:

#define LOCK_WAIT   256		/* 表示正在等待鎖 */
#define LOCK_ORDINARY 0 	/* 表示 next-key lock ,鎖住記錄本身和記錄之前的 gap*/
#define LOCK_GAP    512		/* 表示鎖住記錄之前 gap(不鎖記錄本身) */
#define LOCK_REC_NOT_GAP 1024	/* 表示鎖住記錄本身,不鎖記錄前面的 gap */
#define LOCK_INSERT_INTENTION 2048	/* 插入意向鎖 */
#define LOCK_CONV_BY_OTHER 4096		/* 表示鎖是由其它事務建立的(比如隱式鎖轉換) */

使用位操作來設定和判斷是否設定了對應的值。
在這裡插入圖片描述

加鎖分析

對於行資料的加鎖是由函式 lock_rec_lock 完成,簡單點來看,主要的引數是 mode(鎖型別),block(包含該行的 buffer 資料頁),heap_no(具體哪一行)。就可以確定加什麼樣的鎖,以及在哪一行加。對於 mode 的值,來源於查詢的邏輯,索引和二級索引的定義,隔離級別等等。

lock fast

lock_rec_lock 首先走 lock_rec_lock_fast 邏輯,判斷能否快速完成加鎖。如果對應 block 上面一個鎖都沒有( lock_rec_get_first_on_page(block)==NULL ),那麼就建立一個鎖( lock_rec_create ),返回加鎖成功。如果 block 上已經存在鎖,滿足下面程式碼的邏輯就返回 LOCK_REC_FAIL, 快速加鎖失敗。

if (lock_rec_get_next_on_page(lock)  /* 頁上是否只有一個鎖 */
        || lock->trx != trx  /* 擁有鎖的事務不是當前事務 */
        || lock->type_mode != (mode | LOCK_REC)/* 已有鎖和要加的鎖模式是否相同 */
        || lock_rec_get_n_bits(lock) <= heap_no) { /* 已有鎖的 n_bits 是否滿足 heap_no */
            status = LOCK_REC_FAIL;
}else if (!impl) {
       /* If the nth bit of the record lock is already set
       then we do not set a new lock bit, otherwise we do
	   set */
       if (!lock_rec_get_nth_bit(lock, heap_no)) {
           lock_rec_set_nth_bit(lock, heap_no);
     	   status = LOCK_REC_SUCCESS_CREATED;
    }

如果上述條件都為 false,說明:
page 上只有一個鎖
擁有該鎖的事務是當前事務
鎖模式相同
n_bits 也足夠描述大小為 heap_no 的行
那麼只需要設定一下 bitmap 就可以了(impl 表示加隱式鎖,其實也就是不加鎖)。

注:上述函式 lock_rec_get_first_on_page(block) 是從全域性 Lock_sys->hash 中拿到第一個鎖的,也就是 Hash 桶的第一個 node。

lock slow

lock fast 邏輯失敗後就會走 lock slow 邏輯,也就是上述 lock fast 判斷的
四個條件中有一個或多個為 true的時候。

lock slow 首先判斷當前事務上是否已經加了同等級或者更強級別的鎖,函式 lock_rec_has_expl,迴圈取出對應行上的所有鎖,它們要滿足以下幾個條件,就認為行上有更強的鎖。

1.基本鎖型別更強,就比如加了 LOCK_X 就不必要加 LOCK_S 了。lock_mode 基本鎖型別之間的強弱關係使用 lock_strength_matrix 判斷(lock_mode_stronger_or_eq)

  	static const byte lock_strength_matrix[5][5] = {
 	/**         IS     IX       S     X       AI */
 	/* IS */ {  TRUE,  FALSE, FALSE,  FALSE, FALSE},
 	/* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE},
 	/* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
 	/* X  */ {  TRUE,  TRUE,  TRUE,  TRUE,   TRUE},
 	/* AI */ {  FALSE, FALSE, FALSE, FALSE,  TRUE}
 };

2.不是插入意向鎖。
3.沒有等待,LOCK_WAIT 位為0
4.LOCK_REC_NOT_GAP 位為0。(沒有這個標記預設就是 NEXT KEY LOCK,鎖住行前面的gap) 或者 要加鎖的 LOCK_REC_NOT_GAP 位為 1 或者 當前行為 PAGE_HEAD_NO_SUPREMUM, 表示上界。
5.LOCK_GAP 位為0 或者 要加鎖的 LOCK_GAP 為 1 或者 當前行為 PAGE_HEAD_NO_SUPREMUM, 表示上界。

如果沒有更強級別的鎖,就要進行鎖衝突判斷,如果有鎖衝突就需要入佇列等待,並且還要進行死鎖檢測。衝突判斷呼叫函式 lock_rec_other_has_conflicting,迴圈的拿出對應行上的每一個鎖,呼叫 lock_rec_has_to_wait 進行衝突判斷。

以下描述 “鎖” 表示迴圈拿出的每個鎖,“當前鎖” 表示要加的鎖。

如果鎖和當前鎖是相同的事務,返回 false,不需要等待。
如果鎖和當前鎖的基本鎖型別相容,返回 false,不需要等待。相容性根據鎖的相容矩陣判斷。相容矩陣:

  static const byte lock_compatibility_matrix[5][5] = {
 	/**         IS     IX       S     X       AI */
 	/* IS */ {  TRUE,  TRUE,  TRUE,  FALSE,  TRUE},
 	/* IX */ {  TRUE,  TRUE,  FALSE, FALSE,  TRUE},
 	/* S  */ {  TRUE,  FALSE, TRUE,  FALSE,  FALSE},
 	/* X  */ {  FALSE, FALSE, FALSE, FALSE,  FALSE},
 	/* AI */ {  TRUE,  TRUE,  FALSE, FALSE,  FALSE}
  };

如果上述兩條都不滿足,不是相同的事務,基本鎖型別也不相容,那麼滿足下面任意一條,同樣返回false,不需要等待,否則返回 true,需要等待。

如果當前鎖鎖住的是 supremum 或者 LOCK_GAP 為 1 並且 LOCK_INSERT_INTENTION 為 0。因為不帶 LOCK_INSERT_INTENTION 的 GAP 鎖不需要等待任何東西,不同的使用者可以在 gap 上持有衝突的鎖。

如果當前鎖 LOCK_INSERT_INTENTION 為 0 並且鎖是 LOCK_GAP 為 1。因為行鎖(LOCK_ORDINARY LOCK_REC_NOT_GAP)不需要等待一個 gap 鎖。

如果當前鎖 LOCK_GAP 為 1,鎖 LOCK_REC_NOT_GAP 為 1。同樣的,因為 gap 鎖沒有必要等待一個 LOCK_REC_NOT_GAP 鎖。

如果鎖 LOCK_INSERT_INTENTION 為 1。此處是最後一步,說明之前的條件都不滿足,原始碼中備註描述如下:

No lock request needs to wait for an insertintention 
lock to be removed. This is ok since our rules allow conflicting 
locks on gaps. This eliminates a spurious deadlock 
caused by a next-key lock waiting for an insert
 intention lock; when the insert intention lock was 
 granted, the insert deadlocked on the waiting next-key 
 lock. Also, insert intention locks 
 do not disturb eachother.

舉個簡單的例子,如果一行資料上已經加了 LOCK_S | LOCK_REC_NOT_GAP, 再嘗試去加 LOCK_X | LOCK_GAP,LOCK_S 和 LOCK_X 本身是衝突的,但是滿足上述第 3 個條件,返回 FALSE,不需要等待。

如果行資料上沒有更強級別的鎖,也沒有衝突的鎖,並且加的不是隱式鎖,就呼叫 lock_rec_add_to_queue。核心思想是複用鎖物件,如果要加鎖的行資料上當前沒有其它鎖等待,並且行所在的資料頁上有相似的鎖物件(lock_rec_find_similar_on_page)就可以直接設定對應行的 bitmap 位,表示加鎖成功。如果有其它鎖等待,就重新建立一個鎖物件。

死鎖檢測

死鎖檢測的入口函式是 lock_deadlock_check_and_resolve,演算法是深度優先搜尋,如果在搜尋過程中發現有環,就說明發生了死鎖,為了避免死鎖檢測開銷過大,如果搜尋深度超過了 200(LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK)也同樣認為發生了死鎖。

MySQL 5.7 之後增加了更多物件導向的程式碼結構,但是實際演算法並沒有改變。

稍早版本的時候,Innodb 使用的是遞迴方式搜尋,為了減少棧空間的開銷,改為使用入棧的方式。

兩個輔助資料結構:

/** Deadlock check context. */
struct lock_deadlock_ctx_t {
    const trx_t*    start;      /*!< Joining transaction that is
                    requesting a lock in an incompatible
                    mode */

    const lock_t*   wait_lock;  /*!< Lock that trx wants */

    ib_uint64_t mark_start; /*!<  Value of lock_mark_count at
                    the start of the deadlock check. */

    ulint       depth;      /*!< Stack depth */

    ulint       cost;       /*!< Calculation steps thus far */

    ibool       too_deep;   /*!< TRUE if search was too deep and
                    was aborted */
};

/** DFS visited node information used during deadlock checking. */
struct lock_stack_t {
    const lock_t*   lock;           /*!< Current lock */
    const lock_t*   wait_lock;      /*!< Waiting for lock */
    ulint       heap_no;        /*!< heap number if rec lock */
};


lock_stack_t 就是輔助的棧結構,使用一個 lock_stack_t 型別的陣列來作為資料棧,初始化在建立 Lock_sys 的時候,大小為 LOCK_STACK_SIZE, 實際上是 srv_max_n_thread,最大的執行緒數。

lock_deadlock_ctx_t 中的 start 始終保持不變,是第一個請求鎖的事務,如果深度搜尋過程中鎖對應的事務等於 start,那麼就說明產生了環,發生死鎖。wait_lock 表示搜尋中的事務等待的鎖。

在這裡插入圖片描述
舉個簡單的例子:

有三個事務 A,B,C 已經獲得了三行資料 1,2,3 上的 X 鎖。現在事務 A 去拿資料 2 的 X 鎖,阻塞等待。同樣事務 B 也去拿資料 3 的 X 鎖,同樣阻塞等待。當事務 C 嘗試去拿 資料 1 的 X 鎖時,發生死鎖。看下此時的死鎖檢測流程:

1.ctx 中的 start 初始化為 C,wait_lock 初始化為 X1(資料1上的X鎖)

2.根據 wait_lock=X1,呼叫函式 lock_get_first_lock 拿到加在資料 1 上的第一個鎖 lock。在例子中就是事務 A 已經獲得的 X1 鎖。

3.然後判斷 lock 對應的事務(A)是否也在等待其它鎖:lock->trx->lock.que_state == TRX_QUE_LOCK_WAIT。當前事務 A 確實在等待 X2 鎖。所以為 true,把當前的 lock 入棧(lock_dead_lock_push)。

4.ctx 中的 wait_lock 更新為 lock->trx->lock.wait_lock, 也就是 X1 鎖的持有者事務 A 所等待的鎖 X2。

5.同步驟 2 ,根據 wait_lock=X2, 拿到加在資料 2 上的第一個鎖賦值給 lock,也就是事務 B 持有的 X2 鎖。完成一次迴圈。

6.再次進入迴圈,lock 對應的事務(B)同樣在等待其它鎖,所以把當前的 lock 入棧。

7.ctx 中的 wait_lock 更新為 lock->trx->lock.wait_lock, 也就是 X2 鎖持有者事務 B 所等待的鎖 X3。

8.同步驟 2,根據 wait_lock=X3, 拿到加在資料 3 上的第一個鎖賦值給 lock,也就是事務 C 持有的 X3 鎖。完成一次迴圈。

9.再次進入迴圈,此時 lock->trx = C = ctx->start。死鎖形成。

上述例子較為簡單,沒有涉及到一行資料上有多個鎖,也沒有出棧操作,一次深度遍歷就找到了死鎖,實際情況會複雜點,其它分支可以參看原始碼理解。

show variables like 'innodb_deadlock_detect';#死鎖檢查開關

victim 選擇

當發生死鎖後,會選擇一個代價較少的事務進行回滾操作,選擇函式:lock_deadlock_select_victim(ctx)。Innodb 中的 victim 選擇比較粗暴,不論死鎖鏈條有多長,只會在 ctx->start 和 ctx->wait_lock->trx 二者中選擇其一。對應上述例子,就是在事務 B 和事務 C 中選擇。

具體的權重比較函式是 trx_weight_ge, 如果一個事務修改了不支援事務的表,那麼認為它的權重較高,否則認為 undo log 數加持有的鎖數之和較大的權重較高。

死鎖資訊分析

當發生死鎖之後,會呼叫 lock_deadlock_notify 寫入死鎖資訊,SHOW ENGINE INNODB STATUS 語句可以看到最近一次發生的死鎖資訊,因為死鎖資訊是預設寫到 temp 目錄的臨時檔案中,每次發生死鎖都會覆蓋寫。如果開啟 innodb_print_all_deadlocks可以把歷史所有的死鎖資訊列印到 errlog 中。

關於列印出來的內容具體含義有文章已經講的比較清楚了:mysql lover 和 percona。其中推薦 percona 的文章,其實發生死鎖後想找出原因的話,只有死鎖資訊是不夠的,因為 1.只顯示最近兩條事務的資訊 2.只顯示事務最近執行的一條語句。如文中推薦的做法,配合 general log 和 binlog 進行排查。
link

SHOW ENGINE INNODB STATUS 語句可以看到最近一次發生的死鎖資訊

配合 general log 和 binlog 進行排查。

鎖等待以及喚醒

鎖的等待以及喚醒實際上是執行緒的等待和喚醒,呼叫函式 lock_wait_suspend_thread 掛起當前執行緒,配合 OS_EVENT 機制,實現喚醒和鎖超時等功能

Test Case 實踐

在完成一個鎖相關 patch 的時候發現 test case 中比較詭異的點,在執行應該產生死鎖的語句時,不是每次都會產生死鎖,也會發生鎖超時的情況。以死鎖檢測中描述的例子,test case 如下:

--disable_warnings
DROP TABLE IF EXISTS t1;
--enable_warnings
create table t1(a int primary key, b int) engine=innodb;
insert into t1 values(1,1),(2,2),(3,3),(4,4),(5,5),(6,6),(7,7),(8,8),(9,9);
# ************* 3 Transactions cause deadlock. **************** #
# Hold locks
connection con1;
begin;
select * from t1 where a=1 for update;
connection con2;
begin;
select * from t1 where a=2 for update;
connection con3;
begin;
select * from t1 where a=3 for update;

# Request locks as a circle
connection con1;
insert into t1 values(11,11);
send select * from t1 where a=2 for update;
connection con2;
insert into t1 values(12,12);
send select * from t1 where a=3 for update;
connection con3;
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;


其中插入語句是為了產生 undo log,控制那一個事務會被選為 victim。上述 test case 預期產生死鎖的語句有時會報鎖超時,也就是沒有正確發生死鎖。起初以為是 victim 選擇演算法的原因,後來才發現是因為 send 語句,它只保證語句發出去,並不保證執行完畢,所以在最後一條 select 語句執行的時候也許前面的語句還沒執行完,無法產生死鎖。

使用 wait condition 語句等待下就沒問題了:

let $wait_condition=
  SELECT COUNT(*) = 2 FROM information_schema.innodb_trx
  WHERE trx_operation_state = 'starting index read' AND
  trx_state = 'LOCK WAIT';
--source include/wait_condition.inc
--error ER_LOCK_DEADLOCK
select * from t1 where a=1 for update;

總結

Innodb 的鎖系統實際上是封裝了一層邏輯,和行本身資料一點關係也沒有,瞭解之前以為會像檔案鎖一樣,鎖的粒度越小,維護起來越複雜,所以開頭提到的 Berkeley DB 才只有頁鎖,瞭解之後很迷惑為什麼不支援行鎖… 區分一下 Innodb 同步機制使用的鎖和本文介紹的鎖是不同的,可以參考這篇月報, 有一個最不可思議的死鎖問題,就是這兩種鎖之間切換導致的。鎖系統作為事務中一個重要模組,需要配合其它模組,對於事務系統可以參考本期月報這篇文章。

MySQL · 引擎特性 · InnoDB 同步機制; MySQL · 引擎特性 · InnoDB 事務系統

link
link

本文說明,主要技術內容來自網際網路技術大佬的分享,還有一些自我的加工(僅僅起到註釋說明的作用)。如有相關疑問,請留言,將確認之後,執行侵權必刪

相關文章