🐬

MySQL8.0コードリーディング: レコードロック取得(lock_rec_lock)

に公開

コードリーディングの対象

現行のAurora MySQL LTSバージョンである3.04系と互換のあるMySQL8.0のコードを読んでいきます。

https://github.com/mysql/mysql-server/tree/8.0

今回読み進めるのも前回に引き続きレコードロック周りです。今回は、どのようにレコードロックが取得されるのかをコードから読み解きます。

https://github.com/mysql/mysql-server/blob/8.0/storage/innobase/lock/lock0lock.cc

前提

レコードロックが対象

今回対象にしたレコードロックは、前回扱ったロックタイプがLOCK_RECのものを指します。

通常、UPDATE users SET age = 30 WHERE id = 10;のようなクエリが実行された時のロック取得フローは以下のようになります。

  1. usersテーブルにインテンション排他ロック(LOCK_TABLE, LOCK_IX)を取得
  2. id = 10のインデックスレコードをスキャンし、排他ロック(LOCK_REC, LOCK_X, LOCK_REC_NOT_GAP)を取得
  3. 実際にレコードを更新する

実際にS/Xロックを取る前にテーブル単位でインテンションロックをかけますが、これはテーブルロックなので今回の記事の対象外になります。

データがどう管理されているか

(クラスタ化を含み)インデックス上のデータは、ストレージ上ではページという単位で管理されています。InnoDBがデータを処理する際には、必ずメモリ上のバッファプールと呼ばれるキャッシュ領域にページをフェッチします。
https://dev.mysql.com/doc/refman/8.0/ja/innodb-buffer-pool.html

レコードはページ上でヒープ番号を割り振られており、ページIDとヒープ番号の組み合わせでレコードを特定できるようになっています。バッファプール上でレコードを処理する際、複数のスレッドが同時に同じページへアクセスすることを防ぐため、ページはページシャードという単位でまとめられ、このシャード単位で内部的なロック(mutex/ラッチ)がかけられます。

ロック取得の流れ(コールチェーン)

実際にコード上での呼び出しの流れは以下のようになっています。基本的にはロックを伴う読み取りが発生する場合に、ロック対象のインデックスレコード単位で呼び出されます。

ha_innobase

storage/innobase/handler/ha_innodb.h
MySQLサーバは各テーブルを開く際にテーブルハンドラを作成します。テーブルハンドラはhandlerクラスで抽象化されており、ストレージエンジンごとに実装が異なります。ストレージエンジンがInnoDBである場合、テーブルハンドラとして利用されるのがha_innobaseです。スレッドにおけるテーブルに対するロック取得の方針などはこのハンドラによって制御されるため、ha_innobaseのメソッドを読んでいく必要があります。スレッドで実行されるステートメントのロッキングリードの方針はha_innobase::store_lockha_innobase::external_lockを通じて決定され、m_prebuiltというメモリ上の構造体に格納され、実際に読み取り系処理が呼び出される時はm_prebuiltの値を読み取ることでロッキングリードを行います。ロッキングリードを行うメソッドはlock_rec_lockという関数を内部で呼び出しており、そこでロッキングリードが行われます。

この記事では、あくまでロック取得方針が決まった後、そのロック取得方針を使ってレコードロックを取得する部分だけに集中します。

(SQLスレッド)
|
+-> (ストレージエンジンハンドル: ha_innobase) ha_innobase::index_read や ha_innobase::update_row など
    |
    +-> row_search_mvcc()
        |
        +-> sel_set_rec_lock()
            |
            +-> (インデックスの種類[Clustered / Secondary]で分岐)
                |
                +-> lock_clust_rec_read_check_and_lock()
                |   |
                |   +-> lock_rec_lock() // ここでロック取得
                |
                +-> lock_sec_rec_read_check_and_lock()
                    |
                    +-> lock_rec_lock() // ここでロック取得

