MySQL · 引擎特性 · InnoDB 事務鎖簡介

JorgeSchmit 8年前發布 | 45K 次閱讀 InnoDB MySQL 數據庫服務器

來自: https://yq.aliyun.com/articles/4270

InnoDB 事務鎖系統簡介

前言

本文的目的是對InnoDB的事務鎖模塊做個簡單的介紹,使讀者對這塊有初步的認識。本文先介紹行級鎖和表級鎖的相關概念,再介紹其內部的一些實現;最后以兩個有趣的案例結束本文。

本文所有的代碼和示例都是基于當前最新的MySQL5.7.10版本。

行級鎖

InnoDB支持到行級別粒度的并發控制,本小節我們分析下幾種常見的行級鎖類型,以及在哪些情況下會使用到這些類型的鎖。

LOCK_REC_NOT_GAP

鎖帶上這個FLAG時,表示這個鎖對象只是單純的鎖在記錄上,不會鎖記錄之前的GAP。在RC隔離級別下一般加的都是該類型的記錄鎖(但唯一二級索引上的duplicate key檢查除外,總是加 LOCK_ORDINARY 類型的鎖)

LOCK_GAP

表示只鎖住一段范圍,不鎖記錄本身,通常表示兩個索引記錄之間,或者索引上的第一條記錄之前,或者最后一條記錄之后的鎖。可以理解為一種區間鎖,一般在RR隔離級別下會使用到GAP鎖。

你可以通過切換到RC隔離級別,或者開啟選項innodb_locks_unsafe_for_binlog來避免GAP鎖。這時候只有在檢查外鍵約束或者duplicate key檢查時才會使用到GAP LOCK。

LOCK_ORDINARY(Next-Key Lock)

也就是所謂的NEXT-KEY鎖,包含記錄本身及記錄之前的GAP。當前MySQL默認情況下使用RR的隔離級別,而NEXT-KEY LOCK正是為了解決RR隔離級別下的幻讀問題。所謂幻讀就是一個事務內執行相同的查詢,會看到不同的行記錄。在RR隔離級別下這是不允許的。

假設索引上有記錄1, 4, 5, 8,12 我們執行類似語句:SELECT... WHERE col > 10 FOR UPDATE。如果我們不在(8, 12)之間加上Gap鎖,另外一個Session就可能向其中插入一條記錄,例如9,再執行一次相同的SELECT FOR UPDATE,就會看到新插入的記錄。

這也是為什么插入一條記錄時,需要判斷下一條記錄上是否加鎖了。

LOCK_S(共享鎖)

共享鎖的作用通常用于在事務中讀取一條行記錄后,不希望它被別的事務鎖修改。但所有的讀請求產生的LOCK_S鎖是不沖突的。在InnoDB里有如下幾種情況會請求S鎖。

1.普通查詢在隔離級別為SERIALIZABLE會給記錄加LOCK_S鎖。但這也取決于場景:非事務讀在SERIALIZABLE隔離級別下,無需加鎖。(不過在當前最新的5.7.10版本中,SHOW ENGINE INNODB STATUS的輸出中不會打印只讀事務的信息,只能從 informationschema.innodb_trx 表中獲取到該只讀事務持有的鎖個數等信息)。

2.類似SQL SELECT…IN SHARE MODE, 會給記錄加S鎖,其他線程可以并發查詢,但不能修改。基于不同的隔離級別,行為有所不同:

  • RC隔離級別: LOCK_REC_NOT_GAP | LOCK_S
  • RR隔離級別:如果查詢條件為唯一索引且是唯一等值查詢時,加的是 LOCK_REC_NOT_GAP | LOCK_S ;對于非唯一條件查詢,或者查詢會掃描到多條記錄時,加的是 LOCK_ORDINARY | LOCK_S 鎖,也就是記錄本身+記錄之前的GAP

