|InnoDB事務鎖系統及其實現

語言: CN / TW / HK

提示:公眾號展示程式碼會自動折行,建議橫屏閱讀

「第一部分 前言」

InnoDB引擎支援行級別鎖,實現了四種隔離級別,本文梳理了InnoDB事務系統及鎖系統的原理和原始碼實現,並且對其中一些比較特別的feature做一個簡單的介紹。

因為涉及的模組程式碼非常龐大,部分實現細節並未深入,如有錯漏,歡迎指正。

在介紹InnoDB的事務系統和鎖系統之前,有必要對一些基本概念做一個簡單的回顧。

我們都知道事務的四大屬性ACID,這些屬性的保證與資料庫中的幾大模組緊密的耦合在一起:

  • 為了保證原子性Atomicity,資料庫需要保證事務要麼都成功,要麼都不成功,因此需要記事務的undo日誌,undo日誌保證做了一半的事務可以被全部撤銷掉。

  • 為了保證永續性Durability,資料庫需要保證提交的事務修改的資料能夠在各種異常情況下(資料庫例項crash,機器故障)都不能丟失,因此需要記事務的redo日誌,redo日誌保證在事務提交前,事務的修改日誌就已經成功落盤。

  • 為了保證隔離性Isolation,就需要併發控制了,併發控制機制管理事務的並行執行,使得它們看起來彷彿是單獨執行的一樣。

以上三點的終極目標是保證資料庫事務的一致性Consistency,保證資料從一個一致的狀態到另一個一致的狀態。

注:這裡的一致性與分散式資料庫的一致性是不一樣的。分散式資料庫的一致性是定義了讀操作與寫操作之間的關係,有強一致性和弱一致性之分。強一致性,或者稱為線性一致性,Linearizability,也就是CAP中的C,它要求,每一個讀操作都將返回『最近的寫操作』(基於單一的實際時間)的值。弱一致性則放寬了這種限制。

分散式資料庫的一致性與本文接下來的內容沒有關係,只是為了明確它和事務的一致性是不一樣的,特在此說明。

「第二部分 隔離級別」

不同隔離級別下,事務有不一樣的行為和可容忍的異常,不定義隔離級別就無法去討論事務系統如何去做事務的併發控制的,因此它是資料庫如何保證事務Isolation的前提。

ANSI/ISO SQL-92定義了四種隔離級別(Read Uncommitted, Read Committed, Repeatable Read和Serializable),也是MySQL/InnoDB中實現的四種隔離級別。

InnoDB實現這四種隔離級別的方式可以概括為:

  • Read Uncommited

    • 可以讀取未提交記錄,存在髒讀的異常。此隔離級別一般不會使用。

  • Read Committed (RC)

    • 針對快照讀,不加鎖讀資料的一個已提交的版本。

    • 針對當前讀,RC隔離級別對讀取到的記錄只加記錄鎖,因此存在幻讀異常。

  • Repeatable Read (RR)

    • 針對快照讀,不加鎖。 

    • 針對當前讀,RR隔離級別不僅對讀取到的記錄加記錄鎖,同時對讀取的範圍加鎖(間隙鎖),新的在該範圍內的記錄不能夠插入和刪除,因此不存在幻讀異常。

    • RR是MySQL InnoDB預設的隔離級別。

  • Serializable

    • 所有的讀操作均為當前讀,讀加讀鎖 (S鎖),寫加寫鎖 (X鎖)。

    • Serializable隔離級別下,讀寫衝突,相互阻塞,因此併發度急劇下降。

注:眾所周知的快照隔離級別(Snapshot Isolation, SI)不在SQL-92定義的四種隔離級別之列,卻在很多資料庫中實現,如InterBase, PostgreSQL, Oceanbase。在SI下,事務在啟動時得到一個數據庫的版本號。事務結束時,成功提交僅當它修改的快照的資料項此時沒有被外界改變,即沒有寫寫衝突,否則事務回滾。SI能夠規避髒讀、不可重複讀、幻讀的異常,但卻不是SQL-92定義的無併發異常的可序列化,這是因為SI下可能出現寫偏序異常,解決完寫偏序異常後的SI被稱作可序列化的SI(Serializable SI, SSI),已經被PostgreSQL 9.1採用。

這裡出現了當前讀和快照讀的概念,這與MySQL中基於MVCC的併發控制機制是密不可分的。

「第三部分 MVCC」