この記事では、この末端に位置するlock_rec_lockの関数を読み解いていきます。

lock_rec_lock

この関数がレコードロック(ロックタイプ: LOCK_REC)の取得を行うためのエントリポイントになっています。実際の処理はロック競合が少ないケースを高速に処理するlock_rec_lock_fastと、競合がある場合に詳細な判定を行うlock_rec_lock_slowの二つに分かれています。

まずlock_rec_lock_fastで高速にロックの取得を試みて、その結果次第ではlock_rec_lock_slowにフォールバックします。

static dberr_t lock_rec_lock(
  bool impl, // 暗黙ロックかどうか
  select_mode sel_mode, // 読み取りモード
  ulint mode, // ロックモード
  const buf_block_t *block, // 対象レコードを含むバッファブロック(buffer poolのページブロック)
  ulint heap_no, // レコードのヒープ番号
  dict_index_t *index, // レコードが所属するインデックス
  que_thr_t *thr // クエリスレッド(SQLスレッド)
) 
{
  ...
  switch (lock_rec_lock_fast(impl, mode, block, heap_no, index, thr)) {
    case LOCK_REC_SUCCESS:
      return (DB_SUCCESS);
    case LOCK_REC_SUCCESS_CREATED:
      return (DB_SUCCESS_LOCKED_REC);
    case LOCK_REC_FAIL:
      return (
          lock_rec_lock_slow(impl, sel_mode, mode, block, heap_no, index, thr));
    default:
      ut_error;
  }
}

レコードロックの前提条件

デバッグビルドでのみ動作するチェック処理があり、この関数を実行する条件がそのまま反映されているので、まずは冒頭のアサーション系を見ていきます。

locksys::owns_page_shard

locksysはグローバル変数として定義されており、サーバ内でのロック処理を管理するための管制塔となっている。メモリ上でリソースの競合をハンドリングするためのラッチもlocksysが管理している。

owns_page_shardは、指定したページIDがストレージ上で所属するページシャードに対して、ラッチが取得されていることを確認するメソッド。

ストレージ上のページはページシャードという単位でまとめられており、ページシャード単位でラッチを管理することでメモリ上での操作による競合を管理している。ページのデータに対してロックを取る際には、まずストレージ上のページシャード単位でラッチを取得する必要があるため、今手元にあるページに紐づくストレージ上のページが所属するページシャードのラッチ取得を確認している。

暗黙ロック

書き込みによるレコードロックについては、明示的にロックオブジェクトを生成してロックキューに入れることをしないケースがあります。ロックオブジェクトの生成やロック競合の処理にはそれなりのコストがかかるため、「レコードがいつどのトランザクションに更新/作成したのか」という事実だけを残しておくという最適化を行なっており、これを暗黙ロックといいます。比較して、通常のロックを明示ロックと呼びます。

暗黙ロックは実際にロック競合を起こすまでは明示ロックに変換されないため、performance_schema.data_locksにも現れません。

  // 引数のblock(バッファプール上のページ)に紐づくストレージ上のページIDが所属するページシャードのラッチを保持していることを確認
  ut_ad(locksys::owns_page_shard(block->get_page_id()));

  // 読み取り専用のサーバではないことを確認
  ut_ad(!srv_read_only_mode);

  // Sロックを要求する場合、テーブルにISロックが取得済みでなければならない
  ut_ad((LOCK_MODE_MASK & mode) != LOCK_S || lock_table_has(thr_get_trx(thr), index->table, LOCK_IS));

  // Xロックを要求する場合、テーブルにIXロックが取得済みでなければならない
  ut_ad((LOCK_MODE_MASK & mode) != LOCK_X ||lock_table_has(thr_get_trx(thr), index->table, LOCK_IX));

  // ロックモードがSロックもしくはXロックであることを確認
  ut_ad((LOCK_MODE_MASK & mode) == LOCK_S || (LOCK_MODE_MASK & mode) == LOCK_X);

  // ギャップモードに想定通りの値が入っていることを確認(0はLOCK_ORDINARY)
  ut_ad(mode - (LOCK_MODE_MASK & mode) == LOCK_GAP || mode - (LOCK_MODE_MASK & mode) == LOCK_REC_NOT_GAP || mode - (LOCK_MODE_MASK & mode) == 0);

  // Clustered Indexであるか、対象のインデックスがオンラインDDL実行中ではないことを確認
  ut_ad(index->is_clustered() || !dict_index_is_online_ddl(index));

  // 暗黙ロックとして処理する場合、要求モードがLOCK_REC_NOT_GAPであることをチェックする(暗黙ロックはレコードに対してしかかけられない)
  ut_ad(!impl || ((mode & LOCK_REC_NOT_GAP) == LOCK_REC_NOT_GAP));

