BizStationブログ

ビズステーション株式会社の公式ブログです。

MySQL/MariaDBとTransactdのInnoDBロック制御詳細 その1

今回から数回にわたり、TransactdのオペレーションとInnoDBにおけるロックについて解説します。
ロックについてはあまり良くわからなくてもとりあえずそれなりに動くアプリケーションは作れてしまいます。ですが、マルチユーザー環境でミッションクリティカルなアプリケーションを書くには、ロックの理解が不可欠です。ロックをうまく使って、矛盾や間違いのない読み書きをしつつ同時実行性も高いアプリケーションにしましょう。
その1では、Transactdを実装する上でMySQLのソースやドキュメントから得た知見を基に、InnoDBのロックの種類と分離レベルに応じてそれをどのように使うかをまとめてみます。

Index

MySQLトランザクション関連用語

説明に入る前に、事前にMySQLトランザクション関連用語の理解が必要です。下記の用語についてあまりよくわからない場合は、大まかで良いので理解しておいてください。

MySQLのREPEATABLE-READ

MySQLトランザクション分離レベルはREPEATABLE READが標準です。通常 REPEATABLE READではファントムリードを回避できませんが、MySQLのREPEATABLE READはネクストキーロックという仕組みによってファントムリードが起きないようになっています。そのため、さらに分離レベルの高い SERIALIZABLE とほぼ同じものです。

トランザクション分離レベルは4つありますが、READ_UNCOMMITEDはほとんど使用されないので、ここでは省いて説明します。

InnoDBのロック

はじめにInnoDBの基本的なロックについて説明します。InnoDBはMVCCとこれらのロックを使って、トランザクション分離レベルを実装しています。

行ロック (row-level locking)

InnoDBは行ロックをサポートしています。行ロック(row lock)にはShared lock(共有ロックS)とExclusive lock(排他ロックX)の2種類があります。
トランザクションT1によって共有ロック(S)が取得されたレコードRは、別のトランザクションT2から共有ロック(S)はできますが、排他ロック(X)はできません。また、トランザクションT1によって排他ロック(X)が取得されたレコードRは、別のトランザクションT2から共有ロック(S)も排他ロック(X)もできません。

2つのトランザクションが同じレコードのロックを取得しようとしたときのロックの可否を表したのがInnoDBソースコードの以下の部分です。「TRUE」の組み合わせでは互いに競合せず双方とも取得できます。「FALSE」の組み合わせでは、後から取得しようとした方は、先に取得されたロックが解放されるまでロックを取得することができません。まだ説明していないIXやISというロックがありますが、ここではXとSの組み合わせのみ見てください。

//mysql-5.6.20/storage/innobase/lock/lock0lock.cc : 323

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

共有ロックと排他ロックそれぞれの使い道は以下の通りです。

共有ロック 読取
排他ロック 読取、更新、削除

GAPロック

GAPロックは、レコードとレコードの間の空間をロックします。これでInsertをブロックし、行ロックで更新をブロックすることで、ファントムリードを防止します。具体的には、後ろ側のレコードにGAPロックを行います。GAPロックは単体で使用する場合と、行ロックと組み合わせて使用する場合があります。行ロックと組み合わせたものをネクストキーロック(Next key lock)と呼んでいます。

GAPロック単体、ネクストキーロック、GAPロックを含まない通常の行ロックの3種類をinnodb_monitorで見ると、下記のように表示されます。*1

GAPロック単体

.`test` trx id 134515465 lock mode S locks gap before rec

ネクストキーロック

.`test` trx id 134515465 lock mode S locks

GAPロックを含まない通常の行ロック(row lock)

.`test` trx id 134515465 lock mode S locks rec but not gap

読み取ったレコードすべてにネクストキーロックを行い、最後に読み取ったレコードにGAPロックかネクストキーロック*2を行うことで、検索範囲内の更新と挿入ブロックを実現します。
たとえば、ユニークなインデックスフィールド id の1から15までレコードが挿入されたテーブルがあるとします。そこにREPEATABLE-READで select id from table where id <= 10 LOCK IN SHARE MODEとすると、まず、id 1から10まで10個のレコードは確実にネクストキーロックが行われます。11は、ユニークキーで <= としているので読まなくても検索終了を判断できそうなのですが、11もネクストキーロックされます。(11のロックについては、インデックスや検索条件の書き方によって変わります。MySQLでは、ほぼロックされると考えてください。)