MySQL InnoDB儲存引擎實現的是基於多版本的併發控制(Multi Version Concurrency Control, MVCC),MVCC的優勢耳熟能詳:讀不加鎖,讀寫操作不會相互阻塞,系統的併發效能高。

MVCC中,讀操作可以分為兩類:

  • 快照讀(snapshot read):讀操作讀取的是系統的一個一致性快照版本的資料(有可能是歷史資料),不需要加鎖。

  • 當前讀(current read):讀取的資料需要是最新的,需要對資料加鎖,防止有併發的操作修改。

MySQL InnoDB中哪些操作是快照讀,哪些操作是當前讀呢?

  • 快照讀:簡單的select ... from ... where ?,在Repeatable Read和Read Committed隔離級別下屬於快照讀,不加鎖。

  • 當前讀:select ... lock in share mode/select ... for update/insert/update/delete等操作屬於當前讀,需要加鎖。

InnoDB實現MVCC的關鍵技術點

  • ROW記錄格式

  • ROW和undo log關係

  • ReadView判斷

ROW格式

主鍵 columns... DB_TRX_ID DB_ROLL_PTR
  • DB_TRX_ID: 表示這條記錄最後一次被變更的事務ID。

  • DB_ROLL_PTR: 回滾指標,指向undo。

可通過語句 SHOW EXTENDED COLUMNs from innodb_table_stats; 檢視

讀取檢視(ReadView)

  • ReadView為活躍事務列表:未提交的事務

  • 通過高低水位判斷讀取的版本

    • low_limit_id 高水位

      • 當前讀看不到大於此id的事務修改值

    • up_limit_id低水位

      • 當前讀可以看到所有小於此id的事務修改值

MVCC實現原理-可見性判斷

關於InnoDB MVCC更詳細的原理和可見性判斷可以參考本公眾號文章:  https://mp.weixin.qq.com/s/UxawgHGembMKK2lA33ZQDA ,寫的非常棒!

注:InnoDB中MVCC是基於鎖和多版本實現的,還有其他的實現MVCC的併發控制機制,例如基於Timestamp Ordering(TO)的方式,有興趣的同學可以去詳細瞭解。

「第四部分 鎖型別」

鎖的概念、名詞很多,如果沒有對鎖構建出一個完整的世界觀,那麼理解起來就會比較有阻礙,接下來我們把這些鎖給分一下類。

按照鎖的粒度進行劃分可以分為:表鎖和行鎖。

這裡就不討論頁鎖了,頁鎖是 BDB(BerkeleyDB) 儲存引擎中才有的概念,我們這裡主要討論 InnoDB 儲存引擎。

按照相容性可以把鎖劃分為:共享鎖和排他鎖。

被加上共享鎖的資源,能夠和其他人進行共享,而如果被加上了排他鎖,其他人在拿不到這把鎖的情況下是無法進行任何操作的。

按照鎖的實現,這裡的實現就是 InnoDB 中具體的鎖的種類了,分別有:

  • 意向鎖(Intention Locks)

  • 記錄鎖(Record Locks)

  • 間隙鎖(Gap Locks)

  • 臨鍵鎖(Next-Key Locks)

  • 插入意向鎖(Insert Intention Locks)

  • 自增鎖(AUTO-INC Locks)

即使按照這種分類來對鎖進行了劃分,看到了這麼多的鎖的名詞可能仍然會有點懵。比如我SELECT ... FOR UPDATE 的時候到底加的是什麼鎖?

我們應該透過現象看本質,本質是什麼?本質是鎖到底加在了什麼物件上,而這個很好回答:

  • 要麼加在了表上

  • 要麼加在了行上

根據作用不同,這些鎖又分為以下幾種:

意向鎖(Intention Locks)

意向鎖是一種表級鎖,它有以下兩種型別:

  • 意向共享鎖(IS)表明該事務打算對錶中的記錄加共享鎖

  • 意向排他鎖(IX)表明該事務打算對錶中的記錄加排他鎖

例如,SELECT ... FOR SHARE在對應記錄行上加鎖之前會在對應表上加的意向共享鎖,而SELECT .. FOR UPDATE則先會對錶加意向排他鎖。

意向鎖只會和表級別的鎖之間發生衝突,而不會和行級鎖發生衝突。因為意向鎖的主要目的是為了表明有事務即將、或者正在鎖定某一行。

記錄鎖(Record Locks)

InnoDB記錄鎖的鎖定物件是對應那行資料所對應的索引,而不是具體的行。也就是說,鎖資訊並不儲存在真正的行資料上。