lock_rec_lock_fast

lock_rec_lock_fastは、ロック取得処理の最適化を行うために特別に作られている関数です。ロック競合がほとんど発生しない状況を想定して、以下のような単純なケースを高速に処理します。

  • 対象レコードが属するページに、まだ誰も明示的なロックをかけていない
  • ページレコードに存在するロックが、自分のトランザクションがかけた全く同じ種類のロックだけであり、再利用できる
static inline lock_rec_req_status lock_rec_lock_fast(
  bool impl, // 暗黙ロックかどうか
  ulint mode, // ロックモード
  const buf_block_t *block, // 対象レコードを含むバッファブロック(buffer poolのページブロック)
  ulint heap_no, // レコードのヒープ番号
  dict_index_t *index, // レコードが所属するインデックス
  que_thr_t *thr // クエリスレッド(SQLスレッド)
)
{
  ...
}

ページブロックのロック状況の取得

理想的な状況ではさっさとロックを取って終わりたいので、まずは試しにページブロックのロックを取得します。lock_sys->rec_hash.find_on_blockは指定されたブロックをスキャンして、ロックを見つけたら渡されたラムダ関数を実行し、ラムダ関数がtrueを返したタイミングでseenに入っているロックをother_lockに格納します。

  lock_t *lock = nullptr;
  lock_t *other_lock =
     // find_on_blockは渡されたblockをスキャンして、ロックを見つけるたびに渡されたラムダ関数を実行する
    // 第二引数にラムダ関数を渡している。[&]がついており、呼び出し元のスコープの変数(ここではlock)をいじれるようにキャプチャしている。
      lock_sys->rec_hash.find_on_block(block, [&](lock_t *seen) { // seenに見つかったロックが格納されている
        if (lock != nullptr) { // 見つかったらtrueを返し、この時点の対象のロックがother_lockに返される
          return true;
        }
        lock = seen; // lockに最初に見つかった一個目のロックを格納
        return false;
      });

ちょっとわかりにくいですが、ここではlock_rec_lock_fastで処理すべきロック要求なのかどうかを判断するために、lockother_lockが以下のどの状態なのかを確定させています。

  • ページブロックに取得されているロックが存在しない
    • lock == nullptr && other_lock == nullptrの状態
  • ページブロックに取得されているロックが1つ
    • lock != nullptr && other_lock == nullptrの状態
  • ページブロックに取得されているロックが2つ以上
    • lock != nullptr && other_lock != nullptrの状態

これによってページブロックに対するロック取得状況がわかるため、後続のレコードロック取得をシンプルに分岐させていくことができます。

レコードロックの取得

先ほど取得したlockとother_lockの状態に基づいて処理を分岐させます。ざっくり全体観だけここで掴んでおきます。

  trx_t *trx = thr_get_trx(thr); // スレッドに紐づくトランザクションを取得
  lock_rec_req_status status = LOCK_REC_SUCCESS; // レコードロックリクエストのstatusを初期化

  if (lock == nullptr) { // 既存ロックはない
    ...
  } else {
    if (other_lock != nullptr || ...) { // 既存ロックが二つ以上ある
      ...
    } else { // 既存ロックが一つある
      ...
    }
  }
  ...