GAPロック単体でロックされたレコードは、前の空間がロックされているだけでレコードそのものはロックされていません。以下の例では、同じレコード(0: 1:で示されたフィールドの内容が同じ)に対して異なる2つのトランザクションがGAPロック(X)と共有ロック(S)を取得しています。

//トランザクション
.`test` trx id 134515525 lock_mode X locks gap before rec
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000010; asc ;;
1: len 4; hex 80000003; asc ;;

//トランザクション2

.`test` trx id 134515526 lock mode S
Record lock, heap no 4 PHYSICAL RECORD: n_fields 2; compact format; info bits 0
0: len 4; hex 80000010; asc ;;
1: len 4; hex 80000003; asc ;;

なお、検索がEND OF RECORDSまで到達した場合は、GAPロックを明示するレコードが存在しません。その場合は、最後のレコードのさらに後ろにある終端を示すレコード(supremum)がロックされます。

.`test` trx id 134515465 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
0: len 8; hex 73757072656d756d; asc supremum;;

MySQLのhandlerインターフェースには、ネクストキーロックを明示的に指示する方法は存在しませんが、分離レベルにREPEATABLE READかSERIALIZABLEが指定されていると、行ロックに替えてネクストキーロックが使用されます。

余談ですが、SQLの悩みに「ロックされるレコードの範囲はどこになるのかがわかりにくい」といったものがあります。読み取り方法や範囲をオプティマイザが決めるためです。Transactdの場合はそのような悩みはありません。それらはコードを書く人が決めるからです。

インテンションロック (Intention Locks)

インテンションロックは、multiple granularity locking (複数粒度ロック)とMySQLのドキュメントに記されていますが、実際にこのようなロックがあるわけではありません。テーブルにこのロックを明示すると、以降の行操作に対して、行の共有ロック(S)または排他ロック(X)の取得が行われる仕組みのことです。
行ロックに合わせて、インテンションロックにも共有ロック(IS)と排他ロック(IX)の2種類があります。ともにテーブルに対してロックを明示します。

  • 共有ロック(IS)は、トランザクションT内でテーブルtの複数の行に対して共有ロック(S)の取得を試みる。
  • 排他ロック(IX)は、トランザクションT内でテーブルtの複数の行に対して排他ロック(X)の取得を試みる。

インテンションロックの使われ方を簡単に説明すると、これから更新や削除を行う際には排他ロック(IX)を設定し、更新や削除をされたくない読み取りを行う際には共有ロック(IS)を設定します。(SQL文で明示的にインテンションロックを操作する方法はありません。MySQLSQL文からテーブルロックタイプを決め、そのタイプからInnoDBインテンションロックタイプを決定します)

Multi-Versioning

InnoDBの各レコードには、最後に追加または更新・削除したトランザクションのIDが記録されます。更新・削除によって古くなったバージョン(旧バージョン)のレコードは、テーブルスペースのロールバックセグメントに保持されます。

  • スナップショットは、自分のトランザクションIDと同じか、より前のトランザクションIDで最後にコミットされたレコードの集合。
  • スナップショットの読み取りはロックは不要。また、ロックの取得や変更はできない。

前述の共有ロック(S)と排他ロック(X)のいずれでもないLOCK_NONE(ロックなし)の読み取りは、スナップショットが使われます。

Consistent Reads

トランザクションT1において、他のトランザクションによって変更された値に影響されることなく、T1内で原始性が保証された一貫性のある読み取りができることをConsistent reads(一貫性読み取り)と言います。
たとえば、売上伝票のヘッダーと明細はトランザクションによって一式でアトミックな書き込みが行われるとします。読み取りにおいて一貫性がないと、ヘッダーと明細で異なるバージョンを読んでしまうことが起こります。T1がヘッダーを読み取り、次に明細を読み取る前に、T2によって変更された伝票がコミットされた場合などです。Consistent readsはアトミックな特性と併せて、このような問題を解決するためにとても重要な機能です。
InnoDBのConsistent readsには、2つの方法があります。

  • Consistent Nonlocking Reads (ロックなし一貫性読み取り)
  • Consistent locking Reads (ロッキング一貫性読み取り)