當一張表沒有定義主鍵時,InnoDB 會建立一個隱藏的RowID,並以此 RowID 來建立聚簇索引。

間隙鎖(Gap Locks)

官方文件描述:

Gap Lock的唯一目的就是阻止其他事務插入到間隙中。Gap Lock可以同時存在,不同的事務可以同時獲取相同的Gap Lock,並不會互相沖突。Gap Lock也是可以顯示的被禁止的,只要將事務的隔離級別降低到 READ COMMITTED。

值得注意的是,持有GAP的鎖與其他非LOCK_INSERT_INTENTION的鎖都是相容的,也就是說,GAP鎖就是為了防止插入的。

臨鍵鎖(Next-Key Locks)

臨鍵鎖(Next-Key Locks)實際上是記錄鎖和間隙鎖的組合。換句話說,臨鍵鎖會給對應的索引加上記錄鎖,並且外加鎖定一個區間。這樣其他事務就無法向這個區間內新增資料。

在RR隔離級別下,InnoDB 使⽤ Next-Key Locks防⽌幻讀。

幻讀:在一個事務內,先後執行了兩次相同的查詢,第一次查詢出來 5 條資料,但是第二次再查,查出了 7 條資料,這就是幻讀。

插入意向鎖(Insert Intention Locks)

插入意向鎖是間隙鎖的一種,在執行INSERT語句時需要加的鎖,也是用來避免幻讀異常的鎖。插入意向鎖相互之間不衝突,因此併發的事務在相同的index gap內插入不同的行不需要相互等待。

但實際上,在InnoDB的實現中,在不存在衝突的時候,該鎖並不會真正的被建立並加入到鎖管理器中去,我們在介紹 INSERT 語句的加鎖流程時會做更具體的分析。

自增鎖(AUTO-INC Locks)

自增鎖是一種表級鎖,如果一張表存在自增欄位 AUTO_INCREMENT,那麼事務向該表新增主資料時就會持有自增鎖,當語句執行完之後就會釋放。在該事務釋放自增鎖之前,其他的事務不能向該表執行INSERT語句。

實現時,在dict_table_t結構體中有一個autoinc_mutex和autoinc成員變數,用來維護自增欄位的值。只在分配時加個mutex即可很快就釋放。

因此,在statement格式下不不能保證批量量插⼊操作的binlog複製安全性,因為相同一批INSERT語句分配的自增ID每次可能不相同。

「第五部分 關鍵結構體」

鎖模式

用一個32位的變量表示:

uint32_t lock_t::type_mode;

0-3四個bit定義了鎖的模式,即這把鎖是一把意向共享鎖、意向排它鎖、共享鎖、排它鎖還是一把自增鎖。
第4位表示這把鎖是否是表鎖,1代表是表鎖,0代表不是。
第5位表示這把鎖是否是記錄鎖,1代表是,0代表不是。
以此類推。

 · LOCK_GAP:只鎖間隙
 · LOCK_REC_NO_GAP:只鎖記錄
 · LOCK_ORDINARY: 鎖記錄和記錄之前的間隙
 · LOCK_INSERT_INTENTION: 插入意向鎖,用於insert時檢查鎖衝突

在程式碼實現上,不同模式的鎖的表示方法如下表所示

鎖模式

表示方法

記錄鎖 LOCK_X | LOCK_REC_NO_GAP
間隙鎖 LOCK_X | LOCK_GAP
next-key鎖 LOCK_X | LOCK_ORDINARY
插入意向鎖

LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION

全域性鎖管理器(lock_sys_t)

  • 主要成員變數是一個hash table,用來管理全域性活躍事務建立的鎖物件。

struct lock_sys_t {
locksys::Latches latches;

/** 雜湊的key是由記錄的space id和page no來生成的,value為lock_t */
hash_table_t *rec_hash;

Lock_mutex wait_mutex;

srv_slot_t *waiting_threads;
}

鎖物件(lock_t)

  • 行鎖表鎖共用lock_t;

  • 一個lock_t要麼是行鎖要麼是表鎖。

struct lock_t {
/** 擁有這個鎖物件的事務 */
trx_t *trx;


/** 該事務持有的鎖鏈表 */
UT_LIST_NODE_T(lock_t) trx_locks;


/** 記錄鎖的索引 */
dict_index_t *index;


/** 記錄鎖的雜湊節點,即接入雜湊鏈的節點,一個鎖物件可能被多個事務建立 */
lock_t *hash;

/** 表鎖和記錄鎖採用union方式管理,節省空間,因為一個鎖物件要麼是行鎖要麼是表鎖 */
union {
/** 表鎖 */
lock_table_t tab_lock;


/** 行鎖 */
lock_rec_t rec_lock;
};

/** 表示鎖的型別和模式 */
uint32_t type_mode;
};