3.通常INSERT操作是不加鎖的,但如果在插入或更新記錄時,檢查到Duplicate key(或者有一個被標記刪除的duplicate key),對于普通的INSERT/UPDATE,會加LOCK_S鎖,而對于類似REPLACE INTO或者INSERT ..ON DUPLICATE這樣的SQL加的是X鎖。而針對不同的索引類型也有所不同:

  • 對于聚集索引(參閱函數 row_ins_duplicate_error_in_clust ),隔離級別小于等于RC時,加的是 LOCK_REC_NOT_GAP 類似的S或者X記錄鎖。否則加 LOCK_ORDINARY 類型的記錄鎖(NEXT-KEY LOCK)
  • 對于二級唯一索引,若檢查到重復鍵,當前版本總是加LOCK_ORDINARY類型的記錄鎖(函數 row_ins_scan_sec_index_for_duplicate )。實際上按照RC的設計理念,不應該加GAP鎖(bug#68021),官方也事實上嘗試修復過一次,即對于RC隔離級別加上LOCK_REC_NOT_GAP,但卻引入了另外一個問題,導致二級索引的唯一約束失效(bug#73170),感興趣的可以參閱我寫的 這篇博客 ,由于這個嚴重bug,官方很快又把這個fix給revert掉了。

4.外鍵檢查

當我們刪除一條父表上的記錄時,需要去檢查是否有引用約束( row_pd_check_references_constraints ),這時候會掃描子表( dict_table_t::referenced_list )上對應的記錄,并加上共享鎖。按照實際情況又有所不同。我們舉例說明

使用RC隔離級別,兩張測試表:

create table t1 (a int, b int, primary key(a));
create table t2 (a int, b int, primary key (a), key(b), foreign key(b) references t1(a));
insert into t1 values (1,2), (2,3), (3,4), (4,5), (5,6), (7,8), (10,11);
insert into t2 values (1,2), (2,2), (4,4);

執行SQL:delete from t1 where a = 10;

  • 在t1表記錄10上加 LOCKREC_NOT_GAP|LOCK_X
  • 在t2表的supremum記錄(表示最大記錄)上加 LOCK_ORDINARY|LOCK_S ,即鎖住(4, ~)區間

執行SQL:delete from t1 where a = 2;

  • 在t1表記錄(2,3)上加 LOCK_REC_NOT_GAP|LOCK_X
  • 在t2表記錄(1,2)上加 LOCK_REC_NOT_GAP|LOCK_S 鎖,這里檢查到有引用約束,因此無需繼續掃描(2,2)就可以退出檢查,判定報錯。

執行SQL:delete from t1 where a = 3;

  • 在t1表記錄(3,4)上加 LOCK_REC_NOT_GAP|LOCK_X
  • 在t2表記錄(4,4)上加 LOCK_GAP|LOCK_S 鎖

另外從代碼里還可以看到,如果掃描到的記錄被標記刪除時,也會加 LOCK_ORDINARY|LOCK_S 鎖。具體參閱函數 row_ins_check_foreign_constraint

5.INSERT…SELECT插入數據時,會對SELECT的表上掃描到的數據加LOCK_S鎖

LOCK_X(排他鎖)

排他鎖的目的主要是避免對同一條記錄的并發修改。通常對于UPDATE或者DELETE操作,或者類似SELECT....FOR UPDATE操作,都會對記錄加排他鎖。我們以如下表為例:

create table t1 (a int, b int, c int, primary key(a), key(b));
插入數據 insert into t1 values (1,2,3), (2,3,4),(3,4,5), (4,5,6),(5,6,7);

執行SQL(通過二級索引查詢):update t1 set c = c +1 where b = 3;

  • RC隔離級別:1. 鎖住二級索引記錄,為NOT GAP X鎖;2.鎖住對應的聚集索引記錄,也是NOT GAP X鎖。
  • RR隔離級別下:1.鎖住二級索引記錄,為 LOCK_ORDINARY|LOCK_X 鎖;2.鎖住聚集索引記錄,為NOT GAP X鎖

執行SQL(通過聚集索引檢索,更新二級索引數據):update t1 set b = b +1 where a = 2;

  • 對聚集索引記錄加 LOCK_REC_NOT_GAP | LOCK_X 鎖;
  • 在標記刪除二級索引時,檢查二級索引記錄上的鎖( lock_sec_rec_modify_check_and_lock ),如果存在和 LOCK_X | LOCK_REC_NOT_GAP 沖突的鎖對象,則創建鎖對象并返回等待錯誤碼;否則無需創建鎖對象;
    • 當到達這里時,我們已經持有了聚集索引上的排他鎖,因此能保證別的線程不會來修改這條記錄。(修改記錄總是先聚集索引,再二級索引的順序),即使不對二級索引加鎖也沒有關系。但如果已經有別的線程已經持有了二級索引上的記錄鎖,則需要等待。
  • 在標記刪除后,需要插入更新后的二級索引記錄時,依然要遵循插入意向鎖的加鎖原則。

我們考慮上述兩種SQL的混合場景,一個是先鎖住二級索引記錄,再鎖聚集索引;另一個是先鎖聚集索引,再檢查二級索引沖突,因此在這類并發更新場景下,可能會發生死鎖。

不同場景,不同隔離級別下的加鎖行為都有所不同,例如在RC隔離級別下,不符合WHERE條件的掃描到的記錄,會被立刻釋放掉,但RR級別則會持續到事務結束。你可以通過GDB,斷點函數lock_rec_lock來查看某條SQL如何執行加鎖操作。

LOCK_INSERT_INTENTION(插入意向鎖)

INSERT INTENTION鎖是GAP鎖的一種,如果有多個session插入同一個GAP時,他們無需互相等待,例如當前索引上有記錄4和8,兩個并發session同時插入記錄6,7。他們會分別為(4,8)加上GAP鎖,但相互之間并不沖突(因為插入的記錄不沖突)。

當向某個數據頁中插入一條記錄時,總是會調用函數lock_rec_insert_check_and_lock進行鎖檢查(構建索引時的數據插入除外), 會去檢查當前插入位置的下一條記錄上是否存在鎖對象,這里的下一條記錄不是指的物理連續,而是按照邏輯順序的下一條記錄。如果下一條記錄上不存在鎖對象:若記錄是二級索引上的,先更新二級索引頁上的最大事務ID為當前事務的ID;直接返回成功。

如果下一條記錄上存在鎖對象,就需要判斷該鎖對象是否鎖住了GAP。如果GAP被鎖住了,并判定和插入意向GAP鎖沖突,當前操作就需要等待,加的鎖類型為 LOCK_X | LOCK_GAP | LOCK_INSERT_INTENTION ,并進入等待狀態。但是插入意向鎖之間并不互斥。這意味著在同一個GAP里可能有多個申請插入意向鎖的會話。

鎖表更新

我們知道GAP鎖是在一個記錄上描述的,表示記錄及其之前的記錄之間的GAP。但如果記錄之前發生了插入或者刪除操作,之前描述的GAP就會發生變化,InnoDB需要對鎖表進行更新。

對于數據插入,假設我們當前在記錄[3,9]之間有會話持有鎖(不管是否和插入意向鎖沖突),現在插入一條新的記錄5,需要調用函數 lock_update_insert 。這里會遍歷所有在記錄9上的記錄鎖,如果這些鎖不是插入意向鎖并且是LOCK_GAP或者NEXT-KEY LOCK(沒有設置LOCK_REC_NOT_GAP標記),( lock_rec_inherit_to_gap_if_gap_lock ),就會為這些會話的事務增加一個新的鎖對象,鎖的類型為 LOCK_REC | LOCK_GAP ,鎖住的GAP范圍在本例中為(3,5)。所有符合條件的會話都繼承了這個新的GAP,避免之前的GAP鎖失效。

對于數據刪除操作,調用函數 lock_update_delete ,這里會遍歷在被刪除記錄上的記錄鎖,當符合如下條件時,需要為這些鎖對應的事務增加一個新的GAP鎖,鎖的Heap No為被刪除記錄的下一條記錄:

lock_rec_inherit_to_gap
        for (lock = lock_rec_get_first(lock_sys->rec_hash, block, heap_no);
             lock != NULL;
             lock = lock_rec_get_next(heap_no, lock)) {

            if (!lock_rec_get_insert_intention(lock)
                && !((srv_locks_unsafe_for_binlog
                      || lock->trx->isolation_level
                      <= TRX_ISO_READ_COMMITTED)
                     && lock_get_mode(lock) ==
                     (lock->trx->duplicates ? LOCK_S : LOCK_X))) {
                    lock_rec_add_to_queue(
                            LOCK_REC | LOCK_GAP | lock_get_mode(lock),
                            heir_block, heir_heap_no, lock->index,
                            lock->trx, FALSE);
            }
    }</code></pre><code data-language="c"> 

從上述判斷可以看出,即使在RC隔離級別下,也有可能繼承LOCK GAP鎖,這也是當前版本InnoDB唯一的意外:判斷Duplicate key時目前容忍GAP鎖。上面這段代碼實際上在最近的版本中才做過更新,更早之前的版本可能存在二級索引損壞,感興趣的可以閱讀我的 這篇博客

完成GAP鎖繼承后,會將所有等待該記錄的鎖對象全部喚醒( lock_rec_reset_and_release_wait )。

LOCK_PREDICATE

從MySQL5.7開始MySQL整合了 boost.geometry 庫以更好的支持空間數據類型,并支持在在Spatial數據類型的列上構建索引,在InnoDB內,這個索引和普通的索引有所不同,基于R-TREE的結構,目前支持對2D數據的描述,暫不支持3D.

R-TREE和BTREE不同,它能夠描述多維空間,而多維數據并沒有明確的數據順序,因此無法在RR隔離級別下構建NEXT-KEY鎖以避免幻讀,因此InnoDB使用稱為Predicate Lock的鎖模式來加鎖,會鎖住一塊查詢用到的被稱為MBR(minimum boundingrectangle/box)的數據區域。 因此這個鎖不是鎖到某個具體的記錄之上的,可以理解為一種Page級別的鎖。

Predicate Lock和普通的記錄鎖或者表鎖(如上所述)存儲在不同的lock hash中,其相互之間不會產生沖突。

Predicate Lock相關代碼見 lock/lock0prdt.cc 文件

關于Predicate Lock的設計參閱官方 WL#6609

由于這塊的代碼量比較龐大,目前小編對InnoDB的spatial實現了解有限,本文暫不對此展開,將在后面單獨專門介紹spatial index時,再細細闡述這塊內容

隱式鎖

InnoDB通常對插入操作無需加鎖,而是通過一種“隱式鎖”的方式來解決沖突。聚集索引記錄中存儲了事務id。如果另外有個session查詢到了這條記錄,會去判斷該記錄對應的事務id是否屬于一個活躍的事務,并協助這個事務創建一個記錄鎖,然后將自己置于等待隊列中。該設計的思路是基于大多數情況下新插入的記錄不會立刻被別的線程并發修改,而創建鎖的開銷是比較昂貴的,涉及到全局資源的競爭。

關于隱式鎖轉換, 上一期的月報 我們已經介紹過了,這里不再贅述。

鎖的沖突判定

鎖模式的兼容性矩陣通過如下數組進行快速判定:

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}
};