Consistent Nonlocking Reads

Consistent Nonlocking Readsはマルチバージョン機能を使って、トランザクションを開始した時点のスナップショットにアクセスすることで実現します。他のトランザクションによってコミット済みの挿入・更新されたレコードがあっても、スナップショットにはそれはないので、Consistent readsを実現します。また、ロック不要なので Consistent Nonlocking Readsです。
InnoDBでConsistent Nonlocking Readsを行うには、ロックを要求しない読み取りを行うことで実現します。(SQLでは、SELECT ... FOR UPDATE か SELECT ... LOCK IN SHARE MODEを使わない読み取り)ただし、分離レベルがSERIALIZABLEの場合は、トランザクション内のすべての読み取り時に共有ロック(S)を要求してしまうので、Consistent Nonlocking Readsは行えません。
以下は、SERIALIZABLEがLOCK_Sを強要する部分のInnoDBのコードの抜粋です。(mysql-5.6.20)

// /mysql-5.6.20/storage/innobase/handler/ha_innodb.cc : 12191
if (trx->isolation_level == TRX_ISO_SERIALIZABLE
        && prebuilt->select_lock_type == LOCK_NONE
        && thd_test_options(
          thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)) {

      /* To get serializable execution, we let InnoDB
      conceptually add 'LOCK IN SHARE MODE' to all SELECTs
      which otherwise would have been consistent reads. An
      exception is consistent reads in the AUTOCOMMIT=1 mode:
      we know that they are read-only transactions, and they
      can be serialized also if performed as consistent
      reads. */

      prebuilt->select_lock_type = LOCK_S;
      prebuilt->stored_select_lock_type = LOCK_S;
    }

この、Consistent Nonlocking Readsには2つ注意点があります。

  • READ-COMMITEDの場合は、スナップショットのバージョンがトランザクション内で変わる。
  • スナップショットは更新できない。更新は最新バージョンに対して行うが、スナップショットとは値が異なることがある。

REPEATABLE-READのときは、トランザクションの最初から最後まで同じスナップショットのバージョンです。ところが、READ-COMMITEDのときは1つのSQL文の中でのみ同じものです。トランザクション中であっても、1文目と2文目では異なるスナップショット(のバージョン)が使われる可能性があります。(1文内だけのConsistent Nonlocking Reads)
スナップショットはコミット済の確定した内容なので、その行をロックして変更することはできません。トラザクション内で更新が必要な場合は、スナップショットではなく、最新バージョンをロックする必要があります。もし、最新のバージョンが、自分のトランザクションIDより新しいものであった場合、その内容はスナップショットとは異なるものになります。そのため、トランザクションに更新を含む場合、スナップショットを使ったConsistent Nonlocking Readsとの混合は、REPEATABLE-READを満たすことができません。
下図は、REPEATABLE-READのトランザクションにおいて、スナップショットの様子を図にしたものです。
f:id:bizstation:20141223115353p:plain
トランザクション2がConsistent Nonlocking Reads で読み取りを行ったあと、record 2を更新するには、トランザクション3(trx_id = 3)によって更新された最新のバージョンを読み取って排他ロック(X)を取得しなくてはなりません。最新のバージョンはConsistent Nonlocking Readsで読み取ったバージョンとは異なります。絶対に、ロックなしREADによって得られた値を元に更新してはいけません。

Consistent locking Reads

Consistent locking ReadsというワードはMySQLのドキュメントにはありませんが、Nonlockingと対比するために便宜的にこう呼んでいます。
Consistent locking Readsは、トランザクション内で読み取ったレコードをすべてネクストキーロックして、更新と挿入をブロックすることで、Consistent readを実現する方法です。(この方法は、更新を行いつつ一貫性のある読み取りができますが、ロックが多くなるためトランザクションの同時実行性能は大きく低下してしまいます。)
実はこのConsistent locking Readsが、SERIALIZABLE です。前述のとおり、SERIALIZABLE を指定するとロックを指定していない読み取りにおいても、ネクストキーロック(S)を自動で取得します。
それ以外の分離レベルでは、SELECT ... FOR UPDATE や SELECT ... LOCK IN SHARE MODE を使ってロックを指定しないと読み取りにロックをかけません。Consistent locking Readsにするには明示的にロックを行う必要があります。ちなみに、REPEATABLE-READですべての読み取りにロックを指定をすると、SERIALIZABLEと同じになります。
READ_COMMITEDでは、ロック指定読み取り時にネクストキーロックに替えて通常の行ロックが使用されます。READ_COMMITEDはファントムリードを防止できなくてもよいのでこれでよいのですが、Consistent locking Readsにはなりません。

