InnoDBデータロック - 第2.5回「ロック」(深堀り) (2020/11/23)

InnoDBデータロック - 第2.5回「ロック」(深堀り) (2020/11/23)

https://mysqlserverteam.com/innodb-data-locking-part-2-5-locks-deeper-dive/
投稿者:Kuba Łopuszański
これで一斉に

それでは、InnoDB Data Locking - Part 2「Locks」で学んだことをまとめて、テーブルロックとレコードロックについて、以下のような状況を理解してみましょう。

mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)

mysql> SELECT * FROM t FOR SHARE;
+----+
| id |
+----+
| 5 |
| 10 |
| 42 |
+----+
3 rows in set (0.00 sec)

mysql> DELETE FROM t WHERE id=10;
Query OK, 1 row affected (0.00 sec)

mysql> INSERT INTO t VALUES (4);
Query OK, 1 row affected (0.00 sec)

mysql> SELECT INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE
FROM performance_schema.data_locks WHERE OBJECT_NAME='t';
+------------+-----------+------------------------+---------------+
| INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE |
+------------+-----------+------------------------+---------------+
| NULL | TABLE | NULL | IS |
| PRIMARY | RECORD | supremum pseudo-record | S |
| PRIMARY | RECORD | 5 | S |
| PRIMARY | RECORD | 10 | S |
| PRIMARY | RECORD | 42 | S |
| NULL | TABLE | NULL | IX |
| PRIMARY | RECORD | 10 | X,REC_NOT_GAP |
| PRIMARY | RECORD | 4 | S,GAP |
+------------+-----------+------------------------+---------------+
8 rows in set (0.00 sec)

私たちはそれを見ます。

  • 最初のSELECT * FROM t FOR SHARE;は、5,10,42,至高の疑似レコードにSロック(ギャップとレコードの両方にある)を作成しました。
    本質的にこれは、軸全体がロックで覆われていることを意味します。そしてこれは、他のトランザクションがこのクエリの結果セットを変更することを防ぐために必要なものです。
    また、これは最初にtのISテーブルロックを必要としました。
  • DELETE FROM t WHERE id=10; は、最初にIXテーブルロックを取得してテーブルを変更する意図を示し、次にX,REC_NOT_GAPを取得してid=10のレコードだけを変更します。
  • 最後に、INSERT INTO t VALUES (4);は、それがすでにIXを持っていることを見たので、挿入を実行するために進みました。
    これは非常にトリッキーな操作であり、私たちが離れて抽象化した詳細について話す必要があります。
    これは、一時的にページをラッチすることから始まります(「ロック」ではなく「ラッチ」という言葉に注意してください!)これがレコードを置くのに正しい場所であるかどうかを確認し、
    挿入ポイントの右側にあるポイントのロックシステムキューをラッチして、*,GAP,SまたはXロックがあるかどうかをチェックします。
    私たちの場合は何もなかったので、すぐにレコードを挿入しました(これは暗黙のロックを持っています。
    "last modified by "フィールドに私たちのトランザクションのIDを持っているので、4に明示的なX,REC_NOT_GAPロックがない理由を説明してくれるといいのですが)。
    反対のケースとしては、競合するロックがあり、競合を明示的に追跡するために待ち受けのINSERT_INTENTIONロックが作成され、それがGRANTEDになったら操作を再試行することができるようになります。
    最後のステップは、軸上に新しい点を挿入すると、既存のギャップが2つに分割されるという事実を処理することです。
    したがって、この古いギャップに対して既に存在していたロックは何であれ、挿入点の左側に新たに作成されたギャップに継承されなければなりません。
    これが 4 行目に S,GAP が見られる理由です:これは 5 行目の S ロックを継承しています。