對于記錄鎖而言,鎖模式只有LOCK_S 和LOCK_X,其他的FLAG用于鎖的描述,如前述LOCK_GAP、LOCK_REC_NOT_GAP以及LOCK_ORDINARY、LOCK_INSERT_INTENTION四種描述。在比較兩個鎖是否沖突時,即時滿足兼容性矩陣,在如下幾種情況下,依然認為是相容的,無需等待(參考函數 lock_rec_has_to_wait )

  • 對于GAP類型(鎖對象建立在supremum上或者申請的鎖類型為LOCK_GAP)且申請的不是插入意向鎖時,無需等待任何鎖,這是因為不同Session對于相同GAP可能申請不同類型的鎖,而GAP鎖本身設計為不互相沖突。
  • LOCK_ORDINARY 或者LOCK_REC_NOT_GAP類型的鎖對象,無需等待LOCK_GAP類型的鎖
  • LOCK_GAP類型的鎖無需等待LOCK_REC_NOT_GAP類型的鎖對象。
  • 任何鎖請求都無需等待插入意向鎖。

表級鎖

InnoDB的表級別鎖包含五種鎖模式:LOCK_IS、LOCK_IX、LOCK_X、LOCK_S以及LOCK_AUTO_INC鎖,鎖之間的相容性遵循數組 lock_compatibility_matrix 中的定義。