ページブロックにロックが存在しない場合(lock == nullptr)

ページブロックにロックが取得されていなければ処理がもっとも単純で、ただロックを取得するだけです。

  if (lock == nullptr) {
    if (!impl) { // 明示的なロックを取得するケース
      RecLock rec_lock(index, block, heap_no, mode); // ロック作成に必要なヘルパーオブジェクトの作成
      trx_mutex_enter(trx); // トランザクションの内部情報(ロックリストなど)を操作するために、mutexで保護
      rec_lock.create(trx); // 実際にロックオブジェクトを作成し、ハッシュテーブルとトランザクションのロックリストに追加
      trx_mutex_exit(trx); // トランザクションの保護を解除
      status = LOCK_REC_SUCCESS_CREATED;
    }
    // 暗黙ロックの場合はロックオブジェクトを作らず、何もしない
  }

ページブロックにロックが存在する場合(lock != nullptr)

既存ロックがある場合に、このロックオブジェクトを再利用できるかどうかでlock_rec_lock_fastで処理できるか判断します。OR条件なので、後続の条件は先に評価された条件を前提とします。

  1. 既存のロックが2つ以上存在する
    lock_rec_lock_fastはロック競合が少ない状況を高速に処理することがコンセプトのため、このケースは対象外(lock_rec_lock_slowへフォールバック)となります。

  2. 既存の唯一のロックが他のトランザクションによって取られている
    ロック競合が発生する可能性があり、互換性チェックや待機キューの処理が必要になるため、lock_rec_lock_fastの範囲を超えます。

  3. 既存の唯一の自分のロックが今回要求しているロックと種類(ロックモード、ギャップモード)が違う
    SロックからXロックへの昇格など、ロックの単純な再利用ができないため、lock_rec_lock_fastの範囲を超える

  4. 既存の唯一の種類が同じ自分のロックが管理しているページ上のヒープ範囲が、今回取りたいロックのヒープ番号をカバーしていない
    既存ロックオブジェクトの管轄外なので、再利用ができない

また、既存のロックオブジェクトの使い回しができる場合でも以下のケースでは何も処理を行いません(その必要がない)。

  1. 暗黙ロックの取得要求である場合
    明示ロックを取らなくて良いので、何もしないしエラーステータスにもしない

  2. 既存のロックオブジェクトがすでに対象のheap_noに対してロックフラグを立てている
    既に同一トランザクションで同じ種類のロックがかかっているため、取得不要

上記の条件を潜り抜けたロック要求はlock_rec_lock_fastで処理されることが許可され、ロック取得が行われます。

else {
    trx_mutex_enter(trx); // トランザクションの内部情報(ロックリストなど)を操作するために、mutexで保護
    if (other_lock != nullptr || // 既存のロックが二つ以上存在する
        lock->trx != trx || // 既存の唯一のロックが他のトランザクションによって取られている
        lock->type_mode != (mode | LOCK_REC) || // 既存の自分の唯一のロックが今回取ろうとするロックと種類が違う
        lock_rec_get_n_bits(lock) <= heap_no // 既存ロックの管理範囲が今回のロックの取得範囲をカバーしていない
        ) {
      status = LOCK_REC_FAIL; // このケースはlock_rec_lock_fastでは処理できないので失敗させる
    } else if (!impl) {
      // 再利用先のロックオブジェクトがロックしたいレコードのビット(heap_no)にフラグを立てていない
      if (!lock_rec_get_nth_bit(lock, heap_no)) { 
        lock_rec_set_nth_bit(lock, heap_no); // ロックオブジェクトを用いて、対象のレコードのビット(heap_no)に対してロックフラグを立てる
        status = LOCK_REC_SUCCESS_CREATED;
      }
    }
    trx_mutex_exit(trx); // トランザクションの保護を解除
  }