これは実際の複雑さの氷山の一角に過ぎませんが(削除された行からのロックの継承、セカンダリインデックス、一意性チェックについてはまだ話していません)、
ここから得られるいくつかのハイレベルのアイデアがあります。

  • 一般的に、シリアライズ性を提供するためには、「見たものをロックする」必要があります。
    スキャン時にクエリがどのようにテーブルにアクセスするかを想像できれば、ロックしなければならないものは大体推測できます。
    つまり、スキャンした範囲全体をロックしなければならないのではなく、ロックしたいポイントにまっすぐジャンプできるように、良いインデックスを持つことが重要なのです。
  • 逆もまた真です: シリアライズ性を気にしなければ、何かをロックしないという考えを持つことができます。
    例えば、より低い分離レベルであるREAD COMMITTEDでは、行間のギャップをロックしないようにしています(これにより、他のトランザクションが行を挿入することができ、いわゆる "幻の行 "効果が発生します。
  • InnoDBでは、"挿入中 "と "削除中 "を含むすべての行がインデックスに物理的に存在しているため、軸上に表示され、ギャップに分割されます。
    これは、「進行中」の変更を「スクラッチパッド領域」に保持し、コミット時にのみ共通の画像にマージする他のいくつかのエンジンとは対照的です。
    これは、概念的には並行トランザクション間の相互作用はないにもかかわらず(例えば、コミットされるまではトランザクションによって行が挿入されているのを見るべきではありません)、
    低レベルの実装ではかなり多くの相互作用があることを意味します(例えば、トランザクションは公式にはまだ存在していない行に対して待ちロックを持つことができます)。
    ですから、performance_schema.data_locksがまだ挿入されていない行やすでに削除された行を報告しているのを見ても驚かないでください(後者は最終的にはパージされます)。

レコードロックの圧縮 (および LOCK_DATA の欠落)

上の例では、レコードがロックされているインデックスのカラムの行の値を示す便利な LOCK_DATA カラムを見たことがあるでしょう。
これは人間が状況を分析するのに非常に便利ですが、この "LOCK_DATA "をメモリオブジェクトに明示的に保存するのはもったいないので、
このデータは実際にperformance_schema.data_locksテーブルを問い合わせるときに、
バッファプールのページで利用可能なデータとロックシステムのメモリで利用可能な圧縮された情報からその場で再構築されます。
つまり、ロックシステムはレコードロックを、レコードがあったページの<space_id, page_no>と、
ページ内のレコードの番号であるheap_noで識別します(これらの番号は、一般的にはページ上のレコード値と同じ順番である必要はありません。)
このアプローチには、ポイントが3つの固定長の数字(space_id, page_no, heap_no)を使って記述されているという良い利点があります。
さらに、クエリが同じページ上の複数の行をロックしなければならないことはよくあることなので、
heap_noだけが異なるすべてのロックは(通常は)一つのオブジェクトにまとめて格納され、
heap_no番目のビットが与えられたレコードがこのロックインスタンスによってカバーされるべきかどうかを示すのに十分な長さのビットマップを持っています。
(ここではいくつかのトレードオフがありますが、1つのレコードをロックするだけで済む場合でも、ビットマップ全体のためにスペースを「無駄に」消費してしまうからです。
ありがたいことに、1ページあたりのレコード数はn/8バイトの余裕があるほど少ないことが多いです)

そのため、performance_schema.data_locksがそれぞれのレコードロックを別々に報告しているにもかかわらず、
それらは同じオブジェクト内の異なるビットに対応していることが多く、OBJECT_INSTANCE_BEGINカラムを見ればそれがわかります。

> CREATE TABLE t(id INT PRIMARY KEY);
> insert into t values (1),(2),(3),(4);
> delete * from t where id=3;
> insert into t values (5);
> BEGIN;
> SELECT * FROM t FOR SHARE;
+----+
| id |
+----+
| 1 |
| 2 |
| 4 |
| 5 |
+----+
> SELECT OBJECT_INSTANCE_BEGIN,INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE
FROM performance_schema.data_locks WHERE OBJECT_NAME='t';
+-----------------------+------------+-----------+------------------------+-----------+
| OBJECT_INSTANCE_BEGIN | INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE |
+-----------------------+------------+-----------+------------------------+-----------+
| 3011491641928 | NULL | TABLE | NULL | IS |
| 3011491639016 | PRIMARY | RECORD | supremum pseudo-record | S |
| 3011491639016 | PRIMARY | RECORD | 1 | S |
| 3011491639016 | PRIMARY | RECORD | 2 | S |
| 3011491639016 | PRIMARY | RECORD | 5 | S |
| 3011491639016 | PRIMARY | RECORD | 4 | S |
+-----------------------+------------+-----------+------------------------+-----------+

SELECT...FROM t...は意味的な順序(idの昇順)で行を返していることに注意してください。
これは、主インデックスをスキャンする最も簡単な方法は、実際に主キーの順に行を訪問することを意味します。
しかし、performance_schema.data_locksのSELECT...from performance_schema.data_locksは、
内部実装でいくつかのヒントを明らかにします: id=5で新たに挿入された行は、id=3で削除された行によって残された穴に入りました。
すべてのレコードロックが同じオブジェクトインスタンスに格納されていることがわかり、
このインスタンスのビットマップには、すべての実際の行と最上位の疑似レコードに対応するheap_noのビットが設定されていることが推測できます。

ここで、ロックシステムはカラムの値を知らないので、LOCK_DATA カラムを埋めるためにバッファプール内の実際のページの内容を参照しなければならないことを実証してみましょう。
バッファプールはディスク上の実際のページのキャッシュと考えることができます
(簡略化しすぎて申し訳ありません: 実際には、REDOログのデルタに保存されているページへのパッチも含まれているので、ディスク上のページからのデータよりも新鮮なものになります)。
Performance_schema はバッファプールからのデータのみを使用しますが、ディスクからのデータは使用しませんので、
もしそこにあるページが見つからない場合、ディスクからのデータ取得を試みず、代わりに LOCK_DATA カラムに NULL を報告します。
バッファプールからページを強制的に退避させるには?一般的には 私は知りません。
しかし、うまくいくように見えるのは、バッファプールの容量に達するほど多くの新しいページをバッファプールにプッシュして、最も古いページを強制退避させなければならないことです。
この目的のために、私は新しいクライアントを開いて、バッファプールに収まらないほど巨大なテーブルを作成します。
どのくらいの大きさですか?

con2> SELECT @@innodb_buffer_pool_size;
+---------------------------+
| @@innodb_buffer_pool_size |
+---------------------------+
| 134217728 |
+---------------------------+


OK、128MBのデータをプッシュする必要があります。(この実験はバッファプールのサイズを小さくすることで簡単にできます。
これは一般的には動的に行うことができますが、残念ながら "チャンク" のデフォルトサイズは非常に大きく、128MB 以下にすることはできません)

con2> CREATE TABLE big(
id INT PRIMARY KEY AUTO_INCREMENT,
blah_blah CHAR(200) NOT NULL
);
con2> INSERT INTO big VALUES (1,REPEAT('a',200));
con2> INSERT INTO big (blah_blah) SELECT blah_blah FROM big;
con2> INSERT INTO big (blah_blah) SELECT blah_blah FROM big;
con2> INSERT INTO big (blah_blah) SELECT blah_blah FROM big;
...
con2> INSERT INTO big (blah_blah) SELECT blah_blah FROM big;
Query OK, 262144 rows affected (49.14 sec)
Records: 262144 Duplicates: 0 Warnings: 0


...で十分なはずです。もう一度performance_schema.data_locksを見てみましょう。

> SELECT OBJECT_INSTANCE_BEGIN,INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE
FROM performance_schema.data_locks WHERE OBJECT_NAME='t';
+-----------------------+------------+-----------+------------------------+-----------+
| OBJECT_INSTANCE_BEGIN | INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE |
+-----------------------+------------+-----------+------------------------+-----------+
| 3011491641928 | NULL | TABLE | NULL | IS |
| 3011491639016 | PRIMARY | RECORD | supremum pseudo-record | S |
| 3011491639016 | PRIMARY | RECORD | NULL | S |
| 3011491639016 | PRIMARY | RECORD | NULL | S |
| 3011491639016 | PRIMARY | RECORD | NULL | S |
| 3011491639016 | PRIMARY | RECORD | NULL | S |
+-----------------------+------------+-----------+------------------------+-----------+


ハ!見てください。これで LOCK_DATA カラムに NULL が入りました。
Lock System はどのページのどのヒープ番号がロックされているかをまだ知っているので、他のクライアントからこれらのレコードにアクセスしようとすると待たなければなりません。


con2> DELETE FROM t WHERE id = 2;

ですから、LOCK_DATAにNULLがあっても慌てないでください。これは、そのページが現在バッファプールで利用できないことを意味しています。

予想通り、DELETEを実行するとページがメモリ上に移動したので、問題なくデータを見ることができるようになりました。

> SELECT ENGINE_TRANSACTION_ID,INDEX_NAME,LOCK_DATA,LOCK_MODE,LOCK_STATUS
FROM performance_schema.data_locks
WHERE OBJECT_NAME='t' AND LOCK_TYPE='RECORD';
+-----------------------+------------+------------------------+---------------+-------------+
| ENGINE_TRANSACTION_ID | INDEX_NAME | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+-----------------------+------------+------------------------+---------------+-------------+
| 2775 | PRIMARY | 2 | X,REC_NOT_GAP | WAITING |
| 284486501679344 | PRIMARY | supremum pseudo-record | S | GRANTED |
| 284486501679344 | PRIMARY | 1 | S | GRANTED |
| 284486501679344 | PRIMARY | 2 | S | GRANTED |
| 284486501679344 | PRIMARY | 5 | S | GRANTED |
| 284486501679344 | PRIMARY | 4 | S | GRANTED |
+-----------------------+------------+------------------------+---------------+-------------+


ロック分割

先にほのめかされたように、「点」と「点と点の間の隙間」を持つ「軸」のメンタルモデルは、(理論的には)ロックシステムでは2つの異なる方法でモデル化することができます。

  • オプションA:2つの別々の種類の資源。つまり、ギャップ(15,33)は1つのリソースであり、ポイント[33]は別のリソースである。
    それぞれが独立して要求され、単純なアクセスモードのセットを使って許可されます(例えば、XとSだけ)
  • オプション B: レコードと先行するギャップの組み合わせのための単一のリソースと、
    ギャップとレコードで何をしたいかをエンコードするアクセスモードのより広いセット (X, X, REC_NOT_GAP, X, GAP, S, S, REC_NOT_GAP, S, GAP, ....)

主な利点は、最も一般的なケース(スキャン中にトランザクションがギャップとレコードの両方をロックする必要がある場合)では、
2つのオブジェクトではなく、1つのオブジェクトをメモリ上で必要とすることで、スペースを節約できるだけでなく、
メモリのルックアップが少なくて済み、リスト内の1つのオブジェクトに対してファストパスを使用できることです。

しかし、概念的には X = X,GAP + X,REC_NOT_GAP と S = S,GAP + S,REC_NOT_GAP となり、
InnoDB 8.0.18 では後述の「ロックスプリッティング」と呼ばれる技術を使ってこれらの式を利用することができるようになりました。

トランザクションが待たなければならない、あるいはデッドロックしなければならない一般的な理由は、
レコード上にすでにロックがあるが、その前のギャップにはロックがない場合(例えば、X,REC_NOT_GAPを持っていた)、
レコードの前のギャップをカバーするために「アップグレード」しなければならなかった(例えば、Xを要求した)が、
残念ながら別のトランザクションを待たなければならなかった(例えば、この別のトランザクションがS,REC_NOT_GAPを待っていた)場合です。
(一般的にトランザクションがまだ待っているリクエストを無視できない理由は、ウェイターを飢えさせないためです。このようなシナリオは deadlock_on_lock_upgrade.test で詳しく説明されています。)

「ロック分割」の手法は、上記の式を用いて、必要とされる - possessed = missing(我々の場合)を導き出します。
X - X,REC_NOT_GAP = X,GAP.
just for X,GAPのように、Xに対するトランザクションリクエストは静かに、より控えめなリクエストに変換されます
この特定の状況では、これはリクエストがすぐに許可されることを意味します (*,GAP リクエストは何も待つ必要がないことを思い出してください)。
セカンダリインデックス

前述したように、各インデックスは独立した軸として見ることができ、独自のポイントとギャップがあり、それをロックすることができます。
おそらく、常識的なルールに従えば、与えられたクエリに対してどのポイントとギャップをロックしなければならないかを自分で見つけることができるでしょう。
基本的には、あるトランザクションが他のトランザクションの結果セットに影響を与える何かを変更した場合、
この読み込みトランザクションが必要とするロックは、クエリプランに関係なく、変更を行うトランザクションが必要とするロックと競合しなければならないことを確認したいと考えています。
この目標を達成するためにルールを設計する方法はいくつかあります。

例えば、単純なテーブルを考えてみましょう。

CREATE TABLE point2D(
x INT NOT NULL PRIMARY KEY,
y INT NOT NULL UNIQUE
);
INSERT INTO point2D (x,y) VALUES
(0,3),
(1,2),
(3,1),
(2,0);


どのようなロックが必要なのかを考えてみましょう。

DELETE FROM point2D WHERE x=1;

xとyという2つの軸があります。少なくとも(1)の点をx軸にロックするのは合理的だと思います。y軸に何かをロックしないようにすることはできますか?
正直なところ、それはデータベースの実装に依存すると思いますが、次のように考えてみてください。

SELECT COUNT(*) FROM point2D WHERE y=2 FOR SHARE;

は、ロックがx軸のみに格納されている場合に動作しなければなりません。
合理的には、このSELECTは、一致する行を見つけるためにy列のインデックスを使用して開始しますが、それがロックされているかどうかを知るためには、そのx値を学習しなければなりません。
これはまだ合理的な要件です。実際にInnoDBはプライマリキーのカラム(私たちの場合はx)を各セカンダリインデックスエントリに格納しているので、yのインデックスでxの値を探すことは大したことではありません。
しかし、InnoDBのロックは実際にはxの値に結びついているのではなく(例えば、これはかなり長い文字列かもしれません)、
むしろheap_no(ビットマップのオフセットとして使用する短い数値)に結びついていることに注意してください - ロックの存在をチェックするにはheap_noを知る必要があります。
そのため、ロックの存在を確認するためには、heap_noを知る必要があります。
したがって、あなたはプライマリインデックスに飛び込み、レコードを含むページをロードして、そのレコードのheap_noの値を知る必要があります。あまり速くありません。

そこで、別のアプローチとして、どのインデックスを使って x=1 の行を見つけても、他のインデックスを参照しなくてもロックが発見されるようにするという方法があります。
これは、y軸上のポイントをy=2でロックすることで実現できます。
このようにして、上で述べたSELECTクエリは、それ自身のロックを取得しようとしたときに、それがロックされていることを知ることができます。
このSELECTはどのようなロックを取るべきでしょうか?繰り返しになりますが、これはいくつかの方法で実装することができます: y軸上の点をy=2でロックすることもできますし、
プライマリインデックスにダイブしてx軸上の点をx=1でロックすることもできます。
すでに述べたように、性能上の理由から、最初のアプローチの方が、プライマリインデックスでのルックアップを避けることができるので、より速く感じられます。

そこで、私たちの疑念が現実と一致するかどうかを見てみましょう。
まず、セカンダリインデックスを経由して選択を行うトランザクションが保持するロックを調べてみましょう
(まれに、オプティマイザがセカンダリインデックスを使用するのとは対照的にプライマリインデックスを経由してスキャンするクエリプランを選択することがありますが、
これは非常識だと思われるようなクエリであっても起こり得ます。また、何がより速いかについての人間の直感は間違っているかもしれません)


con1> BEGIN;
con1> SELECT COUNT(*) FROM point2D WHERE y=2 FOR SHARE;
con1> SELECT INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE
FROM performance_schema.data_locks WHERE OBJECT_NAME='point2D';
+------------+-----------+-----------+---------------+
| INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE |
+------------+-----------+-----------+---------------+
| NULL | TABLE | NULL | IS |
| y | RECORD | 2, 1 | S,REC_NOT_GAP |
+------------+-----------+-----------+---------------+


これは我々の予想と一致します: テーブル全体の意図的なロック (IS) と、特定のレコードのロック (S,REC_NOT_GAP) がありますが、その前のギャップではなく、両方とも「共有」されています。
LOCK_DATAカラムは、このレコードを2,1と記述していることに注意してください。
これは、この行のセカンダリインデックスエントリに格納されている順番と同じ順番でカラムをリストアップしているからです。つまり、2,1は<y=2,x=1>を意味します。

このトランザクションをROLLBACKして元の状態に戻し、今度はDELETEだけでどのようなロックが取られるかを確認してみましょう。

con1> COMMIT;
con1> BEGIN;
con1> DELETE FROM point2D WHERE x=1;
con1> SELECT INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE FROM performance_schema.data_locks WHERE OBJECT_NAME='point2D';
+------------+-----------+-----------+---------------+
| INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE |
+------------+-----------+-----------+---------------+
| NULL | TABLE | NULL | IX |
| PRIMARY | RECORD | 1 | X,REC_NOT_GAP |
+------------+-----------+-----------+---------------+


テーブル全体(IX)には期待される意図ロックがあり、プライマリインデックスのレコード自体にはロックがあり、どちらも「排他的」ですが、セカンダリインデックスにはロックがありません。
DELETEがプライマリインデックス上のロックのみを取り、SELECTがセカンダリインデックス上のロックのみを取るとしたら、InnoDBはどのようにしてこの2つが同時に実行されるのを防ぐのでしょうか?

このDELETEトランザクションを開いたままにして、別のクライアントを起動して、削除された行を見ることができるかどうかを見てみましょう。

con2> BEGIN;
con2> SELECT COUNT(*) FROM point2D WHERE y=2 FOR SHARE;
⌛


ふむ...SELECTがブロックされました(これは良いことです)が、どうやって?
何が起こっているのかを把握するために、performance_schema.data_locksを検査してみましょう。

con1> SELECT ENGINE_TRANSACTION_ID trx_id,INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE,LOCK_STATUS
FROM performance_schema.data_locks WHERE OBJECT_NAME='point2D';
+-----------------+------------+-----------+-----------+---------------+-------------+
| trx_id | INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+-----------------+------------+-----------+-----------+---------------+-------------+
| 283410363307272 | NULL | TABLE | NULL | IS | GRANTED |
| 283410363307272 | y | RECORD | 2, 1 | S | WAITING |
| 1560 | NULL | TABLE | NULL | IX | GRANTED |
| 1560 | PRIMARY | RECORD | 1 | X,REC_NOT_GAP | GRANTED |
| 1560 | y | RECORD | 2, 1 | X,REC_NOT_GAP | GRANTED |
+-----------------+------------+-----------+-----------+---------------+-------------+


はっ! 私たちのトランザクション (283410363307272) は、セカンダリインデックスレコード <y=2,x=1> (およびその前のギャップ) の S ロックを取得するのを待っています。
しかし... 1560 が保持しているロックを検査していたとき、ほんの一瞬前にはそのようなロックを見たことがありませんでした
- このロックは今になって現れたのですが、なぜでしょうか? 1560が現在「アクティブに何かをしている」わけではないことを考えると、これはさらに不可解です
- どうやってロックを取得したのでしょうか?

performance_schema.metadata_locks は明示的なロックのみを表示し、暗黙のロックは表示しないことを覚えておいてください。
これが実際に意味することは、283410363307272が<y=2,x=1>のSロックを付与するようにLock Systemに要求すると、
Lock Systemは最初にこのレコードに暗黙のロックがあるかどうかをチェックして、それを推測します。
これはかなり複雑なプロセスです (lock_sec_rec_some_has_impl "lock system." で始まるソースコードを参照してください)。
誰かがこのセカンダリインデックスレコードに暗黙のロックをかけているのか?)


  • page_get_max_trx_id(page)の値をチェックしてください - 各ページについて、このセカンダリインデックスページを変更したすべてのトランザクションの最大IDを格納します。
    DELETE 操作はそれを自分の id に "バンプ" しました (既にそれが大きくなっていない限り)。
  • そして、この max_trx_id を、まだアクティブなトランザクションの中で最小の ID を追跡する trx_rw_min_trx_id() と比較します。
    言い換えれば、アクティブなトランザクションがこのセカンダリインデックスに暗黙のロックをかけている可能性があるかどうかをヒューリスティックに判断しようとし、ここでいくつかのトレードオフを行います。
    • セカンダリインデックスでは、レコードごとにmax_trx_idをトラッキングするのではなく、
      ページ全体でトラッキングするので、ストレージの使用量は少なくなりますが、実際には同じページ上の他のレコードに変更が適用されていたとしても、
      自分のレコードが変更されたというのはもっともらしいことではないかと考えることができます。
    • このtrx idがアクティブなトランザクションのセットに属しているかどうかを注意深くチェックすることはなく、それらの中で最小のidと比較するだけです
      率直に言って、前の単純化の観点から正しい状態を保つためには、このようにしなければなりません:行を修正したトランザクションの実際のidを知らないのです。
  • もしヒューリスティックが、誰もがこのレコードに暗黙のロックをかけていることは不可能だと言うならば、
    このページに記載されているレコードを変更したトランザクションの最大値よりも低いidを持つアクティブなトランザクションは存在しないので、ここで止めることができます。
    これは、プライマリインデックスを参照する必要がなかったことを意味します。
  • そうでなければ、物事は厄介なものになります。我々はrow_vers_impl_x_lockedに降りる。
    • プライマリインデックスにあるレコードを探す (まれにパージスレッドとの競合のためにすでに見つからない場合がある)
    • この特定の行を変更した最後のトランザクションのtrx_idを取得します(これが上記の最初のヒューリスティックのより正確なアナログであることに注意してください)。
    • trx_id がまだアクティブであるかどうかをチェックします (これが上記の 2 番目のヒューリスティックのより正確なアナログであることに注意してください)。
    • トランザクションがまだアクティブである場合、そのトランザクションが暗黙のロックを持っていないことがあります。
      この場合、セカンダリインデックスのエントリは概念的には影響を受けないので、暗黙のロックは必要ありません。
      これをチェックするためには、その行の以前のバージョンを取得して、
      インデックス付きの列が概念的にはロックが必要であることを意味する方法で影響を受けていないかどうかを正確にチェックしなければなりません。
      これは非常に複雑です。ここでは説明しませんが、興味があれば row_clust_vers_matches_sec と row_vers_impl_x_locked_low で私のコメントを読んでください。
  • 最後に、暗黙のロックが必要であると判断された場合、その正当な所有者(プライマリインデックスレコードヘッダの trx_id)に代わって明示的なロック(常にX,REC_NOT_GAP型)に変換されます。