InnoDB表級鎖的目的是為了防止DDL和DML的并發問題。但從5.5版本開始引入MDL鎖后,InnoDB層的表級鎖的意義就沒那么大了,MDL鎖本身已經覆蓋了其大部分功能。以下我們介紹下幾種InnoDB表鎖類型。

LOCK_IS/LOCK_IX

也就是所謂的意向鎖,這實際上可以理解為一種“暗示”未來需要什么樣行級鎖,IS表示未來可能需要在這個表的某些記錄上加共享鎖,IX表示未來可能需要在這個表的某些記錄上加排他鎖。意向鎖是表級別的,IS和IX鎖之間相互并不沖突,但與表級S/X鎖沖突。

在對記錄加S鎖或者X鎖時,必須保證其在相同的表上有對應的意向鎖或者鎖強度更高的表級鎖。

LOCK_X

當加了LOCK_X表級鎖時,所有其他的表級鎖請求都需要等待。通常有這么幾種情況需要加X鎖:

  • DDL操作的最后一個階段( ha_innobase::commit_inlace_alter_table )對表上加LOCK_X鎖,以確保沒有別的事務持有表級鎖。通常情況下Server層MDL鎖已經能保證這一點了,在DDL的commit 階段是加了排他的MDL鎖的。但諸如外鍵檢查或者剛從崩潰恢復的事務正在進行某些操作,這些操作都是直接InnoDB自治的,不走server層,也就無法通過MDL所保護。
  • 當設置會話的autocommit變量為OFF時,執行 LOCK TABLE tbname WRITE 這樣的操作會加表級的LOCK_X鎖。( ha_innobase::external_lock )
  • 對某個表空間執行discard或者Import操作時,需要加LOCK_X鎖( ha_innobase::discard_or_import_tablespace )

LOCK_S

  • 在DDL的第一個階段,如果當前DDL不能通過ONLINE的方式執行,則對表加LOCK_S鎖( prepare_inplace_alter_table_dict )

  • 設置會話的autocommit為OFF,執行LOCK TABLE tbname READ時,會加LOCK_S鎖( ha_innobase::external_lock )

從上面的描述我們可以看到LOCK_X及LOCK_S鎖在實際的大部分負載中都很少會遇到。主要還是互相不沖突的LOCK_IS及LOCK_IX鎖。一個有趣的問題是,每次加表鎖時,卻總是要掃描表上所有的表級鎖對象,檢查是否有沖突的鎖。很顯然,如果我們在同一張表上的更新并發度很高,這個鏈表就會非常長。

基于大多數表鎖不沖突的事實,我們在RDS MYSQL中對各種表鎖對象進行計數,在檢查是否有沖突時,例如當前申請的是意向鎖,如果此時LOCK_S和LOCK_X的鎖計數都是0,就可以認為沒有沖突,直接忽略檢查。由于檢查是在持有全局大鎖 lock_sys->mutex 下進行的。在單表大并發下,這個優化的效果還是非常明顯的,可以減少持有全局大鎖的時間。

