全て クラス 名前空間 関数 変数 型定義 列挙型 列挙値 ページ
InnoDBのロックとその制御

InnoDBのロックとそれの制御方法について説明します。

ミッションクリティカルなアプリケーション

マルチユーザー環境でミッションクリティカルなアプリケーションを書くには、ロックの理解が不可欠です。ロックをうまく使って矛盾や間違いのない読み書きと同時実行性の良いアプリケーションにしましょう。 説明に入る前に、事前にMySQLのトランザクション関連用語の理解が必要です。下記の用語についてあまりよくわからない場合は、大まかで良いので理解しておいてください。

ACID
MVCC(マルチバージョン コンカレンシー コントロール)
トランザクション分離レベルとファントムリード

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つのトランザクションが同じレコードのロックを取得しようとしたときのロックの可否を表しています。「可」の組み合わせでは互いに競合せず双方とも取得できます。「負荷」の組み合わせでは、後から取得しようとした方は、先に取得されたロックが解放されるまでロックを取得することができません。

共有ロック(S)排他ロック(X)
共有ロック(S)不可
排他ロック(X)不可不可

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

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

GAPロック (row-level locking)

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

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)を取得しています。

//トランザクション1
.`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が指定されていると、行ロックに替えてネクストキーロックが使用されます。

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

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

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

Multi-Versioning

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

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

Consistent Reads

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

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つ注意点があります。

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

Consistent reads.png

トランザクション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)を自動で取得します。 それ以外の分離レベルでは、ロックを指定しないと読み取りにロックをかけません。Consistent locking Readsにするには明示的にロックを行う必要があります。ちなみに、REPEATABLE-READですべての読み取りにロックを指定をすると、SERIALIZABLEと同じになります。
READ_COMMITEDでは、ロック指定読み取り時にネクストキーロックに替えて通常の行ロックが使用されます。READ_COMMITEDはファントムリードを防止できなくてもよいのでこれでよいのですが、Consistent locking Readsにはなりません。

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

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

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の行ロックシステムとは別に、テーブルをロックする機能があります。
SQL文で言うとLOCK TABLE statementがそれに相当します。
LOCK TABLEは、MySQLの管理するテーブルロックマネージャの機能を利用して実現するものです。MySQLのテーブル操作はhandlerインターフェースを通じて行いますが、handlerインターフェースでテーブルにアクセスするには事前に必ずテーブルのロックを取得しなければなりません。
LOCK TABLE ... READは、ロックマネージャから書き込みをブロックするTL_READロック(自身も他者もREAD可 WRITE不可)を取得します。以降このテーブルはREADロックの取得のみ許可されます。
LOCK TABLE ... WRITEはTL_WRITE排他ロック(自身はREAD WRITE可、他者はREAD WRITE不可)を取得して、以降このテーブルのすべてのロック取得をブロックします。すなわち、読み取りも書き込みもできなくなります。 取得したロックは、UNLOCK TABLEで開放されます。
Transactdでの、テーブルロックは、nsdatabase::openTabe() メソッドのmodeパラメータに、TD_OPEN_EXCLUSIVEを指定するとWRITE、TD_OPEN_READONLY_EXCLUSIVEを指定するとREADロックで行うようになっています。どちらも、テーブルを閉じる( nstable::close() )とロックが開放されます。
1つのデータベース内でテーブルごとに、EXCLUSIVEモードと通常モードを混在させてオープンすることができます。さらに、それらのテーブルが混在したトランザクションもそのまま行えます。

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

分離レベルとロック

分離レベルに応じてInnoDBがどのように読み取り時にロックをするかを表にまとめます。

MySQLの分離レベルと読み取りロックタイプ表

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

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

Transactdでのロック制御

ここまでは、主にInnoDBのロックの内容について説明してきましたが具体的にTransactdにおいてどのようにそれらを制御するか説明します。
Transactdのトランザクションには以下の3つの種類があります。

自動トランザクション

自動トランザクションとは、Trasnactd内部で暗黙のうちに自動的に開始されるトランザクションです。その他の2つは明示的に開始を指定します。自動トランザクションは、スナップショットまたはユーザートランザクションが開始されていない時に、テーブルに対するアクセスを行うと自動で開始されます。終了は1つのオペレーションが終わると終了します。すなわち、1回のテーブルアクセスの通信の度に開始され終了します。
自動トランザクションの読み取りオペレーションは、常に nonlocking readsでロックを取得しません。オペレーションごとにトランザクションが開始されるため、その都度最新のスナップショットが使用されます。
行削除 nstable::update() と更新 nstable::del() は、呼び出されたときに、直前のオペレーションで読み取られたカレントレコードをサーバー側で再度読み直し、排他ロック(X)を取得し削除・更新を行います。直前のオペレーションで読み取られた値と最新の値が異なった場合は、それを検出して nstable::stat() にSTATUS_CHANGE_CONFLICTエラーが返ります。そのため、このシナリオのロストアップデートは起こりません。また、事前にロックを取得してから更新する方法もあります。それらについては、以降で詳しく説明します。

ユーザートランザクション

ユーザートランザクションは読み書き可能なトランザクションです。 nsdatabase::beginTrn(short bias=SINGLELOCK_READ_COMMITED+NOWAIT_WRITE)メソッドで開始し、 nsdatabase::endTrn() または nsdatabase::abortTrn() で終了します。
ユーザートランザクションは開始メソッドのbias値によって複数のロックタイプとロック粒度を選択できます。ロック粒度には、シングルレコードロックとマルチレコードロックの2種類があります。

以下はbias値に対するロック粒度とロックタイプの一覧表です。

beginTranのbias値 ロック粒度 InnoDB分離レベル 行ロックタイプ
SINGLELOCK_NOGAP シングルレコードロック READ_COMMITED row lock(X)
MULTILOCK_NOGAP マルチレコードロック READ_COMMITED row lock(X)
MULTILOCK_GAP マルチレコードロック REPEATABLE_READ next key lock(X)

biasのデフォルト値は、SINGLELOCK_NOGAPです。SINGLELOCK_NOGAPは、最後に読み取ったレコードの排他ロック(X)のみ保持します。更新や削除を行う際にはその行を確定するために読み取りを必要とします。更新はその読み取った値に基づいて行うことでロストアップデートのない処理を行うことができます。また、ロックの範囲が非常に狭いため最も同時実行性の良い処理が行えます。
MULTILOCK_NOGAPは、アクセスしたすべてのレコードを排他ロック(X)を取得します。但し、row lockなので読み取り範囲内への挿入ブロックはできません。ロックの範囲は広くなりますので、同時実行性は悪くなります。
MULTILOCK_GAPは、アクセスしたすべてのレコードを排他ロック(X)+GAPロックします。これによりアクセスした範囲への挿入もブロックします。InnoDB分離レベルは、REPEATABLE_READ を使用していますが、機能上の分離レベルのSERIALIZABLEになります。同時実行性は最も悪くなりますが 完全な読み取り一貫性を確保した更新が行えます。

SINGLELOCK_NOGAPとMULTILOCK_NOGAP(1番目と2番目)の場合は、nstable::unlock() にて直前の読み取り行のロックを解放することができます。必要な行のみロックを得たい場合に細かな制御を可能にします。尚、 nstable::find()など一回のオペレーションで複数の行を取得する場合は、ロックの解放はできません。 nstable::unlock() は直前の読み取り行のみ有効でなためです。細かくロック、アンロックの制御を行いたい場合は、 seek系オペレーションを使用してください。

MULTILOCK_GAPまたはMULTILOCK_NOGAPでは、読み取りオペレーションの lockBias値にROW_LOCK_Sを指定することで、排他ロック(X)に替えて共有ロック(S)にすることができます。更新を行わない行の読み取りに際してこれを使うことで、不要な排他ロック(X)を防止し必要な行のみ排他ロック(X)にすることができます。lockBias値が指定できる読み取りオペレーションは、 nastable::seek系とnastable::stespk系のオペレーションです。

スナップショット

スナップショットは読み取り専用トランザクションです。 nsdatabase::beginSnapshot(short bias=CONSISTENT_READ)メソッドで開始し、nsdatabase::endSnapshot() で終了します。 スナップショットは開始メソッドのbias値によって複数のロックタイプを選択できます。
以下はbias値に対するロック粒度とロックタイプの一覧表です。

beginSnapshotのbias値 ロック粒度 InnoDB分離レベル 行ロックタイプ
CONSISTENT_READ 対象外 REPEATABLE_READ ロックなし
(consistent nonlocking reads)
MULTILOCK_NOGAP_SHARE マルチレコードロック READ_COMMITED row lock(S)
MULTILOCK_GAP_SHARE マルチレコードロック REPEATABLE_READ next key lock(S)

biasのデフォルト値は、CONSISTENT_READです。CONSISTENT_READは、REPEATABLE_READによる一貫性のある読み取りが行えます。集計処理などにおいて、読み取りの開始から最後まで一貫性を要する場合などに使用します。
MULTILOCK_NOGAP_SHAREの場合は、nstable::unlock() にて直前の読み取り行のロックを解放することができます。 必要な行のみロックを得たい場合に細かな制御を可能にします。尚、 nstable::find()など一回のオペレーションで複数の行を取得する場合は、ロックの解放はできません。 nstable::unlock() は直前の読み取り行のみ有効でなためです。細かくロック、アンロックの制御を行いたい場合は、 seek系オペレーションを使用してください。

自動トランザクション時の行ロック

自動トランザクションの読み取りはデフォルトで、nonlocking readsですが、lockBias値にROW_LOCK_Xを指定することで、排他ロック(X)を取得することができます。単純に1つのレコードを更新したい場合に使用します。

...
tb->setFV("id", 1);
tb->seek(ROW_LOCK_X);
if (tb->stat() == 0)
{
tb->setFV("name", "ABC");
tb->update();
if (tb->stat() == 0)
;// success!
}
...

自動トランザクションは、通常1つのオペレーションで終了してしまいますが、このロックオペレーションを行った場合に限り、次に続くオペレーションを行ってから終了します。これによりロストアップデートの無い更新が・削除が行えます。
また、このロックは、テーブルごとに最後にアクセスしたレコードのみ有効で次に何らかのオペレーションを行うとそのロックは解放されます。

ロックWait

ロックが競合した場合、InnoDBは自動的に規定時間内リトライを繰り返します。その間にロックが解放されればそのまま処理が進みます。解放されなかった場合は、Transactdが nstable::stat() にSTATUS_LOCK_ERRORを返します。既定時間は、サーバー側の my.cnf の、mysqldセクション transactd_lock_wait_timeout にて秒で指定します。エントリが無い場合はデフォルト値 1 が使用されます。my.cnfでの設定の詳細は Transactd 運用マニュアルを参照してください。 また、 nsdatabase::lockWaitCount()nsdatabase::lockWaitTime() によってクライアント側でリトライすることもできます。これらは、Transactdへのアクセスにおいてはデフォルトで共にゼロが設定され無効になっています。多くの場合は、InnoDBによるリトライの方が、通信のオーバーヘッドがなく効率的です。

ロックのリトライ問題

ロックのリトライでは間違ったレコードをロックしてしまうかも知れないシナリオがあります。
たとえば、nstable::seekLast() で最後のレコードを読み取ってその id の値を +1してその後ろにそれを id としてレコードを挿入するプログラムなどです。 最後のレコード id = 5 の状態のときに seekLast() したらロックが競合しリトライしているとします。 その間にid = 6のレコードが他のトランザクションによって追加されたときに、ロックできなったレコードをターゲットにしたままにしてしまうと正しいレコード id = 6を返さず、既に最後ではなくなった id = 5 を読み取ってしまうことになります。
このような間違った読み取り( wrong reading)はInnoDBによるリトライもクライアント側でのリトライでも発生しません。ロックされていた場合は、最初から最終レコードを探し直します。( seekLast()を最初からし直す)

Transactd SDK 2018年07月31日(火) 19時40分25秒 doxygen