1. 程式人生 > 其它 >MySQL 深潛 - MDL 鎖的實現與獲取機制

MySQL 深潛 - MDL 鎖的實現與獲取機制

簡介:本文將介紹在 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 表示的,用字串的形式表示了物件的名稱。

完整的字串由 namespace、按層次每一級的名稱組成,多種名稱空間可以將不同型別的同名物件區分開。名稱空間包括 GLOBAL、SCHEMA、TABLE、FUNCTION、PROCEDURE 等資料庫中可以建立的不同物件型別組成。

物件的名稱根據型別的不同可以由多種層次組成。比如表物件就由資料庫名和表名唯一的描述;如果是 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 系統中可忽略。


        | Type of active   |
Request |   scoped lock    |
 type   | IS(*)  IX   S  X |
---------+------------------+
IS       |  +      +   +  + |
IX       |  +      +   -  - |
S        |  +      -   +  - |
X        |  +      -   -  - |
        
         |    Pending      |
 Request |  scoped lock    |
  type   | IS(*)  IX  S  X |
 ---------+-----------------+
IS       |  +      +  +  + |
IX       |  +      +  -  - |
S        |  +      +  +  - |
X        |  +      +  +  + |

Here: "+" -- means that request can be satisfied
"-" -- means that request can't be satisfied and should wait

2)物件鎖

物件鎖包含的 MDL 型別比較豐富,應用於資料庫絕大多數的基本物件。它們的相容性矩陣如下:

  Request  |  Granted requests for lock            |
   type    | S  SH  SR  SW  SWLP  SU  SRO  SNW  SNRW  X  |
 ----------+---------------------------------------------+
 S         | +   +   +   +    +    +   +    +    +    -  |
 SH        | +   +   +   +    +    +   +    +    +    -  |
 SR        | +   +   +   +    +    +   +    +    -    -  |
 SW        | +   +   +   +    +    +   -    -    -    -  |
 SWLP      | +   +   +   +    +    +   -    -    -    -  |
 SU        | +   +   +   +    +    -   +    -    -    -  |
 SRO       | +   +   +   -    -    +   +    +    -    -  |
 SNW       | +   +   +   -    -    -   +    -    -    -  |
 SNRW      | +   +   -   -    -    -   -    -    -    -  |
 X         | -   -   -   -    -    -   -    -    -    -  |
        
  Request  |         Pending requests for lock          |
   type    | S  SH  SR  SW  SWLP  SU  SRO  SNW  SNRW  X |
 ----------+--------------------------------------------+
 S         | +   +   +   +    +    +   +    +     +   - |
 SH        | +   +   +   +    +    +   +    +     +   + |
 SR        | +   +   +   +    +    +   +    +     -   - |
 SW        | +   +   +   +    +    +   +    -     -   - |
 SWLP      | +   +   +   +    +    +   -    -     -   - |
 SU        | +   +   +   +    +    +   +    +     +   - |
 SRO       | +   +   +   -    +    +   +    +     -   - |
 SNW       | +   +   +   +    +    +   +    +     +   - |
 SNRW      | +   +   +   +    +    +   +    +     +   - |
 X         | +   +   +   +    +    +   +    +     +   + |
        
  Here: "+" -- means that request can be satisfied
        "-" -- means that request can't be satisfied and should wait

在 MDL 獲取過程中,通過這兩個相容性矩陣,就可以判斷當前是否存在與請求的 MDL 不相容的 granted / pending 狀態的 MDL,來決定該請求是否能被滿足,如果不能被滿足則進入 pending 等待狀態。

MDL 系統也通過相容性矩陣來判斷鎖型別的強弱,方法如下:

/**
  Check if ticket represents metadata lock of "stronger" or equal type
  than specified one. I.e. if metadata lock represented by ticket won't
  allow any of locks which are not allowed by specified type of lock.

  @return true  if ticket has stronger or equal type
          false otherwise.
*/
bool MDL_ticket::has_stronger_or_equal_type(enum_mdl_type type) const {
  const MDL_lock::bitmap_t *granted_incompat_map =
      m_lock->incompatible_granted_types_bitmap();

  return !(granted_incompat_map[type] & ~(granted_incompat_map[m_type]));
}

表示式的寫法有點繞,可以理解為,如果 type 型別與某種 m_type 型別相容的 MDL 不相容,那麼 type 型別更強;否則 m_type 型別相同或更強。或者較弱的型別不相容的 MDL 型別,較強的 MDL 都不相容。