LOCK_AUTO_INC

AUTO_INC鎖加在表級別,和AUTO_INC、表級S鎖以及X鎖不相容。鎖的范圍為SQL級別,SQL結束后即釋放。AUTO_INC的加鎖邏輯和InnoDB的鎖模式相關,這里在簡單介紹一下。

通常對于自增列,我們既可以顯式指定該值,也可以直接用NULL,系統將自動遞增并填充該列。我們還可以在批量插入時混合使用者兩種方式。不同的分配方式,其具體行為受到參數 innodb_autoinc_lock_mode 的影響。但在基于STATEMENT模式復制時,可能會影響到復制的數據一致性, 官方文檔 有詳細描述,不再贅述,只說明下鎖的影響。

自增鎖模式通過參數 innodb_autoinc_lock_mode 來控制,加鎖選擇參閱函數 ha_innobase::innobase_lock_autoinc

具體的,有以下幾個值:

AUTOINC_OLD_STYLE_LOCKING (0)

也就是所謂的傳統加鎖模式(在5.1版本引入這個參數之前的策略),在該策略下,會在分配前加上AUTO_INC鎖,并在SQL結束時釋放掉。該模式保證了在STATEMENT復制模式下,備庫執行類似INSERT..SELECT這樣的語句時的一致性,因為這樣的語句在執行時無法確定到底有多少條記錄,只有在執行過程中不允許別的會話分配自增值,才能確保主備一致。很顯然這種鎖模式非常影響并發插入的性能,但卻保證了一條SQL內自增值分配的連續性。

AUTOINC_NEW_STYLE_LOCKING (1)

這是InnoDB的默認值。在該鎖模式下

  • 普通的INSERT或REPLACE操作會先加一個 dict_table_t::autoinc_mutex ,然后去判斷表上是否有別的線程加了LOCK_AUTO_INC鎖,如果有的話,釋放autoinc_mutex,并使用OLD STYLE的鎖模式。 否則,在預留本次插入需要的自增值之后,就快速的將autoinc_mutex釋放掉。很顯然,對于普通的并發INSERT操作,都是無需加LOCK_AUTO_INC鎖的。因此大大提升了吞吐量。
  • 但是對于一些批量插入操作,例如LOAD DATA, INSERT...SELECT等還是使用OLD STYLE的鎖模式,SQL執行期間加LOCK_AUTO_INC鎖。

和傳統模式相比,這種鎖模式也能保證STATEMENT模式下的復制安全性,但卻無法保證一條插入語句內的自增值的連續性,并且在執行一條混合了顯式指定自增值和使用系統分配兩種方式的插入語句時,可能存在一定的自增值浪費.

例如執行SQL:

INSERT INTO t1 (c1,c2) VALUES (1,'a'), (NULL,'b'), (5,'c'), (NULL,’d’)

假設當前AUTO_INCREMENT值為101,在傳統模式下執行完后,下一個自增值為103,而在新模式下,下一個可用的自增值為105,因為在開始執行SQL時,會先預取了[101, 104] 4個自增值,這和插入行的個數相匹配,然后將AUTO_INCREMENT設為105,導致自增值103和104被浪費掉。

AUTOINC_NO_LOCKING (2)

這種模式下只在分配時加個mutex即可,很快就釋放,不會像NEW STYLE那樣在某些場景下會退化到傳統模式。因此設為2不能保證批量插入的復制安全性。

關于自增鎖的小BUG

這是Mariadb的Jira上報的一個小bug,在row模式下,由于不走parse的邏輯,我們不知道行記錄是通過什么批量導入還是普通INSERT產生的,因此command類型為SQLCOM_END,而在判斷是否加自增鎖時的邏輯時,是通過COMMAND類型是否為SQLCOM_INSERT或者SQLCOM_REPLACE來判斷是否忽略加AUTO_INC鎖。這個額外的鎖開銷,會導致在使用ROW模式時,InnoDB總是加AUTO_INC鎖,加AUTO_INC鎖又涉及到全局事務資源的開銷,從而導致性能下降。

修復的方式也比較簡單,將SQLCOM_END這個command類型也納入考慮。

具體參閱 Jira鏈接

事務鎖管理

InnoDB所有的事務所對象都是掛在全局對象lock_sys上,同時每個事務對象上也維持了其擁有的事務鎖,每個表對象( dict_table_t )上維持了構建在其上的表級鎖對象。

如下圖所示:

加表級鎖

  • 首先從當前事務的 trx_lock_t::table_locks 中查找是否已經加了等同或更高級別的表鎖,如果已經加鎖了,則直接返回成功。( lock_table_has )
  • 檢查當前是否有和當前申請的鎖模式沖突的表級鎖對象( lock_table_other_has_incompatible )
    • 直接遍歷鏈表 dict_table_t::locks 鏈表
  • 如果存在沖突的鎖對象,則需要進入等待隊列( lock_table_enqueue_waiting )
    • 創建等待鎖對象 ( lock_table_create )
    • 檢查是否存在死鎖( DeadlockChecker::check_and_resolve ),當存在死鎖時:如果當前會話被選作犧牲者,就移除鎖請求( lock_table_remove_low ),重置當前事務的wait_lock為空,并返回錯誤碼DB_DEADLOCK;若被選成勝利者,則鎖等待解除,可以認為當前會話已經獲得了鎖,返回成功。
    • 若沒有發生死鎖,設置事務對象的相關變量后,返回錯誤碼DB_LOCK_WAIT,隨后進入鎖等待狀態
  • 如果不存在沖突的鎖,則直接創建鎖對象( lock_table_create ),加入隊列

lock_table_create : 創建鎖對象

  • 當前請求的是AUTO-INC鎖時
    • 遞增 dict_table_t::n_waiting_or_granted_auto_inc_locks 。前面我們已經提到過,當這個值非0時,對于自增列的插入操作就會退化到OLD-STYLE。
    • 鎖對象直接引用已經預先創建好的 dict_table_t::autoinc_lock ,并加入到 trx_t::autoinc_locks 集合中。
  • 對于非AUTO-INC鎖,則從一個pool中分配鎖對象。
    • 在事務對象 trx_t::lock 中,維持了兩個pool,一個是 trx_lock_t::rec_pool ,預分配了一組鎖對象用于記錄鎖分配,另外一個是 trx_lock_t::table_pool ,用于表級鎖的鎖對象分配。通過預分配內存的方式,可以避免在持有全局大鎖時( lock_sys->mutex )進行昂貴的內存分配操作。rec_pool和table_pool預分配的大小都為8個鎖對象。( lock_trx_alloc_locks )
    • 如果table_pool已經用滿,則走內存分配,創建一個鎖對象
  • 構建好的鎖對象分別加入到事務的 trx_t::lock.trx_locks 鏈表上以及表對象的 dict_table_t::locks 鏈表上。
  • 構建好的鎖對象加入到當前事務的 trx_t::lock.table_locks 集合中。

可以看到鎖對象會加入到不同的集合或者鏈表中,通過掛載到事務對象上,可以快速檢查當前事務是否已經持有表鎖;通過掛到表對象的鎖鏈表上,可以用于檢查該表上的全局沖突情況。

加行級鎖

行級鎖加鎖的入口函數為 lock_rec_lock ,其中第一個參數impl如果為TRUE,則當當前記錄上已有的鎖和 LOCK_X | LOCK_REC_NOT_GAP 不沖突時,就無需創建鎖對象。(見上文關于記錄鎖LOCK_X相關描述部分),為了描述清晰,下文的流程描述,默認impl為false。

lock_rec_lock :

  • 首先嘗試fast lock的方式,對于沖突少的場景,這是比較普通的加鎖方式( lock_rec_lock_fast ), 符合如下情況時,可以走fast lock:
    • 記錄所在的page上沒有任何記錄鎖時,直接創建鎖對象,加入rec_hash,并返回成功
    • 記錄所在的page上只存在一個記錄鎖,并且屬于當前事務,且這個記錄鎖預分配的bitmap能夠描述當前的heap no (預分配的bit數為創建鎖對象時的page上記錄數 + 64,參閱函數 RecLock::lock_size ),則直接設置對應的bit位并返回。
  • 無法走fast lock時,再調用slow lock的邏輯( lock_rec_lock_slow )
    • 判斷當前事務是否已經持有了一個優先級更高的鎖,如果是的話,直接返回成功( lock_rec_has_expl )
    • 檢查是否存在和當前申請鎖模式沖突的鎖( lock_rec_other_has_conflicting ),如果存在的話,就創建一個鎖對象( RecLock::RecLock ),并加入到等待隊列中( RecLock::add_to_waitq ),這里會進行死鎖檢測。
    • 如果沒有沖突的鎖,則入隊列( lock_rec_add_to_queue ):已經有在同一個Page上的鎖對象且沒有別的會話等待相同的heap no時,可以直接設置對應的bitmap( lock_rec_find_similar_on_page );否則需要創建一個新的鎖對象。
  • 返回錯誤碼,對于DB_LOCK_WAIT, DB_DEADLOCK等錯誤碼,會在上層進行處理。

等待及死鎖判斷

