簡介:本文將介紹在 MDL 系統中常用的數據結構及含義,然后從實現角度討論 MDL 的獲取機制與死鎖檢測,最后分享在實踐中如何監控 MDL 狀態。
作者 | 泊歌
來源 | 阿里技術公眾號
一 背景
為了滿足數據庫在並發請求下的事務隔離性和一致性要求,同時針對 MySQL 插件式多種存儲引擎都能發揮作用,MySQL 在 Server 層實現了 Metadata Locking(MDL)機制。達到的效果比如可以在事務訪問數據庫的某種資源時,限制其他並發事務刪除該資源。這是一種邏輯意義上的鎖,與操作系統內核提供的有限種類 mutex 不同,MDL 可以靈活自定義鎖的對象、鎖的類型以及不同鎖類型的優先級,甚至可以做到在系統不同狀態時動態調整不同鎖類型的兼容性,極大的方便了數據庫對各種查詢請求進行合理的並發控制。
本文將介紹在 MDL 系統中常用的數據結構及含義,然后從實現角度討論 MDL 的獲取機制與死鎖檢測,最后分享在實踐中如何監控 MDL 狀態。
二 基本概念
1 MDL_key
MDL 的對象是采用鍵值對(key-value)的方式描述的,每一個 key 值都唯一的代表了鎖的對象(value 代表數據庫的某種資源)。key 是由 MDL_key 表示的,用字符串的形式表示了對象的名稱。
對象的名稱根據類型的不同可以由多種層次組成。比如表對象就由數據庫名和表名唯一的描述;如果是 SCHEMA 對象,那就只有數據庫名這一個層次。名稱之間用字符串結束符 '\0' 分隔。因此由這幾部分組成的字符串整體就能作為 key 唯一的表示數據庫的某種對象。
2 enum_mdl_type
對於同一個數據庫對象而言,不同的查詢也有着不同的訪問模式,比如 SELECT 語句是想要讀取對象的內容,INSERT / UPDATE 語句是想要修改對象的內容,DDL 語句是想要修改對象的結構和定義。這些語句對於對象的影響程度和並發隔離性的要求不同,因此 MySQL 定義了不同類型的 MDL 以及他們之間的兼容性來控制這些語句的並發訪問。
MDL 的類型由 enum_mdl_type 表示,最常用的類型包括:
- MDL_SHARED(S),可以共享訪問對象的元數據,比如 SHOW CREATE TABLE 語句
- MDL_SHARED_READ(SR),可以共享訪問對象的數據,比如 SELECT 語句
- MDL_SHARED_WRITE(SW),可以修改對象的數據,比如 INSERT / UPDATE 語句
- MDL_SHARED_UPGRADABLE(SU),可升級的共享鎖,后面可升級到更強的鎖(比如 X 鎖,阻塞並發訪問),比如 DDL 的第一階段
- MDL_EXCLUSIVE(X),獨占鎖,阻塞其他線程對該對象的並發訪問,可以修改對象的元數據,比如 DDL 的第二階段
不同的查詢語句通過請求不同類型的 MDL,結合不同類型的 MDL 之間靈活定制的兼容性,就可以對相互沖突的語句進行並發控制。對於同一對象而言,不同類型的 MDL 之間的默認兼容性如下所述。
不同類型的 MDL 兼容性
MySQL 將鎖類型划分為范圍鎖和對象鎖。
1)范圍鎖
范圍鎖種類較少(IX、S、X),主要用於 GLOBAL、COMMIT、TABLESPACE、BACKUP_LOCK 和 SCHEMA 命名空間的對象。這幾種類型的兼容性簡單,主要是從整體上去限制並發操作,比如全局的讀鎖來阻塞事務提交、DDL 更新表對象的元信息通過請求 SCHEMA 范圍的意向獨占鎖(IX)來阻塞 SCHEMA 層面的修改操作。
這幾種類型的 MDL 兼容性關系由兩個矩陣定義。對於同一個對象來說,一個是已經獲取到的 MDL 類型對新請求類型的兼容性情況;另一個是未獲取到,正在等待的 MDL 請求類型對新請求類型的兼容性。由於 IS(INTENTION_SHARE) 在所有情況下與其他鎖都兼容,在 MDL 系統中可忽略。
Here: "+" -- means that request can be satisfied
"-" -- means that request can't be satisfied and should wait
2)對象鎖
對象鎖包含的 MDL 類型比較豐富,應用於數據庫絕大多數的基本對象。它們的兼容性矩陣如下:
在 MDL 獲取過程中,通過這兩個兼容性矩陣,就可以判斷當前是否存在與請求的 MDL 不兼容的 granted / pending 狀態的 MDL,來決定該請求是否能被滿足,如果不能被滿足則進入 pending 等待狀態。
MDL 系統也通過兼容性矩陣來判斷鎖類型的強弱,方法如下:
表達式的寫法有點繞,可以理解為,如果 type 類型與某種 m_type 類型兼容的 MDL 不兼容,那么 type 類型更強;否則 m_type 類型相同或更強。或者較弱的類型不兼容的 MDL 類型,較強的 MDL 都不兼容。
三 重要數據結構
1 關系示意圖
代表着語句對 MDL 的請求,由 MDL_key 、enum_mdl_type 和 enum_mdl_duration 組成,MDL_key 和 enum_mdl_type 確定了 MDL 的對象和鎖類型。
enum_mdl_duration 有三種類型,表示 MDL 的持有周期,有單條語句級的周期、事務級別的、和顯式周期。
MDL_request 的生命周期是在 MDL 系統之外,由用戶控制的,可以是一個臨時變量。但是通過該請求獲取到的 MDL 生命周期是持久的,由 MDL 系統控制,並不會隨着 MDL_request 的銷毀而釋放。
3 MDL_lock
對於數據庫的某一對象,僅有一個與其名字(MDL_key)對應的鎖對象 MDL_lock 存在。當數據庫的對象在初次被訪問時,由 lock-free HASH 在其內存中創建和管理 MDL_lock;當后續訪問到來時,對於相同對象的訪問會引用到同一個 MDL_lock。
MDL_lock 中既有當前正在等待該鎖對象的 m_waiting 隊列,也有該對象已經授予的 m_granted 隊列,隊列中的元素用 MDL_ticket 表示。
使用靜態 bitmap 對象組成的 MDL_lock_strategy 來存放上述范圍鎖和對象鎖的兼容性矩陣,根據 MDL_lock 的命名空間就可以獲取到該鎖的兼容性情況。
4 MDL_ticket
MDL_lock 與 enum_mdl_type 共同組成了 MDL_ticket,代表着當前線程對數據庫對象的訪問權限。MDL_ticket 在每個查詢請求 MDL 鎖時創建,內存由 MDL 系統分配,在事務結束時摧毀。
MDL_ticket 中包含兩組指針分別將該線程獲取到的所有 ticket 連接起來和將該 ticket 參與的鎖對象的 waiting 狀態或者 granted 狀態的 ticket 連接起來。
5 MDL_context
一個線程獲取 MDL 鎖的上下文,每個連接都對應一個,包含了該連接獲取到的所有 MDL_ticket。按照不同的生命周期存放在各自的鏈表中,由 MDL_ticket_store 管理。
一個連接獲得的所有鎖根據生命周期可以划分為三種:語句級,事務級和顯式鎖。語句級和事務級的鎖都是有着自動的生命周期和作用范圍,他們在一個事務過程中進行積累。語句級的鎖在最外層的語句結束后自動釋放,事務級的鎖在COMMIT、ROLLBACK 和 ROLLBACK TO SAVEPOINT 之后釋放,他們不會被手動釋放。具有顯式生命周期的ticket 是為了跨事務和 checkpoint 的鎖所獲取的,包括 HANDLER SQL locks、LOCK TABLES locks 和用戶級的鎖 GET_LOCK()/RELEASE_LOCK()。語句級和事務級的鎖會按照時間順序的反序被加到對應鏈表的前面,當我們回滾到某一檢查點時,就會從鏈表的前面將對應的 ticket 釋放出棧,直到檢查點創建前最后一個獲取到的 ticket。
當一個線程想要獲取某個 MDL 鎖時,會優先在自己的 MDL_ticket_store 中查找是否在事務內已經獲取到相同鎖對象更強類型的 MDL_ticket。因此 MDL_ticket_store 會提供根據 MDL_request 請求查找 MDL_ticket 的接口,一種是在不同生命周期的 MDL_ticket 鏈表中查找;如果當前線程獲取的 MDL_ticket 數量超過閾值(默認256),會將所有的 MDL_ticket 維護在額外的 std::unordered_multimap 中,來加速查找。
四 MDL 獲取過程
幾乎所有的查詢語句(包括 DML 和 DDL 第一階段)都是在 parse 階段,由 LEX 和 YACC 根據語句的類型給需要訪問的表初始化 MDL 鎖請求,比如 SELECT 語句就是 SR,INSERT 語句就是 SW,ALTER TABLE 語句就是 SU。這個過程在以下調用棧中:
語句在執行前會首先通過 open_tables_for_query 函數將所有需要訪問的表打開,獲得 TABLE 表對象。在這個過程中會先獲取 MDL 鎖,然后才獲取表資源,防止對同一個表的元信息出現並發讀寫。對 MDL 鎖的請求都是由當前線程的上下文 MDL_context 調用 MDL_context::acquire_lock 進行的,調用棧如下:
1 MDL_context::try_acquire_lock_impl
接下來我們重點看一下 MDL_context::try_acquire_lock_impl 的過程。這個函數包含了各種類型鎖(兼容性好的,兼容性差的)的獲取以及鎖沖突檢測,傳入參數是當前的 MDL_request,輸出參數為獲取到的 MDL_ticket。
首先會根據 MDL_request 在當前線程已持有的相同對象 MDL_ticket 中查找類型更強、生命周期相同或不同的 ticket。如果已經持有相同生命周期的,那么直接返回;持有不同生命周期的,根據 ticket 克隆出一個相同周期的返回即可。
我們在前面提到了根據鎖類型的兼容性情況,可以划分為 unobtrusive 和 obtrusive 的鎖,在鎖獲取過程中也分別對應 fast path 和 slow path,代表獲取的難易度不同。
Unobtrusive(fast path)
對於一些弱類型(unobtrusive,例如 SR/SW 等)的 MDL 請求,由於這部分的請求占絕大多數,且兼容性較好,獲取后不用記錄下是哪個具體的 MDL_ticket,只需要記錄有多少請求已獲取。因此在 MDL_lock 中使用整型原子變量 std::atomic m_fast_path_state 來統計該鎖授予的所有 unobtrusive 的鎖類型數量,每種 unobtrusive 的鎖有不同的數值表示,留下固定的 bit 范圍存放該種鎖類型累加后的結果,相當於用一個 longlong 類型統計了所有 unobtrusive 鎖的授予個數,同時可以通過 CAS 無鎖修改。另外在 m_fast_path_state 的高位 bit,還存在三個狀態指示位,分別是 IS_DESTROYED/HAS_OBTRUSIVE/HAS_SLOW_PATH。
根據 MDL_request 的請求類型,獲取對應類型的 unobtrusive 整型遞增值,如果遞增值為 0,則代表是 obtrusive 的鎖,需要走 slow path。
如果非 0,代表着該類型鎖是 unobtrusive,就會走 fast path,直接通過 CAS 來給 MDL_lock::m_fast_path_state 遞增上對應的整型值即可。但是需要確認一個條件,就是該對象沒有被其他線程以 obtrusive 的方式鎖住,因為 unobtrusive 和 obtrusive 的鎖類型有些是互斥的,只有在沒有 obtrusive 的鎖存在時,其他的 unobtrusive 鎖彼此兼容,才可以不用判斷其他線程的鎖持有情況直接獲取。
CAS 完成后,設置相關數據結構的狀態和引用,將當前 MDL_ticket 加入到線程的 MDL_ticket_store 中即可返回:
Obtrusive(slow path)
對於一些比較強類型(obtrusive,例如 SU/SRO/X 等)的 MDL 請求,會在對應 MDL_lock 的 m_granted 鏈表中存放對應的 MDL_ticket。因此在獲取時也需要遍歷這個鏈表和其他的 bitmap 來判斷與其他線程已獲取或者正在等待的 MDL_ticket 是否存在鎖沖突。
需要走 slow path 獲取鎖之前,當前線程需要將 MDL_lock::m_fast_path_state 中由當前線程之前通過 fast path 獲取到的鎖物化,從 bitmap 中移出,加入到 MDL_lock::m_granted 中。因為在 MDL_lock::m_fast_path_state 中包含的 bitmap 是無法區分線程的,而當前線程獲取的多個鎖之間是不構成鎖沖突的,所以在通過 bitmap 判斷前,需要確保 MDL_lock::m_fast_path_state 的 ticket 都是屬於其他線程的。
在物化完成后,就可以通過當前鎖正在等待的 ticket 類型(m_waiting)、已經授予的 ticket 類型(m_granted)和 unobtrusive 的鎖類型狀態(MDL_lock::m_fast_path_state),結合前面的兼容性矩陣來判斷當前請求的鎖類型是否能獲取到,這個過程主要在 MDL_lock::can_grant_lock 中。
在 m_waiting 和 m_granted 中,除了有鏈表將 ticket 連接起來,也會用 bitmap 收集鏈表中所有 ticket 的類型,方便直接進行比較。在 m_granted 中發現不兼容類型后,還需要遍歷鏈表,判斷不兼容類型的 ticket 是不是當前線程獲取的,只有是非當前線程獲取的情況下,才出現鎖沖突。unobtrusive 的鎖如果能獲取的話,會直接加入到 MDL_lock::m_granted 鏈表中。
2 鎖等待和通知
上述過程中,如果能順利獲取到 MDL_ticket,就完成了 MDL 的獲取,可以繼續查詢過程。如果無法獲取到(不管是 unobtrusive 的鎖由於 obtrusive 的鎖存在而被迫走 slow path,還是本身 obtrusive 的鎖無法獲取),就需要進行鎖等待,鎖等待的過程是不區分是否為 unobtrusive 還是 obtrusive 的,統一進行處理。
每個線程的 MDL_context 中包含一個 MDL_wait 成員,因為鎖等待以及死鎖檢測都是以線程為對象,通過將對應請求的 MDL_ticket 加入到鎖等待者隊列中來訂閱通知。有一組 mutex、condition variable 和枚舉狀態用來完成線程間的等待、通知。等待的狀態包括五種:
WS_EMPTY 為初始狀態,其他的都是等待的結果狀態,從命令可以看出,等待的結果分別可能是:
- GRANTED,該線程獲取到了等待的 MDL 鎖
- VICTIM,該線程作為死鎖的受害者,要求重新執行事務
- TIMEOUT,等待超時
- KILLED,該線程在等待過程中被 kill 掉
等待的線程首先將自己想要獲取的 ticket 加入到 MDL_lock 的 m_waiting 隊列,然后根據配置的等待時間調用 MDL_wait 的函數進行超時等待:
當其他持有不兼容類型鎖的線程查詢完成或者事務結束時,會一塊釋放持有的所有鎖,同時根據是否是 fast path 還是 slow path 路徑獲取到的,恢復 MDL_lock::m_fast_path_state 的狀態和 MDL_lock::m_granted 鏈表。除此之外,如果 MDL_lock::m_waiting 存在正在等待的 ticket,就會調用 MDL_lock::reschedule_waiters() 來喚醒可以獲取到鎖的線程,並設置等待狀態為 GRANTED:
被喚醒的等待線程,如果發現 ticket 是 GRANTED 狀態,就會繼續執行;否則根據不同情況報錯。
3 死鎖檢測
每個線程在進入鎖等待之前,都會進行一次死鎖檢測,避免當前線程陷入死等。在檢測死鎖前,首先將當前線程所獲取到的 unobtrusive 鎖物化,這樣這些鎖才會出現在 MDL_lock::m_granted 鏈表中,死鎖檢測才有可能探測到。並且設置當前線程的等待鎖 MDL_context::m_waiting_for 為當前的 ticket,每個進入等待的線程都會設置等待對象,沿着這條等待鏈就可以檢測死鎖。
MDL_wait_for_subgraph
代表等待圖中一條邊的抽象類,會由死鎖檢測算法進行遍歷。MDL_ticket 派生於 MDL_wait_for_subgraph,通過實現 accept_visitor() 函數來讓輔助檢測類順着邊尋找等待環。
Deadlock_detection_visitor
在等待圖中檢測等待環的輔助類,包含檢測過程中的狀態信息,比如死鎖檢測的起始線程 m_start_node;在搜索過程中發生死鎖后,根據權重選擇的受害者線程 m_victim;搜索的線程深度,假如線程的等待鏈過長,超過了閾值(默認32),即使沒檢測到死鎖也認為死鎖發生。
實現 enter_node() 和 leave_node() 函數來進入下一線程節點和退出,通過 inspect_edge() 來發現是否當前線程節點已經是起始節點從而判斷成環。通過 opt_change_victim_to() 來比較受害者的死鎖權重來決定受害者。
檢測過程
死鎖檢測的思路是,首先廣度優先,從當前線程等待的鎖出發,遍歷 MDL_lock 的等待隊列和授予隊列,看看是否有非當前線程獲取的、與等待的鎖不兼容的鎖存在,如果這個持有線程與算法遍歷的起點線程相同,那么鎖等待鏈存在死鎖;其次深度優先,從不兼容的鎖的持有或等待線程出發,如果該線程也處於等待狀態,那么遞歸重復前述過程,直到找到等待起點線程,否則判斷不存在死鎖。代碼邏輯如下:
受害者權重
在檢測到死鎖后,沿着線程等待鏈退出的時候,會根據每個線程等待 ticket 的權重,選擇權重最小的作為受害者,讓其放棄等待並釋放持有的鎖,在 Deadlock_detection_visitor::opt_change_victim_to 函數中。
在權重方面做的還是比較粗糙的,並不考慮事務進行的階段,以及執行的語句內容,僅僅是根據鎖資源的類型和鎖種類有一個預設的權重,在 MDL_ticket::get_deadlock_weight() 函數中。
- DEADLOCK_WEIGHT_DML,DML 類型語句的權重最小為 0
- DEADLOCK_WEIGHT_ULL,用戶手動上鎖的權重居中為 50
- DEADLOCK_WEIGHT_DDL,DDL 類型語句的權重最大為 100
由此可見,在發生死鎖時更偏向於讓 DML 語句報錯回滾,讓 DDL 語句繼續執行。當同類型語句構成死鎖時,更偏向讓后進入等待鏈的線程成為受害者,讓等待的比較久的線程繼續等待。
當前線程將死鎖環上受害者線程的狀態設置為 VICTIM 並喚醒后,當前線程即可進入等待狀態。
五 MDL 監控
通過 MySQL performance_schema 可以清晰的監控當前 MDL 鎖的獲取情況,performance_schema 是一個只讀變量,設置需重啟,在配置文件中添加:
通過 performance_schema.setup_instruments 表設置 MDL 監控項:
之后我們就可以訪問 performance_schema.metadata_locks 表來監控 MDL 獲取情況,比如有兩個線程處於以下狀態:
線程1事務沒提交,導致線程2做 DDL hang 住,訪問 performance_schema.metadata_locks 可以看到是因為線程1持有 t1 的 SHARED_READ 鎖,導致需要獲取 EXCLUSIVE 鎖的線程2處於等待狀態。
六 PolarDB 在 MDL 上的優化
在 MySQL 社區版中,對分區表數據的訪問操作(DML)與分區維護操作(DDL)是相互阻塞的,主要的原因是 DDL 需要獲取分區表上的 MDL_EXCLUSIVE 鎖。這使得分區維護操作只能在業務低峰時段進行,而且對分區表進行創建/刪除分區的需求是比較頻繁的,極大限制了分區表的使用。
在 PolarDB 中,我們引入了分區級別的 MDL 鎖,使 DML 和 DDL 獲取的鎖粒度降低到分區級,提高了並發度,實現了“在線”分區維護功能。使得分區表的數據訪問和分區維護不相互影響,用戶可以更自由的進行分區維護,而不影響分區表業務流量,大大增強了分區表使用的靈活度。
該功能已經在 PolarDB 8.0.2.2.0 及以上版本中發布,歡迎用戶使用。
七 參考
[1] Source code mysql / mysql-server 8.0.18:
本文為阿里雲原創內容,未經允許不得轉載。