行鎖(lock_rec_t)

  • 如果一個lock_t是行鎖,記錄這些資訊

struct lock_rec_t {
uint32_t m_space; /** 資料所在表空間id */
uint32_t m_page_no; /** 資料所在頁號 */
uint32_t n_bits; /** lock bitmap的位數 */
};

表鎖(lock_table_t)

  • 如果一個lock_t是表鎖,記錄這些資訊

struct lock_table_t {
dict_table_t *table; /** 鎖住的表 */
UT_LIST_NODE_T(lock_t) locks; /** 同一個表上的表鎖鏈表 */
};

NOTES:

  • 表鎖和記錄鎖共用資料結構lock_t;

  • 行鎖以page為單位進行管理,同一個事務在同一個page上的所有行鎖只建立一個lock_t,具體要看某一個記錄上是否有鎖,要用該記錄在page中唯一標識的heap no到bitmap查詢該位是否為1;

  • lock_t結構體中不包含bitmap成員變數,但是在申請記憶體的時候,會在申請sizeof(lock_t)這塊記憶體的基礎上,額外多要一塊bitmap大小的記憶體。緊鄰lock_t存放,每個bit代表頁內一行資料,使用heap_no對應。

行鎖ID(RecID)

  • 用來唯一標識一個lock_t中某一行具體的鎖。

struct RecID { 
uint32_t m_space; /** 表空間ID */
uint32_t m_page_no; /** 頁面號 */
uint32_t m_heap_no; /** 頁上的heap no */
ulint m_fold; /** hashed key value,通過m_fold找到在lock_sys_t::rec_hash對應lock_t連結串列,該連結串列上的所有lock_t擁有相同的space id和page no,某一行上的衝突通過bitmap判斷 */
}

事務鎖管理器(trx_lock_t)

  • 事務物件的一個成員變數,用來管理該事務持有的鎖、等待的鎖等跟鎖相關的資訊。

struct trx_lock_t {
std::atomic<trx_t *> blocking_trx; /** 阻塞住該事務的事務 */

std::atomic<lock_t *> wait_lock; /** 等待的鎖物件 */

UT_LIST_BASE_NODE_T(lock_t) trx_locks; /** 該事務請求的所有鎖鏈表 */

lock_pool_t table_locks; /** 該事務請求的所有表鎖,包括自增鎖 */

std::atomic<ulint> n_rec_locks; /** 請求的行鎖的個數 */
}

事務(trx_t)

  • 儲存一個事務執行期間的上下文。

struct trx_t {
ulint isolation_level;
mutable TrxMutex mutex;
bool abort;
trx_id_t id; /** 事務ID */

trx_id_t no; /** 事務序列化號:事務進入COMMIT_IN_MEMORY狀態前最大的事務ID */
ReadView *read_view; /*!< 事務中使用的一致讀取檢視 */

UT_LIST_NODE_T(trx_t) trx_list; /** 事務連結串列 */
trx_lock_t lock; /** 事務鎖管理器 */
time_t start_time;
lsn_t commit_lsn;
THD *mysql_thd; /*!< MySQL處理該事務的執行緒 */
}

全域性事務管理器(trx_sys_t)

  • 管理全域性正在執行的事務。

struct trx_sys_t {
TrxSysMutex mutex;
MVCC *mvcc; /** 管理讀取檢視 */
std::atomic<trx_id_t> m_max_trx_id;
std::atomic<trx_id_t> m_min_active_id;

trx_ut_list_t mysql_trx_list; /** 事務連結串列 */

/** std::vector of rollback segments */
Rsegs_Vector m_rsegs;
}

這些資料結構之間的關係如下圖所示:

「第六部分 加鎖模式及加鎖流程」

6.1 當前讀加鎖模式

回顧前文所說,MVCC中當前讀操作需要讀取到最新的一致的資料,因此需要對資料行加鎖,對於加在行上的鎖,其本質是將鎖加在了這行資料對應的索引上。在一個支援MVCC併發控制的系統中,一條SQL究竟加什麼鎖還與以下因素密切相關:

  • 當前系統的隔離級別(RC,RR,Serializable)

  • 索引(是否有索引,是否是主鍵索引,是否是二級唯一索引)

  • 是等值查詢還是範圍查詢

  • 是否修改了其他索引資料

  • 查詢計劃(是走索引還是走全表掃描,是否有索引下推)