當發現有沖突的鎖時,調用函數 RecLock::add_to_waitq 進行判斷

  • 如果持有沖突鎖的線程是內部的后臺線程(例如后臺dict_state線程),這個線程不會被一個高優先級的事務取消掉,因為總是優先保證內部線程正常執行。
  • 比較當前會話和持有鎖的會話的事務優先級,調用函數trx_arbitrate 返回被選作犧牲者的事務
    • 當前發起請求的會話是后臺線程,但持有鎖的會話設置了高優先級時,選擇當前線程作為犧牲者
    • 持有鎖的線程為后臺線程時,在第一步已經判斷了,不會選作犧牲者
    • 如果兩個會話都設置了優先級,低優先級的被選做犧牲者,優先級相同時,請求者被選做犧牲者( thd_tx_arbitrate )
    • PS: 目前最新版本的5.7還不支持用戶端設置線程優先級(但增加一個配置session變量的接口非常容易)
  • 如果當前會話的優先級較低,或者另外一個持有鎖的會話為后臺線程,這時候若當前會話設置了優先級,直接報錯,并返回錯誤碼DB_DEADLOCK。
    • 默認不設置優先級時,請求鎖的會話也會被選作victim_trx,但只創建鎖等待對象,不會直接返回錯誤。
  • 當持有鎖的會話被選作犧牲者時,說明當前會話肯定設置了高優先級,這時候會走 RecLock::enqueue_priority 的邏輯
    • 如果持有鎖的會話在等待另外一個不同的鎖時,或者持有鎖的事務不是readonly的,當前會話會被回滾掉。
    • 開始跳隊列,直到當前會話滿足加鎖條件( RecLock::jump_queue )
      • 請求的鎖對象跳過阻塞它的鎖對象,直接操作hash鏈表,將鎖對象往前挪。
      • 從當前lock,向前遍歷鏈表,逐個判斷是否有別的會話持有了相同記錄上的鎖( RecLock::is_on_row ),并將這些會話標記為回滾( mark_trx_for_rollback ),同時將這些事務對象搜集下來,以待后續處理。(但直接阻塞當前會話的事務會被立刻回滾掉)
    • 高優先級的會話非常具有殺傷力,其他低優先級會話即使拿到了鎖,也會被它所干掉。

不過實際場景中,我們并沒有多少機會去設置事務的優先級,這里先拋開這個話題,只考慮默認的場景,即所有的事務優先級都未設置。

在創建了一個處于WAIT狀態的鎖對象后,我們需要進行死鎖檢測( RecLock::deadlock_check ),死鎖檢測采用深度優先遍歷的方式,通過事務對象上的 trx_t::lock.wait_lock 構造事務的wait-for graph進行判斷,當最終發現一個鎖請求等待閉環時,可以判定發生了死鎖。另外一種情況是,如果檢測深度過長(即鎖等待的會話形成的檢測鏈路非常長),也會認為發生死鎖,最大深度默認為 LOCK_MAX_DEPTH_IN_DEADLOCK_CHECK ,值為200。

當發生死鎖時,需要選擇一個犧牲者( DeadlockChecker::select_victim() )來解決死鎖,通常事務權重低的回滾( trx_weight_ge )

  • 修改了非事務表的會話具有更高的權重;
  • 如果兩個表都修改了、或者都沒有修改事務表,那么就根據的事務的undo數量加上持有的事務鎖個數來決定權值。(TRX_WEIGHT)
  • 低權重的事務被回滾,高權重的獲得鎖對象。

Tips:對于一個經過精心設計的應用,我們可以從業務上避免死鎖,而死鎖檢測本身是通過持有全局大鎖來進行的,代價非常高昂,在阿里內部的應用中,由于有專業的團隊來保證業務SQL的質量,我們可以選擇性的禁止掉死鎖檢測來提升性能,尤其是在熱點更新場景,帶來的性能提升非常明顯,極端高并發下,甚至能帶來數倍的提升。

當無法立刻獲得鎖時,會將錯誤碼傳到上層進行處理( row_mysql_handle_errors )

  • DB_LOCK_WAIT :

    • 具有高優先級的事務已經搜集了會阻塞它的事務鏈表,這時候會統一將這些事務回滾掉( trx_kill_blocking );
    • 將當前的線程掛起( lock_wait_suspend_thread ),等待超時時間取決于session級別配置( innodb_lock_wait_timeout ),默認為50秒。
    • 如果當前會話的狀態設置為running,。一種是被選做死鎖檢測的犧牲者,需要回滾當前事務,另外一種是在進入等待前已經獲得了事務鎖,也無需等待
    • 獲得等待隊列的一個空閑slot。( lock_wait_table_reserve_slot )
      • 系統啟動時,已經創建好了足夠用的slot數組,類型為 srv_slot_t ,掛在 lock_sys->waiting_threads 上。
      • 分配slot時,從slot數組的第一個元素開始遍歷,直到找到一個空閑的slot。注意這里存在的一個性能問題是,如果掛起的線程非常多,每個新加入掛起等待的線程都需要遍歷直到找到一個空閑的slot。 實際上如果每次遍歷都從上次分配的位置往后找,到達數組末尾在循環到數組頭,這樣可以在高并發高鎖沖突場景下獲得一定的性能提升。
    • 如果會話在innodb層(通常為true),則強制從InnoDB層退出,確保其不占用 innodb_thread_concurrency 的槽位。然后進入等待狀態。被喚醒后,會再次強制進入InnoDB層
    • 被喚醒后,釋放slot( lock_wait_table_release_slot )
    • 若被選作死鎖的犧牲者了,返回上層回滾事務;若等待超時了,則根據參數 innodb_rollback_on_timeout 的配置,默認為OFF只回滾當前SQL,設置為ON表示回滾整個事務。
  • DB_DEADLOCK : 直接回滾當前事務