ロック付読み取り時のロック対象

クエリーにマッチしないレコードのロック保持と開放について説明します。
クエリーの検索対象は、インデックスを使った特定のレコード範囲(または複数の範囲)、または全レコードのどちらかです。その際に、範囲内ではあるけれども条件にマッチしないレコードのロックはどうなるのでしょうか?
それは分離レベルによって以下のようになります。

SERIALIZABLE
REPEATABLE-READ
ロックを保持
(ファントムリードを防止するため)
READ_COMMITED ロックを解放

この保持するか否かの実装は、InnoDBのha_innobase::unlock_row()内にあります。MySQLもTransactdもアンマッチの時は、unlock_row()を呼び出しますが、InnoDBがREPEATABLE-READとSERIALIZABLEのときは無視してロックを解放しないようになっています。以下はそれを実装するInnoDBソースコード抜粋です。

// /mysql-5.6.20/storage/innobase/handler/ha_innodb.cc : 7222
  switch (prebuilt->row_read_type) {
  case ROW_READ_WITH_LOCKS:
    if (!srv_locks_unsafe_for_binlog
        && prebuilt->trx->isolation_level
        > TRX_ISO_READ_COMMITTED) {
      break;
    }
    /* fall through */
  case ROW_READ_TRY_SEMI_CONSISTENT:
    row_unlock_for_mysql(prebuilt, FALSE);
    break;
  case ROW_READ_DID_SEMI_CONSISTENT:
    prebuilt->row_read_type = ROW_READ_TRY_SEMI_CONSISTENT;
    break;
  }

row_read_typeがROW_READ_WITH_LOCKSであるときは、直前の読み取りでロックをしていることを示します。isolation_levelがTRX_ISO_READ_COMMITTEDより大きい場合はbreakして次のアンロック関数 row_unlock_for_mysql()が呼び出されません。

MySQLのロック制御

ここまではInnoDBのロック機能について説明しました。それとは別にMySQLにはテーブルロック機能があります。また、MySQLがどのように、InnoDBのロックを制御するか説明します。

LOCK TABLES statement

LOCK TABLES statementは、InnoDBのロックとは無関係です。LOCK TABLESは、MySQLの管理するテーブルロックマネージャの機能を利用して実現するものです。MySQLのテーブル操作はhandlerインターフェースを通じて行いますが、handlerインターフェースでテーブルにアクセスするには事前に必ずテーブルのロックを取得しなければなりません。
LOCK TABLES ... READは、ロックマネージャから書き込みをブロックするTL_READロック(自身も他者もREAD可 WRITE不可)を取得します。以降このテーブルはREADロックの取得のみ許可されます。
LOCK TABLES ... WRITEはTL_WRITE排他ロック(自身はREAD WRITE可、他者はREAD WRITE不可)を取得して、以降このテーブルのすべてのロック取得をブロックします。すなわち、読み取りも書き込みもできなくなります。
取得したロックは、UNLOCK TABLESで開放されます。

このLOCK TABLESは、行レベルのロックではくテーブル単位のロックなので、書き込みのある複数トランザクションの同時実行はできません。分離レベルの概念もなくトランザクションは占有です。あえて分離レベル風に名前をつけると EXCLUSIVE です。

トランザクションはLOCK TABLES...で自動で開始されています。AUTOCOMMITが有効だと文の終了でCOMMITされ、後でROLLBACKできなくなってしまいます。複数のSQL文を発行するときはLOCK TABLESの前に、SET AUTOCOMMIT=0 で無効にしておきましょう。UNLOCK TABLESの前にCOMMITまたはROLLBACKすることで、アトミックな処理ができます。

Transactdでテーブルをロックする

少しだけTransactdでの処理を説明します。
Transactdでは、テーブルをオープンするときのmodeパラメータに、TD_OPEN_EXCLUSIVE か TD_OPEN_READONLY_EXCLUSIVEを指定することで、WRITEまたはREADのテーブルロックを行うことができます。