ここでの主なポイントは、暗黙のロックがかかっているかどうかを判断するために、
最悪の場合はプライマリインデックスレコードだけでなく、その前のバージョンもアンドゥログから取得する必要があるということです。
最良のケースでは、セカンダリインデックスのページを見て「ない」と判断することになります。

OK、つまり、DELETE を行うスレッドは少し怠け者で、SELECT を行うスレッドは、DELETE が暗黙のうちに残したものを明示的にするために余分な作業をしなければならないようです。

SELECTが最初に実行されたのがSELECTで、その後にDELETEを開始したとしたらどうなるでしょうか?
このシナリオでも暗黙から暗黙への変換を行うのでしょうか?
SELECT は行を変更してはいけないし、行ヘッダやページヘッダに trx_id を入れてはいけないので、暗黙のロックを推測するための痕跡がないことを考えると、ありえないように思えます。

もしかしたらバグを発見したのかもしれませんね。ROLLBACKしてみましょう

con1> ROLLBACK;
con2> ROLLBACK;

で、この新しいシナリオを確認してみてください。

con2> BEGIN;
con2> SELECT COUNT(*) FROM point2D WHERE y=2 FOR SHARE;
+----------+
| COUNT(*) |
+----------+
| 1 |
+----------+


そして今、別のクライアントではDELETE。