釋放鎖及喚醒

大多數情況下事務鎖都是在事務提交時釋放,但有兩種意外:

  • AUTO-INC鎖在SQL結束時直接釋放( innobase_commit --> lock_unlock_table_autoinc )
  • 在RC隔離級別下執行DML語句時,從引擎層返回到Server層的記錄,如果不滿足where條件,則需要立刻unlock掉。( ha_innobase::unlock_row )

除這兩種情況外,其他的事務鎖都是在事務提交時釋放的。( lock_trx_release_locks --> lock_release )。 事務持有的所有鎖都維護在鏈表 trx_t::lock.trx_locks 上,依次遍歷釋放即可。

對于行鎖,從全局hash中刪除后,還需要判斷別的正在等待的會話是否可以被喚醒( lock_rec_dequeue_from_page )。例如如果當前釋放的是某個記錄的X鎖,那么所有的S鎖請求的會話都可以被喚醒。

這里的移除鎖和檢查的邏輯開銷比較大,尤其是大量線程在等待少量幾個鎖時。當某個鎖從hash鏈上移除時,InnoDB實際上通過遍歷相同page上的所有等待的鎖,并判斷這些鎖等待是否可以被喚醒。而判斷喚醒的邏輯又一次遍歷,這是因為當前的鏈表維護是基于的,并不是基于Heap no構建的。關于這個問題的討論,可以參閱 bug#53825 。官方開發Sunny也提到雖然使用來構建鏈表,移除Bitmap會浪費更多的內存,但效率更高,而且現在的內存也沒有以前那么昂貴。

對于表鎖,如果表級鎖的類型不為LOCK_IS,且當前事務修改了數據,就將表對象的 dict_table_t::query_cache_inv_id 設置為當前最大的事務id。 在檢查是否可以使用該表的Query Cache時會使用該值進行判斷( row_search_check_if_query_cache_permitted ),如果某個用戶會話的事務對象的low_limit_id(即最大可見事務id)比這個值還小,說明它不應該使用當前table cache的內容,也不應該存儲到query cache中。

表級鎖對象的釋放調用函數 lock_table_dequeue

注意在釋放鎖時,如果該事務持有的鎖對象太多,每釋放1000( LOCK_RELEASE_INTERVAL )個鎖對象,會暫時釋放下 lock_sys->mutex 再重新持有,防止InnoDB hang住。

兩個有趣的案例

本小節我們來分析幾個比較有趣的死鎖案例。

普通的并發插入導致的死鎖

create table t1 (a int primary key);開啟三個會話執行: insert into t1(a) values (2);

session 1 session 2 session 3
BEGIN; INSERT..
INSERT (block),為session1創建X鎖,并等待S鎖
INSERT (block, 同上等待S鎖)
ROLLBACK,釋放鎖
獲得S鎖 獲得S鎖
申請插入意向X鎖,等待session3
申請插入意向X鎖,等待session2

上述描述了互相等待的場景,因為插入意向X鎖和S鎖是不相容的。這也是一種典型的鎖升級導致的死鎖。 如果session1執行COMMIT的話,則另外兩個線程都會因為duplicate key失敗。

這里需要解釋下為何要申請插入意向鎖,因為ROLLBACK時原紀錄回滾時是被標記刪除的。而我們嘗試插入的紀和這個標記刪除的紀錄是相鄰的(鍵值相同),根據插入意向鎖的規則,插入位置的下一條紀錄上如果存在與插入意向X鎖沖突的鎖時,則需要獲取插入意向X鎖。

另外一種類似(但產生死鎖的原因不同)的場景是在一張同時存在聚集索引和唯一索引的表上,通過replace into的方式插入沖突的唯一鍵,可能會產生死鎖,在3月份的月報,我已經專門描述過這個問題,感興趣的可以 延伸閱讀下

又一個并發插入的死鎖現象

兩個會話參與。在RR隔離級別下

例表如下:

create table t1 (a int primary key ,b int);

insert into t1 values (1,2),(2,3),(3,4),(11,22);

session 1 session 2
begin;select * from t1 where a = 5 for update;(獲取記錄(11,22)上的GAP X鎖)
begin;select * from t1 where a = 5 for update; (同上,GAP鎖之間不沖突
insert into t1 values (4,5); (block,等待session1)
insert into t1 values (4,5);(需要等待session2,死鎖)

引起這個死鎖的原因是非插入意向的GAP X鎖和插入意向X鎖之間是沖突的。

</code></code></code></code></code></div>

 本文由用戶 JorgeSchmit 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!