MySQLはLOCK TABLES呼び出し時にすべてのテーブルの列挙が必要で、後からテーブルを追加したりできませんが、Transactdは後からいくつでも追加できます。また、通常モードとEXCLUSIVEモードを混在させてオープンすることもできます。さらに、それらのテーブルが混在したトランザクションもそのまま行えます。SQL文にはアトミックな処理に独特の癖がありますが、Transactdにはそのような特別なことは何もありません。

// テーブル1を排他ロックでオープン。 LOCK TABLES ... WRITEと同じ
table* tb1 = db->openTable(name1, TD_OPEN_EXCLUSIVE); 
// テーブル2をREADロックでオープン。 LOCK TABLES ... READと同じ
table* tb2 = db->openTable(name2, TD_OPEN_READONLY_EXCLUSIVE); 
//ロックモードが異なるテーブルが混在したトランザクションもOK!
db->beginTrn();
...
tb2->seek();
...
tb1->update();
...
db->endTrn();

分離レベルとロック制御

分離レベルに応じてInnoDBがどのように読み取り時にロックをするかを表にまとめます。
読み取りには、ロックなしと有り(SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使った読み取り)がありますので、それぞれをトランザクション中に使うとどのような読み取り処理をするかが整理できます。併せてロック有りの際にアンマッチレコードのロック開放を行うかどうかも加えておきました。

なお、ここでのMySQLトランザクションは、 START TRANSACTIONで開始しCOMMITで終了するトランザクションと考えてください。(1文での自動トランザクションも基本的には同じですが、更新を含まない場合、ロックをしないなどの最適化がMySQL内部で自動的に行われます。)

MySQLの分離レベルと読み取りロックタイプ表
分離レベル ロックなしREAD ロック有READ*3 アンマッチレコードのロック解放
SERIALIZABLE できない Next key lock しない
REPEATABLE-READ Consistent Nonlocking Reads
トランザクション内で同一バージョンのスナップショット
Next key lock しない
READ-COMMITED Consistent Nonlocking Reads
1つのSQL文内で同一バージョンのスナップショット
row lock する

なお、更新や削除を行う場合は、対象レコードの読み取り時に自動で排他ロック(X)の取得がトライされ、取得できれば実行されます。

まとめ

ここまで見てきたInnoDBの仕様やロックの特性から、MySQLSQL文でミッションクリティカルなアプリケーションを書く場合に大切なことをまとめます。

  • 分離レベルのデフォルトは、REPEATABLE-READにする。(以下の前提条件)
  • アトミックな操作は、トランザクションで(START TRANSACTION ... COMMIT)で行う。
  • 複数のSQL文による読み取りで一貫性が必要なら、更新が無くともトランザクション内で(START TRANSACTION ... COMMIT)で読み取る。
  • 行ロックはトランザクション内であっても、SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使って明示的しなければ行われない。ただし、分離レベルをSERIALIZABLEにすれば、明示しない読み取りも共有ロック(S)が取得できる。
  • (REPEATABLE-READの)トランザクション内において、ロックなし読み取りと更新処理の混在に注意する。この組み合わせは、REPEATABLE-READではなく、異なった値を読むことがある。受け入れられない場合は、すべての読み取りをロック付読み取りにするか、SERIALIZABLEにする。
  • ファントムリードが問題にならないトランザクションであれば、同時実行性を向上するためにREAD-COMMITEDを使う。
  • テーブルを占有したいときは、SET AUTOCOMMIT=0の後で、LOCK TABLES ... を呼び、処理終了後にCOMMITまたはROLLBACKしてから、UNLOCK TABLESを呼び出す。テーブルロックはテーブルを占有してしまうので、マルチユーザー環境では特別な状況以外使用してはいけない。

いかがだったでしょうか?
これらを理解し実践することがミッションクリティカルなアプリケーションの第一歩になると思います。何か参考になればいいなと思います。

次回、その2ではTransactdのInnoDBロック制御について詳しく書きたいと思います。

*1:参考コード /mysql-5.6.20/storage/innobase/lock/lock0lock.cc : 5011 lock_rec_print()

*2:どちらになるかは検索条件によって変わります

*3:SELECT...FOR UPDATE(X)または SELECT...LOCK IN SHARE MODE(S)を使った読み取り