三 重要資料結構

1 關係示意圖

2 MDL_request

代表著語句對 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_ticket_store::MDL_ticket_handle MDL_ticket_store::find(
    const MDL_request &req) const {
#ifndef DBUG_OFF
  if (m_count >= THRESHOLD) {
    MDL_ticket_handle list_h = find_in_lists(req);
    MDL_ticket_handle hash_h = find_in_hash(req);

    DBUG_ASSERT(equivalent(list_h.m_ticket, hash_h.m_ticket, req.duration));
  }
#endif /*! DBUG_OFF */
  return (m_map == nullptr || m_count < THRESHOLD) ? find_in_lists(req)
                                                   : find_in_hash(req);
}

四 MDL 獲取過程

幾乎所有的查詢語句(包括 DML 和 DDL 第一階段)都是在 parse 階段,由 LEX 和 YACC 根據語句的型別給需要訪問的表初始化 MDL 鎖請求,比如 SELECT 語句就是 SR,INSERT 語句就是 SW,ALTER TABLE 語句就是 SU。這個過程在以下呼叫棧中:

PT_table_factor_table_ident::contextualize()
  |--SELECT_LEX::add_table_to_list()
    |--MDL_REQUEST_INIT -> MDL_request::init_with_source()

語句在執行前會首先通過 open_tables_for_query 函式將所有需要訪問的表開啟,獲得 TABLE 表物件。在這個過程中會先獲取 MDL 鎖,然後才獲取表資源,防止對同一個表的元資訊出現併發讀寫。對 MDL 鎖的請求都是由當前執行緒的上下文 MDL_context 呼叫 MDL_context::acquire_lock 進行的,呼叫棧如下:

open_tables_for_query()
  |--open_table() // 迴圈開啟每一個表
    |--open_table_get_mdl_lock()
      |--MDL_context::acquire_lock() // 獲取lock,如果遇到鎖衝突,那麼等待衝突的鎖被釋放
        |--MDL_context::try_acquire_lock_impl()

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。

/**
   Array of increments for "unobtrusive" types of lock requests for
   per-object locks.

   @sa MDL_lock::get_unobtrusive_lock_increment().

   For per-object locks:
   - "unobtrusive" types: S, SH, SR and SW
   - "obtrusive" types: SU, SRO, SNW, SNRW, X

   Number of locks acquired using "fast path" are encoded in the following
   bits of MDL_lock::m_fast_path_state:

   - bits 0 .. 19  - S and SH (we don't differentiate them once acquired)
   - bits 20 .. 39 - SR
   - bits 40 .. 59 - SW and SWLP (we don't differentiate them once acquired)

   Overflow is not an issue as we are unlikely to support more than 2^20 - 1
   concurrent connections in foreseeable future.

   This encoding defines the below contents of increment array.
*/
{0, 1, 1, 1ULL << 20, 1ULL << 40, 1ULL << 40, 0, 0, 0, 0, 0},

根據 MDL_request 的請求型別,獲取對應型別的 unobtrusive 整型遞增值,如果遞增值為 0,則代表是 obtrusive 的鎖,需要走 slow path。

/**
  @returns "Fast path" increment for request for "unobtrusive" type
            of lock, 0 - if it is request for "obtrusive" type of
            lock.

  @sa Description at method declaration for more details.
*/
MDL_lock::fast_path_state_t MDL_lock::get_unobtrusive_lock_increment(
    const MDL_request *request) {
  return MDL_lock::get_strategy(request->key)
      ->m_unobtrusive_lock_increment[request->type];
}

如果非 0,代表著該型別鎖是 unobtrusive,就會走 fast path,直接通過 CAS 來給 MDL_lock::m_fast_path_state 遞增上對應的整型值即可。但是需要確認一個條件,就是該物件沒有被其他執行緒以 obtrusive 的方式鎖住,因為 unobtrusive 和 obtrusive 的鎖型別有些是互斥的,只有在沒有 obtrusive 的鎖存在時,其他的 unobtrusive 鎖彼此相容,才可以不用判斷其他執行緒的鎖持有情況直接獲取。

MDL_lock::fast_path_state_t old_state = lock->m_fast_path_state;