lock_rec_lock_slow

lock_rec_lock_slowは、lock_rec_lock_fastでは処理できないと判断されたロック要求を処理するための汎用的なロック取得関数です。

static dberr_t lock_rec_lock_slow(
  bool impl, // 暗黙ロックかどうか
  select_mode sel_mode, // 読み取りモード
  ulint mode, // ロックモード
  const buf_block_t *block, // 対象レコードを含むバッファブロック
  ulint heap_no, // レコードのヒープ番号
  dict_index_t *index, // レコードが所属するインデックス
  que_thr_t *thr // クエリスレッド(SQLスレッド)
  ) 
{
  trx_t *trx = thr_get_trx(thr); // トランザクション取得
  ...
}

ネクストキーロックの最適化

InnoDBでは、デフォルトのトランザクション分離レベルがREPEATABLE READなので、ネクストキーロック(レコードロック + ギャップロック)の取得が最も一般的なロックで、対象のheap_nosupremum(上限値を表す特殊なレコード)を指していないケースでは最適化が可能になります。まずはレコードロックを既に取得していないかを確認し、もしレコードロックが取れているなら同じロックオブジェクトを再利用してギャップロックを取得するだけで良いので、早期リターンする最適化を行います。

  auto checked_mode =
      (heap_no != PAGE_HEAP_NO_SUPREMUM && lock_mode_is_next_key_lock(mode)) // 対象レコードがsupremumでなく、ネクストキーロックである
      ? mode | LOCK_REC_NOT_GAP // trueならロックモードにレコードロックを追加する
      : mode; // falseならロックモードそのまま
  // ロックモード、バッファブロック、ヒープ番号、トランザクションを指定して、自分のトランザクションが既に対象のロックを取得していればheld_lockに格納する
  const auto *held_lock = lock_rec_has_expl(checked_mode, block, heap_no, trx);
  // 既に対象のロックが取られていた場合
  if (held_lock != nullptr) {
    // ネクストキーロックの最適化が効いていないケースは、既にロックが取れているので特に何もしない
    if (checked_mode == mode) {
      return (DB_SUCCESS);
    }
    // レコードロックは既に取得されているため、既存のロックオブジェクトを再利用しつつ、ギャップロックを追加して終了する
    lock_reuse_for_next_key_lock(held_lock, mode, block, heap_no, index, trx);
    return (DB_SUCCESS);
  }

他トランザクションとの競合発生時の処理

次に、他のトランザクションが今回のロック要求と競合するロックを持っていないかを確認して、適切な処理を施します。

lock_rec_other_has_conflicting

競合するロックがあれば、その競合するロックオブジェクトを含む構造体を返します。locksys::Conflictingという構造体で、待機を起こすロックオブジェクトはwait_forに格納されている。bypassedがtrueになるのは特殊なケースで、待機中のロックを追い越すことができたかどうかを示します。

namespace locksys {
struct Conflicting {
  /** a conflicting lock or null if no conflicting lock found */
  const lock_t *wait_for;
  /** true iff the trx has bypassed one of waiting locks */
  bool bypassed;
};
} /*namespace locksys*/

lock0lock.cc

rec_lock.add_to_waitq

rec_lockは取りたいロックに対するヘルパーオブジェクトで、このヘルパーオブジェクトのadd_to_waitqメソッドを呼び出すことで、このロックはロック待機キューに追加されます。引数には待機の原因となったロックオブジェクトを渡すことになっています。このメソッドが成功すれば、SQLスレッドはスリープ状態に入ります。
返り値のエラーには、デッドロックが検出されればDB_DEADLOCK、正常に待機状態に入ればDB_LOCK_WAITが返される。