con1> BEGIN;
con1> DELETE FROM point2D WHERE x=1;
⌛


DELETEはWAITINGなので、バグはなさそうです。明示的なロックを見てみましょう。

> SELECT ENGINE_TRANSACTION_ID trx_id,INDEX_NAME,LOCK_TYPE,LOCK_DATA,LOCK_MODE,LOCK_STATUS
FROM performance_schema.data_locks WHERE OBJECT_NAME='point2D';
+-----------------+------------+-----------+-----------+---------------+-------------+
| trx_id | INDEX_NAME | LOCK_TYPE | LOCK_DATA | LOCK_MODE | LOCK_STATUS |
+-----------------+------------+-----------+-----------+---------------+-------------+
| 2077 | NULL | TABLE | NULL | IX | GRANTED |
| 2077 | PRIMARY | RECORD | 1 | X,REC_NOT_GAP | GRANTED |
| 2077 | y | RECORD | 2, 1 | X,REC_NOT_GAP | WAITING |
| 283410363307272 | NULL | TABLE | NULL | IS | GRANTED |
| 283410363307272 | y | RECORD | 2, 1 | S,REC_NOT_GAP | GRANTED |
+-----------------+------------+-----------+-----------+---------------+-------------+


(超感覚的な読者のための技術的な注意事項: 283410363307272は怪しいほど長い数字であるだけでなく、以前の例で見たのと全く同じIDでもあります。
両方の謎の説明は簡単です: 読み取り専用のトランザクションでは、InnoDBは実際の単調なトランザクションIDを割り当てるために時間を無駄にしません。)