do {
  /*
    Check if hash look-up returned object marked as destroyed or
    it was marked as such while it was pinned by us. If yes we
    need to unpin it and retry look-up.
  */
  if (old_state & MDL_lock::IS_DESTROYED) {
    if (pinned) lf_hash_search_unpin(m_pins);
    goto retry;
  }

  /*
    Check that there are no granted/pending "obtrusive" locks and nobody
    even is about to try to check if such lock can be acquired.

    In these cases we need to take "slow path".
  */
  if (old_state & MDL_lock::HAS_OBTRUSIVE) goto slow_path;

  } while (!lock->fast_path_state_cas(
      &old_state, old_state + unobtrusive_lock_increment));

CAS 完成後,設定相關資料結構的狀態和引用,將當前 MDL_ticket 加入到執行緒的 MDL_ticket_store 中即可返回:

/*
  Since this MDL_ticket is not visible to any threads other than
  the current one, we can set MDL_ticket::m_lock member without
  protect of MDL_lock::m_rwlock. MDL_lock won't be deleted
  underneath our feet as MDL_lock::m_fast_path_state serves as
  reference counter in this case.
*/
ticket->m_lock = lock;
ticket->m_is_fast_path = true;
m_ticket_store.push_front(mdl_request->duration, ticket);
mdl_request->ticket = ticket;

mysql_mdl_set_status(ticket->m_psi, MDL_ticket::GRANTED);

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 都是屬於其他執行緒的。

/**
  "Materialize" requests for locks which were satisfied using
  "fast path" by properly including them into corresponding
  MDL_lock::m_granted bitmaps/lists and removing it from
  packed counter in MDL_lock::m_fast_path_state.
*/
void MDL_context::materialize_fast_path_locks() {
  int i;

  for (i = 0; i < MDL_DURATION_END; i++) {
    MDL_ticket_store::List_iterator it = m_ticket_store.list_iterator(i);

    MDL_ticket *matf = m_ticket_store.materialized_front(i);
    for (MDL_ticket *ticket = it++; ticket != matf; ticket = it++) {
      if (ticket->m_is_fast_path) {
        MDL_lock *lock = ticket->m_lock;
        MDL_lock::fast_path_state_t unobtrusive_lock_increment =
            lock->get_unobtrusive_lock_increment(ticket->get_type());
        ticket->m_is_fast_path = false;
        mysql_prlock_wrlock(&lock->m_rwlock);
        lock->m_granted.add_ticket(ticket);
        /*
          Atomically decrement counter in MDL_lock::m_fast_path_state.
          This needs to happen under protection of MDL_lock::m_rwlock to make
          it atomic with addition of ticket to MDL_lock::m_granted list and
          to enforce invariant [INV1].
        */
        MDL_lock::fast_path_state_t old_state = lock->m_fast_path_state;
        while (!lock->fast_path_state_cas(
            &old_state, ((old_state - unobtrusive_lock_increment) |
                         MDL_lock::HAS_SLOW_PATH))) {
        }
        mysql_prlock_unlock(&lock->m_rwlock);
      }
    }
  }
  m_ticket_store.set_materialized();
}

在物化完成後,就可以通過當前鎖正在等待的 ticket 型別(m_waiting)、已經授予的 ticket 型別(m_granted)和 unobtrusive 的鎖型別狀態(MDL_lock::m_fast_path_state),結合前面的相容性矩陣來判斷當前請求的鎖型別是否能獲取到,這個過程主要在 MDL_lock::can_grant_lock 中。

bool MDL_lock::can_grant_lock(enum_mdl_type type_arg,
                              const MDL_context *requestor_ctx) const {
  bool can_grant = false;
  bitmap_t waiting_incompat_map = incompatible_waiting_types_bitmap()[type_arg];
  bitmap_t granted_incompat_map = incompatible_granted_types_bitmap()[type_arg];

  /*
    New lock request can be satisfied iff:
    - There are no incompatible types of satisfied requests
    in other contexts
    - There are no waiting requests which have higher priority
    than this request.
  */
  if (!(m_waiting.bitmap() & waiting_incompat_map)) {
    if (!(fast_path_granted_bitmap() & granted_incompat_map)) {
      if (!(m_granted.bitmap() & granted_incompat_map))
        can_grant = true;
      else {
        Ticket_iterator it(m_granted);
        MDL_ticket *ticket;

        /*
          There is an incompatible lock. Check that it belongs to some
          other context.
        */
        while ((ticket = it++)) {
          if (ticket->get_ctx() != requestor_ctx &&
              ticket->is_incompatible_when_granted(type_arg))
            break;
        }
        if (ticket == NULL) /* Incompatible locks are our own. */
          can_grant = true;
      }
    }
  }
  return can_grant;
}