RecLock rec_lock(thr, index, block, heap_no, mode);
dberr_t err = rec_lock.add_to_waitq(conflicting.wait_for);
  // 競合するロックがあればその競合に関するオブジェクト、なければnullptrが格納される
  const auto conflicting = lock_rec_other_has_conflicting(mode, block, heap_no, trx);
  if (conflicting.wait_for != nullptr) { // 競合が発生している場合
    switch (sel_mode) {
      case SELECT_SKIP_LOCKED: // 読み取りモードがSKIP_LOCKEDであればこのレコードは無視するので、それを指示するerrを返す
        return (DB_SKIP_LOCKED);
      case SELECT_NOWAIT: // 読み取りモードがNOWAITであれば待たずに終了するので、それを指示するerrを返す
        return (DB_LOCK_NOWAIT);
      case SELECT_ORDINARY: // 通常の読み取り(一般にはネクストキーロックを指す)の場合
        RecLock rec_lock(thr, index, block, heap_no, mode); // ロック作成に必要なヘルパーオブジェクトの作成
        trx_mutex_enter(trx); // トランザクションの内部情報(ロックリストなど)を操作するために、mutexで保護
        // 待機の原因となったロック(conflicting.wait_for)を指定し、待機キューにロック要求を追加。スレッドはここでスリープする。
        dberr_t err = rec_lock.add_to_waitq(conflicting.wait_for);
        trx_mutex_exit(trx); // トランザクションの保護を解除
        return (err); // デッドロックが検出されればDB_DEADLOCK、正常に待機状態に入ればDB_LOCK_WAITが返される
    }
  }

ロック競合がない場合

ロック競合がないので、ロックを即座に取得できます。ただし、デッドロックによる追い越しが発生してロック競合がない判定になっている場合には、暗黙ロックの要求でも明示ロックとして取る必要があります。

conflicting.bypassedがtrueになるデッドロックによる追い越しケース

暗黙的なロックを要求するケースで、lock_rec_other_has_conflictingwait_fornullptrで、bypassedtrueの状態になる可能性があります。

この場合、待機キューの中に自分が待機すべきロックは存在しないが、本当に誰もいなかったわけではないです。待機キューの中で、競合相手はいたがそのロックが「自分が原因で待機しているトランザクションのロック」だった場合、デッドロックを避けるためにそのトランザクションを追い越すことを許されるケースがあるからです。

例えば、以下のようなロック昇格のシナリオでbypassが起きます。

  1. trx_a: レコードRにSロックをかける
  2. trx_b: レコードRにXロックをかけようとして待機中になる
  3. trx_a: レコードRに書き込みをかけるためにXロックをかけようとするが、trx_bのロック待機を待つとデッドロックになるのでバイパスが発生する

この時、通常は書き込みが終われば実際に競合が起きるまでコストの低い暗黙ロックになるが、既に競合が発生している状況なので明示的にロックを取って待機キューに入れてあげないといけません。

  // 明示的なロックを要求しているか、暗黙ロックで待機すべきロックを追い越すことができた場合
  if (!impl || conflicting.bypassed) {
    lock_rec_add_to_queue(LOCK_REC | mode, block, heap_no, index, trx); // ロックモードにレコードロックを加えた状態でロックを取得する
    return (DB_SUCCESS_LOCKED_REC);
  }

純粋な暗黙ロックで良い場合

最後までif文をすり抜ける場合は純粋な暗黙ロックということになるので、明示的なロックを取らずに何もせずに処理を終えます。

  return (DB_SUCCESS);

次に読む場所

今回はレコードロックの取得について読み解きました。lock_rec_lockに渡されているsel_mode(読み取りモード)、mode(ロックモード)などは、実行するステートメントによって変わるため、lock_rec_lock実行前にそのロック取得方針を決めているはずです。

ロック取得方針を決める処理は、InnoDBストレージエンジンハンドラの実装があるstorage/innobase/handler/ha_innodb.ccを読むことになります。特に、ha_innobase::store_lockha_innobase::external_lockの二つの処理が重要になるので、次回はこれらを読んでいきたいと思います。

Discussion