RR+主鍵列索引

  • 如果有對應的主鍵記錄就在記錄上加記錄鎖(lock_x | lock_rec_not_gap)

  • 如果沒有對應的主鍵記錄就在後面的記錄上加間隙鎖(lock_x | lock_gap),防止其他事務插入這條記錄。

  • UPDATA和DELETE與SELECT FOR UPDATE的區別在與,在更新包含索引列的情況下,前者需要更新二級索引,需要對涉及索引上加記錄鎖(lock_x | lock_rec_not_gap)。

  • 與等值當前讀不同的是,範圍讀需要在主鍵索引上加next-key locks,防止其他事務在查詢範圍內插入新的資料。其他一樣。

  • 以下是不同範圍更新的加鎖情況,其中supremum表示索引的上界。

RR+唯一索引

通過二級唯一索引更新記錄,首先會在WHERE條件使用到的二級索引上,如果是等值查詢加Record型別的X鎖,如果是範圍查詢,需要加Next-key型別的X鎖。然後通過二級索引找到primary key,並在primary key上加Record型別的X鎖(之所以不是Next-key,是因為查詢條件是二級索引)。最後對滿足範圍條件的最後一條記錄的下一條記錄加間隙鎖(lock_x | lock_gap),如果是supremum加next-key lock(lock_x|lock_ordinary)。

RR+非唯一索引

等值和範圍查詢都需要在對應索引上加next-key locks, 因為不唯一,即使使用等值查詢,其它事務仍然有可能插入滿足查詢條件的新紀錄。

在InnoDB中,通過二級索引更新記錄,首先會在WHERE條件使用到的二級索引上加Next-key型別的X鎖,以防止查詢記錄期間的其它插入/刪除記錄,然後通過二級索引找到primary key並在primary key上加Record型別的X鎖(之所以不是Next-key,是因為查詢條件是二級索引,若WHERE條件使用到的是primary key上的範圍查詢,就會上Next-key型別的X鎖),之後更新記錄並檢查更新欄位是否是其它索引中的某列,如果存在這樣的索引,通過update的舊值到二級索引中刪除相應的entry,此時X鎖型別為Record。最後對最後一條滿足條的記錄的下一條記錄上加間隙鎖,防止有資料插入。

我們以一個二級非唯一索引上的更新操作為例:

mysql> CREATE TABLE `t` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`a` int(11) DEFAULT NULL,
`b` int(11) DEFAULT NULL,
`c` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_a_b` (`a`,`b`),
KEY `idx_b` (`b`)
) ENGINE=InnoDB;


mysql> update set c = c + 1 where b = 62;

程式碼執行流程:

該SQL以二級非唯一索引作為查詢條件,因此,首先需要找到的二級索引上的相應列加next_key lock(lock_x | lock_ordinary),

接著對找到的主鍵索引記錄加記錄鎖(lock_x | lock_rec_not_gap),

更新結束後,對最後一條記錄的下一條記錄(在這裡是b = 63的那條資料)加間隙鎖(lock_x | lock_gap),防止有資料插入。

RR+無索引

在Repeatable Read隔離級別下,如果進行全表掃描的當前讀(可能是沒有索引,也可能是有索引,但是查詢計劃並沒有走索引),那麼會對主鍵索引上的所有記錄加上next key locks,對supremum加上next key locks,對滿足條件的記錄的索引加上記錄鎖(lock_x | lock_rec_not_gap),杜絕所有的併發 更新/刪除/插入 操作。

當然,也可以通過觸發semi-consistent read,來緩解加鎖開銷與併發影響,但是semi-consistent read本身也會帶來其他問題,不建議使用。semi-consistent read在下面的章節中介紹。

RC

  • RC下所有語句都不會加next_key locks和gap locks,只會加record locks。

  • 如果查詢範圍是主鍵索引,對相應主鍵記錄加LOCK_X|LOCK_REC_NOT_GAP,

  • 如果是更新操作,並且更新包括其他索引列,還需要在涉及索引上加LOCK_X|LOCK_REC_NOT_GAP鎖。

  • 如果查詢條件是二級非唯一索引,且是更新操作,並且更新包括其他索引列,那麼會依次對二級索引和對應主鍵行,索引資料加LOCK_X|LOCK_REC_NOT_GAP鎖。這裡需要注意的是,查詢條件對應的資料會直接加顯式鎖,其他的二級索引上得記錄鎖是隱式鎖,這在下面介紹隱式鎖的章節中有所介紹。

RC+無索引查詢

若id列上沒有索引,SQL會走聚簇索引的全掃描進行過濾,由於過濾是由MySQL Server層面進行的。因此每條記錄,無論是否滿足條件,都會被加上X鎖。但是,為了效率考量,MySQL做了優化,對於不滿足條件的記錄,會在判斷後放鎖,最終持有的,是滿足條件的記錄上的鎖,但是不滿足條件的記錄上的加鎖/放鎖動作不會省略。同時,優化也違背了2PL的約束。

提前釋放鎖執行路徑:

Sql_cmd_update::update_single_table->
ha_innobase::unlock_row->
row_unlock_for_mysql->
lock_rec_unlock

6.2 當前讀加鎖流程

函式lock_rec_lock中是確定上鎖模式之後具體的上鎖的流程,首先通過lock_rec_lock_fast函式判斷是否可以快速上鎖,

  • 如果該page上沒有鎖記錄,直接建立鎖記錄,返回加鎖成功;

  • 如果page上有多個行鎖:

    • 如果已經擁有該鎖的事務不是當前事務,或者該鎖不是一個行鎖,或者申請鎖的heap no大於該鎖的bitmap的位數,不能直接加鎖。

    • 否則的話可以加速成功,對應的bitmap位置為1。

如果不能快速加鎖成功的話,呼叫lock_rec_lock_slow上鎖:

  • 如果該事務上已經擁有一個在該記錄上更強的鎖了,直接返回成功;

  • 如果沒有的話:

    • 通過heap no去bitmap查是否這一行存在衝突,存在鎖衝突,加入等待佇列,返回鎖等待;

    • 不存在衝突,將鎖加入到lock sys中去,返回加鎖成功。

6.3 semi-consistent read

簡單來說,semi-consistent read的作用是減少更新同一行記錄時的鎖衝突,減少鎖等待。

一個update語句,如果讀到一行上沒有鎖,讀取最新版本的資料並加鎖;如果讀到一行已經加鎖的記錄,此時InnoDB返回記錄最近提交的版本,由MySQL上層判斷此版本是否滿足update 的where條件。

  • 若滿足,則MySQL會重新發起一次讀操作,此時會讀取行的最新版本(並加鎖)。

  • 對於不滿足更新條件的記錄,可以提前放鎖,減少併發衝突的概率。

semi-consistent read只會發生在read committed隔離級別下,或者是引數innodb_locks_unsafe_for_binlog=ON。且僅僅針對於update操作。

6.4 insert加鎖流程

我們在前文介紹鎖型別的時候提到了插入意向鎖,它的作用是在事務執行insert語句時防止幻讀,插入意向鎖模式:LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION。

請求鎖\持有鎖

GAP

Insert Intention

Record

Next-key

Insert Intention x x
Record x x
Next-key x x

當向某個資料頁中插入一條記錄時,總是會呼叫 lock_rec_insert_check_and_lock 函式進行鎖檢查,會去檢查當前插入位置的下一條記錄上是否存在鎖物件。

如果下一條記錄上存在鎖物件,就需要判斷該物件是否鎖住了gap。如果gap被鎖住了,判定和插入意向鎖衝突,當前插入操作需要等待。但是插入意向鎖之間並不互斥,這意味著同一個gap裡可能有多個申請插入意向鎖的會話。如果不存在衝突,InnoDB使用了隱式鎖來優化這一場景的效能。

隱式鎖

當事務需要加鎖的時,如果這個鎖不可能發生衝突,InnoDB會跳過加鎖環節,這種機制稱為隱式鎖。隱式鎖是InnoDB實現的一種延遲加鎖機制,其特點是隻有在可能發生衝突時才加鎖,從而減少了鎖的數量,提高了系統整體效能。另外,隱式鎖是針對被修改的B+ tree記錄,因此都是記錄型別的鎖,不可能是間隙鎖或Next-Key型別鎖。

InnoDB的每條記錄中都一個隱含的trx_id欄位,這個欄位存在於聚集索引的B+tree中。假設只有主鍵索引,則在進行插入時,行資料的trx_id被設定為當前事務id;假設存在二級索引,則在對二級索引進行插入時,需要更新所在page的max_trx_id。

  • UPDATE、DELETE 在查詢時,直接對查詢用的 Index 和主鍵使用顯示鎖,其他索引上使用隱式鎖。

在函式lock_rec_insert_check_and_lock中,加鎖流程如下。

dberr_t lock_rec_insert_check_and_lock(){
...


/* 插入意向鎖模式 */
const ulint type_mode = LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION;


const lock_t *wait_for =
lock_rec_other_has_conflicting(type_mode, block, heap_no, trx);


/* 如果與插入意向鎖有衝突,建立一個插入意向鎖,加到事務鎖列表中去,插入等待佇列中 */
if (wait_for != nullptr) {
RecLock rec_lock(thr, index, block, heap_no, type_mode);
trx_mutex_enter(trx);
err = rec_lock.add_to_waitq(wait_for);
trx_mutex_exit(trx);
}
switch (err) {
...
case DB_SUCCESS:
/* 如果沒有衝突,直接更新最大事務號,返回成功 */
page_update_max_trx_id(block, buf_block_get_page_zip(block), trx->id,
mtr);
...
}
...
}

隱式鎖轉換為顯式鎖

將記錄上的隱式鎖轉換為顯示鎖是由函式lock_rec_convert_impl_to_expl完成的:

主鍵索引上判斷隱式鎖是否存在

對於主鍵,只需要通過檢視記錄隱藏列trx_id是否是活躍事務就可以判斷隱式鎖是否存在。對於對於二級索引會相對比較麻煩,先通過二級索引頁上的max_trx_id進行過濾,如果無法判斷是否活躍則需要通過應用undo日誌回溯老版本資料,才能進行準確的判斷。

二級索引上判斷隱式鎖是否存在

lock_sec_rec_some_has_impl函式中:先可以通過PAGE_MAX_TRX_ID進行判斷,如果當前PAGE_MAX_TRX_ID的值小於當前活躍事務的最新ID,說明修改這條記錄的事務已經提交,則不存在隱式鎖,反之則可能存在隱式鎖,需要通過聚集索引進行判斷,其判斷過程由函式row_vers_impl_x_locked_low完成:

  • 獲取儲存在聚集索引記錄上的事務ID

    • 如果該ID等於本事務ID,返回本事務,存在隱式鎖。

    • 如果該ID對應的事務已提交,返回0。

    • 事務未提交,需要通過undo日誌找到是哪個事務修改了二級索引記錄

      • 通過undo日誌獲取老版本記錄,獲取老版本事務ID,構造老版本tuple,構造老版本二級索引tuple ,如果兩個版本的二級索引記錄相同,但是deleted_flag位不同,表示某活躍事務刪除了記錄,因此二級索引記錄含有隱式鎖 */  /* 兩個版本的二級索引不同,且deleted_flag為0,表示某活躍事務更新了二級索引記錄,因此二級索引含有隱式鎖。

二級索引在判斷出隱式鎖存在後,也是呼叫lock_rec_convert_impl_to_expl_for_trx函式將隱式鎖轉化為顯示鎖,並將其加入到lock hash table中。

一個併發場景

從上面的分析可知,INSERT操作首先判斷是否有跟插入意向鎖衝突的事務,如果沒有衝突會在對應的位置更新事務號資訊,這兩個操作之間是有時間差的,如果在這之間執行 select ... lock in share mode 語句,由於此時記錄還不存在,所以也不存在活躍事務,不會觸發隱式鎖轉換,這條語句會返回 0 條記錄,並加上 GAP 鎖;而 insert 語句繼續寫資料,不加任何鎖,在 insert 事務提交之後,select ... lock in share mode 就能查到 1 條記錄,這豈不是幻讀問題嗎?

insert呼叫棧:row_ins_step -> row_ins -> row_ins_index_entry_step -> row_ins_index_entry -> row_ins_clust_index_entry -> row_ins_clust_index_entry_low -> btr_cur_optimistic_insert -> btr_cur_ins_lock_and_undo -> lock_rec_insert_check_and_lock。

在函式row_ins_clust_index_entry_low中會開啟一個mini transaction。

每個 mini-transaction 會遵守下面的幾個規則:

  • 修改一個頁需要獲得該頁的 X-LATCH;

  • 訪問一個頁需要獲得該頁的 S-LATCH 或 X-LATCH;

  • 持有該頁的 LATCH 直到修改或者訪問該頁的操作完成。

因此,該併發導致的幻讀不會發生:

  1. 執行 insert 語句,對要操作的頁加 RW-X-LATCH,然後判斷是否有和插入意向鎖衝突的鎖,如果有,加插入意向鎖,進入鎖等待;如果沒有,直接寫資料,不加任何鎖,結束後釋放 RW-X-LATCH。

  2. 執行 select ... lock in share mode 語句,對要操作的頁加 RW-S-LATCH,如果頁面上存在 RW-X-LATCH 會被阻塞,沒有的話則判斷記錄上是否存在活躍的事務,如果存在,則為 insert 事務建立一個排他記錄鎖,並將自己加入到鎖等待佇列,最後也會釋放 RW-S-LATCH。

INSERT當前讀判斷記錄是否存在

  • mtr.start()

  • 首先判斷插入記錄是否有唯一鍵,如果有,則進行唯一性約束檢查:

    • 如果不存在相同鍵值,則繼續第三步;

    • 如果存在相同鍵值,則判斷該鍵值是否加鎖:

      • 如果沒有鎖, 判斷該記錄是否被標記為刪除:

        • 如果標記為刪除,說明事務已經提交,還沒來得及 purge,這時加 S 鎖等待;

        • 如果沒有標記刪除,則報 1062 duplicate key 錯誤;

      • 如果有鎖,說明該記錄正在處理(新增、刪除或更新),且事務還未提交,加 S 鎖等待;

  • 如果唯一性檢查成功,對插入的間隙判斷是否和插入意向鎖(Insert Intension Locks)的有衝突:

    • 如果該間隙已被加上了 GAP 鎖或 Next-Key 鎖,則加鎖失敗,建立插入意向鎖進入等待;

    • 如果沒有,則加鎖成功,表示可以插入,對應page寫trx_id;

  • 插入記錄,更新記錄trx_id。

  • mtr.commit()

INSERT當前讀判斷記錄是否存在的加鎖流程在RR和RC下相同

6.5 鎖喚醒與回滾

鎖衝突進入等待後,事務掛起,等待被喚醒,觸發鎖喚醒的主要場景有:鎖授予喚醒、死鎖喚醒(事務回滾)、超時喚醒(語句回滾)。

如果某一個操作因為鎖等待超時而回滾,此時事務持有在表級上的鎖並不會被釋放(具體流程見lock_cancel_waiting_and_release),這是合理的,因為我們並不知道在該操作之前,事務是不是就已經獲得了該表上的鎖,如果是,那麼釋放這個表鎖會使得,該事務還未結束,其他DDL操作就能夠正常進行,從而引起一致性錯誤。

此外,一個事務中途的操作出現任何異常,已經持有的行鎖不會釋放(semi consistent read除外),嚴格按照兩段鎖協議。

CATS事務排程演算法

CATS(Contention Aware Transaction Schedule)是MySQL 8.0的一個新特性,相關論文發表在VLDB 2018。它的作用是在高衝突場景下,一個事務在釋放一個鎖後決定這把鎖給哪個等待的事務。CATS將等待的事務按照權重排序,權重最高的事務擁有最高的優先順序獲得等待的鎖。事務的權重取決於等待這個事務的事務數量。換句話說,一個事務有越多的等待事務,那麼它獲取鎖的優先順序越高。

回滾流程

回滾有兩種:一種是事務級的回滾,回滾整個事務;第二種是使用savepoint部分回滾,可以回滾到事務中間的某一個狀態。ROLLBACK SAVEPOINT *savepoint_name*

第一種沒什麼好說的,回滾整個事務,釋放所有獲得的鎖,第二種回滾不會結束事務,InnoDB不會釋放記憶體中的行鎖,這是滿足兩段鎖協議的,能夠有效減少死鎖的發生。

如果回滾的操作中有INSERT操作,結果會有點不同,INSERT操作會在下一條滿足條件的記錄上加上gap鎖,如果下一條記錄是supremum,加next-key鎖,防止有新的事務插入這條資料,引起幻讀異常。因此,使用savepoint回滾INSERT操作時,事務並不會釋放supremum上的鎖。

騰訊資料庫技術團隊 對內支援QQ空間、微信紅包、騰訊廣告、騰訊音樂、騰訊新聞等公司自研業務,對外在騰訊雲上依託於CBS+CFS的底座,支援TencentDB相關產品,如TDSQL-C(原CynosDB)、TencentDB for MySQL(CDB)、CTSDB、MongoDB、CES等 騰訊資料庫技術團隊 專注於持續優化資料庫核心和架構能力,提升資料庫效能和穩定性,為騰訊自研業務和騰訊雲客戶提供“省心、放心”的資料庫服務。此公眾號旨在和廣大資料庫技術愛好者一起推廣和分享資料庫領域專業知識,希望對大家有所幫助。