在 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 since EMPTY conflicts with #define in system headers on some
// platforms.
enum enum_wait_status { WS_EMPTY = 0, GRANTED, VICTIM, TIMEOUT, KILLED };

WS_EMPTY 為初始狀態,其他的都是等待的結果狀態,從命令可以看出,等待的結果分別可能是:

  • GRANTED,該執行緒獲取到了等待的 MDL 鎖
  • VICTIM,該執行緒作為死鎖的受害者,要求重新執行事務
  • TIMEOUT,等待超時
  • KILLED,該執行緒在等待過程中被 kill 掉

等待的執行緒首先將自己想要獲取的 ticket 加入到 MDL_lock 的 m_waiting 佇列,然後根據配置的等待時間呼叫 MDL_wait 的函式進行超時等待:

/**
  Wait for the status to be assigned to this wait slot.
*/
MDL_wait::enum_wait_status MDL_wait::timed_wait(
    MDL_context_owner *owner, struct timespec *abs_timeout,
    bool set_status_on_timeout, const PSI_stage_info *wait_state_name) {
  enum_wait_status result;
  int wait_result = 0;

  mysql_mutex_lock(&m_LOCK_wait_status);

  while (!m_wait_status && !owner->is_killed() && !is_timeout(wait_result)) {
    wait_result = mysql_cond_timedwait(&m_COND_wait_status, &m_LOCK_wait_status,
                                       abs_timeout);
  }

  if (m_wait_status == WS_EMPTY) {
    if (owner->is_killed())
      m_wait_status = KILLED;
    else if (set_status_on_timeout)
      m_wait_status = TIMEOUT;
  }
  result = m_wait_status;

  mysql_mutex_unlock(&m_LOCK_wait_status);

  return result;
}

當其他持有不相容型別鎖的執行緒查詢完成或者事務結束時,會一塊釋放持有的所有鎖,同時根據是否是 fast path 還是 slow path 路徑獲取到的,恢復 MDL_lock::m_fast_path_state 的狀態和 MDL_lock::m_granted 連結串列。除此之外,如果 MDL_lock::m_waiting 存在正在等待的 ticket,就會呼叫 MDL_lock::reschedule_waiters() 來喚醒可以獲取到鎖的執行緒,並設定等待狀態為 GRANTED:

void MDL_lock::reschedule_waiters() {
  MDL_lock::Ticket_iterator it(m_waiting);
  MDL_ticket *ticket;

  while ((ticket = it++)) {
    if (can_grant_lock(ticket->get_type(), ticket->get_ctx())) {
      if (!ticket->get_ctx()->m_wait.set_status(MDL_wait::GRANTED)) {
        m_waiting.remove_ticket(ticket);
        m_granted.add_ticket(ticket);
 ...
/**
  Set the status unless it's already set. Return false if set,
  true otherwise.
*/
bool MDL_wait::set_status(enum_wait_status status_arg) {
  bool was_occupied = true;
  mysql_mutex_lock(&m_LOCK_wait_status);
  if (m_wait_status == WS_EMPTY) {
    was_occupied = false;
    m_wait_status = status_arg;
    mysql_cond_signal(&m_COND_wait_status);
  }
  mysql_mutex_unlock(&m_LOCK_wait_status);
  return was_occupied;
}

被喚醒的等待執行緒,如果發現 ticket 是 GRANTED 狀態,就會繼續執行;否則根據不同情況報錯。

3 死鎖檢測

每個執行緒在進入鎖等待之前,都會進行一次死鎖檢測,避免當前執行緒陷入死等。在檢測死鎖前,首先將當前執行緒所獲取到的 unobtrusive 鎖物化,這樣這些鎖才會出現在 MDL_lock::m_granted 連結串列中,死鎖檢測才有可能探測到。並且設定當前執行緒的等待鎖 MDL_context::m_waiting_for 為當前的 ticket,每個進入等待的執行緒都會設定等待物件,沿著這條等待鏈就可以檢測死鎖。

/** Inform the deadlock detector there is an edge in the wait-for graph. */
void will_wait_for(MDL_wait_for_subgraph *waiting_for_arg) {
  /*
    Before starting wait for any resource we need to materialize
    all "fast path" tickets belonging to this thread. Otherwise
    locks acquired which are represented by these tickets won't
    be present in wait-for graph and could cause missed deadlocks.

    It is OK for context which doesn't wait for any resource to
    have "fast path" tickets, as such context can't participate
    in any deadlock.
  */
  materialize_fast_path_locks();

  mysql_prlock_wrlock(&m_LOCK_waiting_for);
  m_waiting_for = waiting_for_arg;
  mysql_prlock_unlock(&m_LOCK_waiting_for);
}

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() 來比較受害者的死鎖權重來決定受害者。

/**
  Inspect a wait-for graph edge from one MDL context to another.

  @retval true   A loop is found.
  @retval false  No loop is found.
*/
bool Deadlock_detection_visitor::inspect_edge(MDL_context *node) {
  m_found_deadlock = node == m_start_node;
  return m_found_deadlock;
}

/**
  Change the deadlock victim to a new one if it has lower deadlock
  weight.

  @param new_victim New candidate for deadlock victim.
*/
void Deadlock_detection_visitor::opt_change_victim_to(MDL_context *new_victim) {
  if (m_victim == NULL ||
      m_victim->get_deadlock_weight() >= new_victim->get_deadlock_weight()) {
    /* Swap victims, unlock the old one. */
    MDL_context *tmp = m_victim;
    m_victim = new_victim;
    m_victim->lock_deadlock_victim();
    if (tmp) tmp->unlock_deadlock_victim();
  }
}

檢測過程

死鎖檢測的思路是,首先廣度優先,從當前執行緒等待的鎖出發,遍歷 MDL_lock 的等待佇列和授予佇列,看看是否有非當前執行緒獲取的、與等待的鎖不相容的鎖存在,如果這個持有執行緒與演算法遍歷的起點執行緒相同,那麼鎖等待鏈存在死鎖;其次深度優先,從不相容的鎖的持有或等待執行緒出發,如果該執行緒也處於等待狀態,那麼遞迴重複前述過程,直到找到等待起點執行緒,否則判斷不存在死鎖。程式碼邏輯如下:

MDL_context::find_deadlock()
  |--MDL_context::visit_subgraph(MDL_wait_for_graph_visitor *) // 如果存在m_waiting_for的話,呼叫對應ticket的accept_visitor()
    |--MDL_ticket::accept_visitor(MDL_wait_for_graph_visitor *) // 根據對應MDL_lock的鎖獲取情況檢測
      |--MDL_lock::visit_subgraph() // 遞迴遍歷鎖的授予連結串列(m_granted)和等待連結串列(m_waiting)去判斷是否存在等待起始節點(死鎖)情況
         // 依次遞迴授予連結串列和等待連結串列的MDL_context來尋找死鎖
        |--Deadlock_detection_visitor::enter_node() // 首先進入當前節點
        |--遍歷授予連結串列(m_granted),判斷相容性
          |--如果不相容的話,呼叫Deadlock_detection_visitor::inspect_edge()判斷是否死鎖
        |--遍歷等待連結串列(m_waiting),同上
        |--遍歷授予連結串列,判斷相容性
          |--如果不相容的話,遞迴呼叫MDL_context::visit_subgraph()尋找連通子圖。如果執行緒等待的ticket已經有明確的狀態,非WS_EMPTY,可以直接返回
        |--遍歷等待連結串列,同上
        |--Deadlock_detection_visitor::leave_node() // 離開當前節點
/*
  We do a breadth-first search first -- that is, inspect all
  edges of the current node, and only then follow up to the next
  node. In workloads that involve wait-for graph loops this
  has proven to be a more efficient strategy [citation missing].
*/
while ((ticket = granted_it++)) {
  /* Filter out edges that point to the same node. */
  if (ticket->get_ctx() != src_ctx &&
      ticket->is_incompatible_when_granted(waiting_ticket->get_type()) &&
      gvisitor->inspect_edge(ticket->get_ctx())) {
    goto end_leave_node;
  }
}
...
/* Recurse and inspect all adjacent nodes. */
granted_it.rewind();
while ((ticket = granted_it++)) {
  if (ticket->get_ctx() != src_ctx &&
      ticket->is_incompatible_when_granted(waiting_ticket->get_type()) &&
      ticket->get_ctx()->visit_subgraph(gvisitor)) {
    goto end_leave_node;
  }
}
...

受害者權重

在檢測到死鎖後,沿著執行緒等待鏈退出的時候,會根據每個執行緒等待 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 是一個只讀變數,設定需重啟,在配置檔案中新增:

[mysqld]
performance_schema = ON

通過 performance_schema.setup_instruments 表設定 MDL 監控項:

UPDATE performance_schema.setup_instruments
SET ENABLED = 'YES', TIMED = 'YES'
WHERE NAME = 'wait/lock/metadata/sql/mdl';

之後我們就可以訪問 performance_schema.metadata_locks 表來監控 MDL 獲取情況,比如有兩個執行緒處於以下狀態:

connect-1 > BEGIN;                    |
Query OK, 0 rows affected (0.00 sec)  |
                                      |
connect-1 > SELECT * FROM t1;         |
+------+------+------+                |
| a    | b    | c    |                |       
+------+------+------+                |
|    1 |    2 |    3 |                |
|    4 |    5 |    6 |                |
+------+------+------+                |
2 rows in set (0.00 sec)              | # DDL will hang
                                      | connect-2 > ALTER TABLE t1 ADD INDEX i1(a);

執行緒1事務沒提交,導致執行緒2做 DDL hang 住,訪問 performance_schema.metadata_locks 可以看到是因為執行緒1持有 t1 的 SHARED_READ 鎖,導致需要獲取 EXCLUSIVE 鎖的執行緒2處於等待狀態。

mysql > SELECT * FROM performance_schema.metadata_locks;
+-------------+--------------------+----------------+-------------+-----------------------+---------------------+---------------+-------------+--------------------+-----------------+----------------+
| OBJECT_TYPE | OBJECT_SCHEMA      | OBJECT_NAME    | COLUMN_NAME | OBJECT_INSTANCE_BEGIN | LOCK_TYPE           | LOCK_DURATION | LOCK_STATUS | SOURCE             | OWNER_THREAD_ID | OWNER_EVENT_ID |
+-------------+--------------------+----------------+-------------+-----------------------+---------------------+---------------+-------------+--------------------+-----------------+----------------+
| TABLE       | test               | t1             | NULL        |       140734873224192 | SHARED_READ         | TRANSACTION   | GRANTED     | sql_parse.cc:6759  |              68 |             23 |
| GLOBAL      | NULL               | NULL           | NULL        |       140734862726080 | INTENTION_EXCLUSIVE | STATEMENT     | GRANTED     | sql_base.cc:6137   |              69 |              6 |
| SCHEMA      | test               | NULL           | NULL        |       140734862726240 | INTENTION_EXCLUSIVE | TRANSACTION   | GRANTED     | sql_base.cc:6124   |              69 |              6 |
| TABLE       | test               | t1             | NULL        |       140734862726400 | SHARED_UPGRADABLE   | TRANSACTION   | GRANTED     | sql_parse.cc:6759  |              69 |              6 |
| BACKUP LOCK | NULL               | NULL           | NULL        |       140734862726560 | INTENTION_EXCLUSIVE | TRANSACTION   | GRANTED     | sql_base.cc:6144   |              69 |              6 |
| TABLESPACE  | NULL               | test/t1        | NULL        |       140734862727040 | INTENTION_EXCLUSIVE | TRANSACTION   | GRANTED     | lock.cc:811        |              69 |              6 |
| TABLE       | test               | #sql-5a52_a    | NULL        |       140734862726720 | EXCLUSIVE           | STATEMENT     | GRANTED     | sql_table.cc:17089 |              69 |              6 |
| TABLE       | test               | t1             | NULL        |       140734862726880 | EXCLUSIVE           | TRANSACTION   | PENDING     | mdl.cc:4337        |              69 |              6 |
| TABLE       | performance_schema | metadata_locks | NULL        |       140734887891904 | SHARED_READ         | TRANSACTION   | GRANTED     | sql_parse.cc:6759  |              67 |              4 |
+-------------+--------------------+----------------+-------------+-----------------------+---------------------+---------------+-------------+--------------------+-----------------+----------------+
9 rows in set (0.00 sec)

六 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:https://github.com/mysql/mysql-server/tree/mysql-8.0.18
[2] MySQL · 原始碼分析 · 常用SQL語句的MDL加鎖原始碼分析:MySQL · 原始碼分析 · 常用SQL語句的MDL加鎖原始碼分析
[3] 線上分割槽維護功能:線上分割槽維護 - 雲原生關係型資料庫 PolarDB MySQL引擎 - 阿里雲

原文連結

本文為阿里雲原創內容,未經允許不得轉載。