かっこいいですね、前回のものとはやや対称的な結果が得られましたが、今回はSELECTがGRANTEDロックを持ち、DELETEがWAITINGロックを持っています。
(もう一つの違いは、今回のSELECTはSの代わりにS,REC_NOT_GAPを持っていることと、率直に言って、なぜ前回のケースではギャップもロックする必要があったのか覚えていませんが、
幸いなことに、これは今回の話には関係ありません)

では、DELETE を行うトランザクションは、DELETE を行うだけではそのようなロックを作成しないことを見たにもかかわらず、
なぜ今、明示的な WAITING ロックを持つようになったのでしょうか?

答えはこうです。
DELETE はセカンダリインデックスのロックを取ろうとしますが (lock_sec_rec_modify_check_and_lock を呼び出すことで)、これにはトリッキーな最適化が含まれています。
すでに競合するロックが存在しないため、明示的なロックを作成する代わりに、必要ならば暗黙のロックが推論できることを呼び出し元から知らされているため、明示的なロックを回避します。
おそらく、lock_tオブジェクトを割り当てる必要がないようにするためです: 主キーの連続した範囲を形成する多くの行に影響を与える単一のDELETE操作を考えてみましょう
- に対応するセカンダリインデックスエントリがあちこちに存在する可能性があるため、圧縮機構の恩恵を受けられませんでした。
また、InnoDB内で暗黙のロックを使用している場所がある限り、いずれにしても暗黙のロックをチェックしなければなりません。

私たちの場合、ロックシステムは競合があると判断し、それを追跡するために明示的なWAITINGロックを作成します。

さて、要約すると、現在のバージョンのInnoDBでは、セカンダリインデックス上でのDELETEとSELECTの競合を防ぐために、どのソリューションを使用しているのでしょうか?それは

  • DELETEは両方のインデックスをロックし、SELECTは1つだけ?または
  • DELETEは、プライマリとSELECTの両方をチェックするだけでロック?

複雑ですが、最初のアプローチのように、セカンダリインデックス上のDELETEによって取られるロックは、可能な限り暗黙のものであるという注意点があります。

さて、これで次のトピックであるデッドロックの検出について話す準備が整いました。

いつもMySQLをご利用いただきありがとうございます。

コメント

このブログの人気の投稿

Oracle Database 19cサポート・タイムラインの重要な更新 (2024/11/20)

Oracle Autonomous AI Lakehouseで10xを超えるトークン使用を削減 (2026/10/26)

Cloudflareロード・バランサを使用したOCIディザスタ・リカバリ (2026/10/19)