データベースシステムにおけるロックは、複数のプロセスやスレッドが共有リソースに同時にアクセスする際の整合性を保証するための重要なメカニズムです。特に、CPUやメモリなどの計算リソースの競合に加えて、データ自体が多くのユーザーによって共有されるリソースであるため、データの同時アクセスにおける一貫性と有効性をどのように維持するかは、すべてのデータベースシステムが解決すべき課題です。ロックの競合は、データベースの並行アクセス性能に大きな影響を与える要因でもあります。
- MySQLにおけるロックの基本的な概念
MySQLには「ラッチ(Latch)」と「ロック(Lock)」という二つの概念が存在し、どちらも広義には「ロック」と称されますが、その目的と性質は大きく異なります。
ラッチは、通常「軽量ロック」とも呼ばれ、非常に短時間のリソース保護を目的としています。その保持時間が長くなると、アプリケーションの性能に著しい悪影響を及ぼす可能性があります。InnoDBストレージエンジンでは、ラッチはさらにミューテックス(Mutex)と読み書きロック(RWLock)に分類されます。これらは、並行スレッドがクリティカルセクションにアクセスする際の正確性を保証するために使用され、通常はデッドロック検出機構を持ちません。
一方、ロックはトランザクションを対象とし、データベース内のテーブル、ページ、行などのオブジェクトを保護するために使用されます。一般的に、ロックはトランザクションがコミットまたはロールバックされた後に解放されます(ただし、トランザクション分離レベルによって解放タイミングが異なる場合があります)。本稿では、主にこの「ロック」に焦点を当てて解説します。
1.1. ロックの種類と互換性
データに対する操作は、主に読み取り(Read)と書き込み(Write)の2種類です。データベースはこれらの操作に対応するため、異なる種類のロックを実装しています。InnoDBストレージエンジンは、標準的な行レベルロックとして、以下の2種類を提供します。
- 共有ロック(Shared Lock、Sロック): トランザクションが特定の行データを読み取ることを許可します。
- 排他ロック(Exclusive Lock、Xロック): トランザクションが特定の行データを削除または更新することを許可します。
これらのロックは、その名前が示す通り、互換性に関して異なる特性を持ちます。共有ロック同士は互換性があり、複数のトランザクションが同時に同じデータを読み取ることができます。しかし、排他ロックは他のいかなるロック(共有ロックまたは排他ロック)とも互換性がなく、一度排他ロックが取得されると、他のトランザクションはそのデータへのアクセスがブロックされます。
| Sロック要求 | Xロック要求 | |
|---|---|---|
| 既存のSロック | 互換性あり | 互換性なし |
| 既存のXロック | 互換性なし | 互換性なし |
| ロックの互換性マトリックス |
この設計は、読み取り操作(共有ロック)は並行して実行可能である一方、書き込み操作(排他ロック)は直列に実行される必要があるという原則に基づいています。これにより、複数のスレッドが競合することなく、データの一貫性と安全性が保証されます。
1.2. ロックの粒度
ロックの粒度は、その対象となるデータ範囲の大小を指し、主にテーブルロック、ページロック、行ロックの3種類があります。各ストレージエンジンは、異なる粒度のロックをサポートしています。
テーブルロック
テーブルレベルのロックは、MySQLのストレージエンジンが提供する中で最も粗い粒度のロック機構です。この方式の最大の利点は、実装が非常に単純であり、システムへのオーバーヘッドが最小限に抑えられることです。そのため、ロックの取得と解放が非常に高速です。また、テーブル全体を一度にロックするため、デッドロックの問題が発生しにくいという特徴もあります。
しかし、粒度が粗いことによる最大の欠点は、ロック競合の発生確率が最も高くなることです。これにより、システムの並行性が著しく低下する可能性があります。主にMyISAM、MEMORY、CSVなどの非トランザクション型ストレージエンジンで利用されています。
テーブルロックは以下の構文で明示的に制御できます。
-- テーブルロックの取得
LOCK TABLES
table_name [[AS] alias] lock_type
[, table_name [[AS] alias] lock_type] ...
lock_type:
READ [LOCAL]
| [LOW_PRIORITY] WRITE
-- テーブルロックの解放
UNLOCK TABLES;
MyISAMでは、クエリ実行時に自動的にロックの取得と解放が行われるため、通常はユーザーが手動でロックを操作する必要はありません。しかし、特定の時点でのスナップショットデータが必要な場合など、明示的なロックが有用な場面もあります。例えば、複数のテーブルから同時にデータ数を集計したい場合などが挙げられます。
LOCK TABLE sales_data READ, inventory_log READ;
SELECT COUNT(order_id) AS total_sales FROM sales_data;
SELECT COUNT(item_id) AS total_items FROM inventory_log;
UNLOCK TABLES;
ページロック
ページロックは、行ロックとテーブルロックの中間的な粒度を持つロックであり、他の多くのデータベース管理システムではあまり一般的ではありません。このロック方式は、行ロックとテーブルロックの中間の特性を持つため、ロックの取得にかかるリソースコストや提供できる並行処理能力も両者の中間に位置します。ページロックも行ロックと同様に、デッドロックが発生する可能性があります。
一般的に、ロックの粒度が小さくなるほど、同じデータ量をロックするために必要となるメモリ量が増加し、アルゴリズムも複雑になります。しかし、粒度が小さくなることで、アプリケーションがロック待ちに遭遇する可能性は低下し、システム全体の並行性は向上します。BerkeleyDBストレージエンジンがページロックを使用する代表例です。
行ロック
行レベルのロックは、ロック対象の粒度が最も小さいことが特徴です。現在、主要なデータベース管理ソフトウェアが提供するロック粒度の中でも最小であり、最も高い並行処理能力を提供します。これにより、ロック競合の発生確率が最小限に抑えられ、高並行性を要求されるアプリケーションの全体的な性能向上に貢献します。
高い並行処理能力という大きな利点がある一方で、行レベルロックにはいくつかの欠点も存在します。ロックの粒度が非常に小さいため、ロックの取得および解放に多くの処理が必要となり、それに伴うオーバーヘッドも大きくなります。また、行レベルロックはデッドロックが最も発生しやすいロックでもあります。InnoDBストレージエンジンが行ロックを使用する主要なエンジンです。
ロック粒度のまとめ
- テーブルロック: オーバーヘッドが小さく、ロック取得が高速。デッドロックは発生しない。粒度が粗く、ロック競合の発生率が最も高いため、並行性は最低。
- 行ロック: オーバーヘッドが大きく、ロック取得が低速。デッドロックは発生する。粒度が最も細かく、ロック競合の発生率が最低のため、並行性は最高。
- ページロック: オーバーヘッドとロック取得時間はテーブルロックと行ロックの中間。デッドロックは発生する。粒度はテーブルロックと行ロックの中間で、並行性は一般的。
ロックの観点から見ると、テーブルロックは、読み取りが主で、インデックス条件によるデータ更新が少ないWebアプリケーションなどに適しています。一方、行ロックは、インデックス条件に基づき少量の異なるデータを並行して大量に更新し、同時に並行読み取りも行うオンライン取引処理(OLTP)システムのようなアプリケーションにより適しています。
- InnoDBストレージエンジンのロック
InnoDBは、行ロックとテーブルロックという多粒度ロックをサポートしています。この多粒度ロックを効率的に実現するため、「意図ロック(Intention Lock)」という概念を導入しています。
2.1. 意図ロック
意図ロックは、テーブル内の特定の行にロックをかける前に、そのテーブル自体に対してかけられる事前ロックです。具体的な例を考えてみましょう。もし意図ロックがなければ、誰かがテーブル内の特定の行に排他ロック(Xロック)をかけて変更している最中に、別のリクエストがテーブル全体に対して排他ロックをかけようとした場合、データベースはテーブル内のすべての行がロックされているかどうかをスキャンして確認する必要があります。このような状況では、非常に効率が悪くなります。
しかし、意図ロックが導入されると、行に排他ロック(Xロック)をかけるトランザクションは、まずテーブル全体に意図排他ロック(IXロック)を取得し、その後に目的の行に排他ロック(Xロック)をかけます。この状態で、もし別のトランザクションがテーブル全体に排他ロックをかけようとした場合、テーブルの意図排他ロック(IXロック)の解放を待つだけでよくなり、テーブル内の個々の行をスキャンする必要がなくなります。これにより、効率が大幅に向上します。
意図ロックも、以下の2種類に分類されます。
- 意図共有ロック(Intention Shared Lock、ISロック): トランザクションがテーブル内の特定のレコードに共有ロックを取得しようとしていることを示します。
- 意図排他ロック(Intention Exclusive Lock、IXロック): トランザクションがテーブル内の特定のレコードに排他ロックを取得しようとしていることを示します。
意図ロックが加わることで、ロックタイプ間の互換性マトリックスはさらに複雑になります。
| 既存のロック \ 要求されるロック | IS | IX | S | X |
|---|---|---|---|---|
| IS | 互換性あり | 互換性あり | 互換性あり | 互換性なし |
| IX | 互換性あり | 互換性あり | 互換性なし | 互換性なし |
| S | 互換性あり | 互換性なし | 互換性あり | 互換性なし |
| X | 互換性なし | 互換性なし | 互換性なし | 互換性なし |
| 意図ロックを含むロックの互換性マトリックス |
意図ロック自体は、全テーブルスキャン以外のリクエストを直接ブロックすることはほとんどありません。その主な目的は、「テーブル内の特定の行データに対してロックを要求しているトランザクションが存在するかどうか」を示すことにあります。
2.2. 行ロックアルゴリズム
InnoDBストレージエンジンは、3種類の行ロックアルゴリズムを提供します。
- レコードロック(Record Lock): 単一のインデックスレコードにかけられるロックです。InnoDBテーブルに明示的なインデックスが設定されていない場合、内部的に作成される隠れた主キーがロック対象となります。
- ギャップロック(Gap Lock): インデックスレコード間の「ギャップ」、すなわち特定の範囲をロックしますが、レコード自体はロックしません。これにより、その範囲への新しいレコードの挿入が阻止されます。
- ネクストキーロック(Next-Key Lock): レコードロックとギャップロックを組み合わせたロックアルゴリズムです。特定のレコードとその直前のギャップの両方をロックします。InnoDBは、デフォルトの分離レベルであるRepeatable Readにおいて、範囲検索時のロックにこのネクストキーロックを適用します。
例えば、インデックスに10, 11, 13, 20という値が存在する場合、ネクストキーロックが適用される区間は以下のようになります。
(-∞, 10], (10, 11], (11, 13], (13, 20], (20, +∞)
([a, b) は a を含み b を含まない区間、(a, b] は a を含まない b を含む区間)
ネクストキーロックは強力ですが、すべてのインデックスに適用されるわけではありません。クエリの対象がユニークインデックス(主キーインデックスを含む)の場合、ネクストキーロックはレコードロックにダウングレードされます。
次に、具体的な例を通じてこれらのロック動作を説明します。
CREATE TABLE product_inventory (
product_id INT PRIMARY KEY, -- 主キーインデックス
category_id INT, -- 通常インデックス
product_name VARCHAR(100)
);
INSERT INTO product_inventory VALUES (1, 101, 'Laptop');
INSERT INTO product_inventory VALUES (3, 101, 'Monitor');
INSERT INTO product_inventory VALUES (5, 103, 'Keyboard');
INSERT INTO product_inventory VALUES (7, 106, 'Mouse');
INSERT INTO product_inventory VALUES (10, 108, 'Webcam');
セッションAで以下の更新クエリを実行します。
-- セッションA
SELECT * FROM product_inventory WHERE category_id = 103 FOR UPDATE;
このクエリはcategory_idが103のレコード(product_idが5のレコード)をロックするだけでなく、category_idインデックスにおいて、そのレコードが存在するギャップ、つまり(101, 103]の範囲と(103, 106]の範囲の一部(category_id = 103のレコードの前のギャップ)にネクストキーロックが適用されます。
この状態でセッションBが、ロック範囲に該当する以下のいずれかの操作を実行しようとすると、セッションAのロックが解放されるまで待機(waiting)状態になります。
-- セッションB (待機状態になる例)
-- category_idが103のレコードを共有ロックしようとする
SELECT * FROM product_inventory WHERE product_id = 5 LOCK IN SHARE MODE;
-- category_idが101と103の間のギャップに挿入しようとする (例: category_id = 102)
INSERT INTO product_inventory VALUES (4, 102, 'Speakers');
-- category_idが103と106の間のギャップに挿入しようとする (例: category_id = 104)
INSERT INTO product_inventory VALUES (6, 104, 'Headphones');
ギャップロックは、以下の2つの方法で明示的に無効にすることができます。
- トランザクションの分離レベルを
READ COMMITTEDに設定する。 innodb_locks_unsafe_for_binlogパラメータを1に設定する。
この例からわかるように、ギャップロックの主な目的は、複数のトランザクションが同じ範囲に新しいレコードを挿入するのを防ぐことです。これは「ファントムリード問題(Phantom Problem)」を解決するために設計されました。MySQLのデフォルト分離レベルであるREPEATABLE READでは、InnoDBはこのメカニズムを使用してファントムリードを防ぎます。
ファントムリードとは、同一トランザクション内で同じSQL文を連続して2回実行した際に、2回目の実行で以前には存在しなかった新しい行が返される現象を指します。これは、最初の実行と2回目の実行の間に他のトランザクションが新しい行を挿入したために発生します。
2.3. 一貫性非ロック読み取り (Consistent Nonlocking Reads)
「一貫性非ロック読み取り」とは、InnoDBストレージエンジンがMVCC(Multi-Version Concurrency Control)メカニズムを利用して、現在の実行時点でのデータベース行データを読み取る方式を指します。もし読み取ろうとしている行がDELETEやUPDATE操作によってロックされている場合でも、読み取り操作は排他ロック(Xロック)のようにロックの解放を待つことなく、その行の「スナップショットデータ」を読み取ります。
InnoDBストレージエンジンは、READ COMMITTED(RC)およびREPEATABLE READ(RR)の分離レベルで一貫性非ロック読み取りを使用します。ただし、スナップショットデータの定義がこれらの分離レベル間で異なります。
- RCレベル: 各文の実行時にロックされた行の最新のスナップショットデータを常に読み取ります。
- RRレベル: トランザクションが開始した時点の行データのバージョンを常に読み取ります。
以下の例で、MVCCの特性とRC/RRの違いを確認してみましょう。
| ステップ | セッションA | セッションB | 説明 |
|---|---|---|---|
| 1 | START TRANSACTION; |
START TRANSACTION; |
両セッションでトランザクション開始。accountsテーブルにuser_id=1, balance=100のレコードが存在。 |
| 2 | SELECT balance FROM accounts WHERE user_id = 1; |
Aはbalance=100を読み取る。 |
|
| 3 | UPDATE accounts SET balance = 150 WHERE user_id = 1; |
Bがuser_id=1の残高を150に更新。 |
|
| 4 | SELECT balance FROM accounts WHERE user_id = 1; |
Aは再びbalance=100を読み取る(RRの場合)。RCの場合、最新コミットデータを読み取るため、Bがコミットしていれば150。Bが未コミットであれば100。 |
|
| 5 | COMMIT; |
Bがトランザクションをコミット。 | |
| 6 | SELECT balance FROM accounts WHERE user_id = 1; |
RRの場合、Aは引き続きbalance=100を読み取る(トランザクション開始時のスナップショット)。RCの場合、balance=150を読み取る。 |
|
| 7 | UPDATE accounts SET balance = 200 WHERE user_id = 1; |
Aは自身のスナップショット100に基づいて200に更新しようとする。実際にデータベース上のレコードは150。 |
|
| 8 | SELECT balance FROM accounts WHERE user_id = 1; |
Aは自身の更新操作により、balance=200を読み取る。 |
|
| 9 | COMMIT; |
Aがコミット。最終的にbalance=200が確定。 |
ステップ1〜2は明確ですが、ステップ3でトランザクションBがデータを更新し、セッションBがコミットする前のステップ4において、セッションAが再度読み取っても、REPEATABLE READ分離レベルでは依然として最初のスナップショットデータ(balance=100)しか参照できません。これはMVCCの特性によるものです。セッションBがコミットした後のステップ6での挙動は、RCとRRで異なるスナップショットの取得タイミングに起因します。
特に注目すべきはステップ7です。セッションAは、自身からは見えない(スナップショットに含まれない)更新されたレコードを更新しようとします。これは成功し、その後のステップ8ではセッションAが自身の変更を見ることができるため、更新された値が読み取れます。これは、MVCCにおける「不可視」は読み取りに関するものであり、レコードが実際に存在しないわけではないため、更新操作は実行できるという点を強調しています。更新が成功すると、トランザクションAはその変更に関するUndoログを記録し、その後のクエリでは自身の行った変更を「可視」として扱います。
2.4. 一貫性ロック読み取り (Consistent Locking Reads)
前述の通り、デフォルトのREPEATABLE READ分離レベルでは、InnoDBストレージエンジンのSELECT操作は一貫性非ロック読み取りを使用します。しかし、特定のシナリオでは、データの論理的な一貫性を保証するために、明示的に読み取り操作にロックをかける必要があります。InnoDBストレージエンジンは、SELECT文に対して以下の2種類の一貫性ロック読み取り操作をサポートしています。
SELECT ... FOR UPDATE: 選択された行に対して排他ロック(Xロック)を取得します。これにより、他のトランザクションがこれらの行を変更したり、XロックまたはSロックを取得したりすることがブロックされます。SELECT ... LOCK IN SHARE MODE: 選択された行に対して共有ロック(Sロック)を取得します。これにより、他のトランザクションがこれらの行に対してSロックを取得することは許可されますが、Xロックを取得しようとするとブロックされます。
- ロックがもたらす課題
ロック機構はトランザクションの分離性を実現し、トランザクションの並行動作を可能にするために不可欠ですが、同時に潜在的な問題を引き起こす可能性もあります。トランザクション分離レベルはこれらの問題の一部を解決することを目的としていますが、ここでは主な課題について簡単に説明します。
3.1. ロストアップデート問題 (Lost Update Problem)
「ロストアップデート」とは、複数のトランザクションが同じデータを読み込み、それぞれが更新を行った結果、最後にコミットされたトランザクションの更新によって、先にコミットされたトランザクションの更新が上書きされ、失われてしまう現象を指します。
例えば、ユーザーの口座残高が100円であるとします。もしアプリケーションが「現在の残高を照会し、その値に基づいて更新を行う」というロジックで送金や入金処理を実装している場合、ロストアップデートが発生する可能性があります。具体的には、トランザクションAが残高100円を読み取り、20円加算して120円に更新しようとしている間に、トランザクションBも同じ残高100円を読み取り、30円加算して130円に更新しようとするとします。もしトランザクションAが先にコミットし、その後トランザクションBがコミットすると、Aの更新結果(120円)はBの更新結果(130円)によって上書きされ、Aの更新は失われます。最終的な残高は130円となりますが、本来は100 + 20 + 30 = 150円であるべきです。
この問題を回避するには、いくつかの方法があります。
- 明示的なロック:
SELECT ... FOR UPDATEを使用して、読み取りと同時に更新対象の行に排他ロックをかけることで、他のトランザクションによる同時更新を防ぎます。 - 楽観的ロック: データにバージョン番号やタイムスタンプのカラムを追加し、更新時にその値が最初に読み取った時点から変更されていないことを確認します。変更されていれば更新を拒否し、アプリケーションレベルで再試行を促します。
- 最新値に基づく更新:
UPDATE accounts SET balance = balance + 20 WHERE user_id = 1;のように、現在の値を基に増減させるSQL文を使用することで、読み取りと更新をアトミックに行い、中間的な値を失うリスクを減らします。 - SERIALIZABLE分離レベル: 最も高いトランザクション分離レベルである
SERIALIZABLEを使用すると、すべての読み取り操作もロックされるため、ロストアップデートは完全に防止されますが、並行性が大きく低下します。
3.2. デッドロック (Deadlock)
デッドロックとは、2つ以上のトランザクションがそれぞれ相手が保持するロックを待機し、互いに待ち状態に陥ることで、どのトランザクションも処理を進められなくなる現象を指します。
InnoDBストレージエンジンは、デッドロック検出機構を備えており、デッドロックが発生した場合、自動的にそのうちの一つのトランザクション(通常は更新量が少ないなど、ロールバックコストが低いもの)をロールバックして、他のトランザクションが進行できるようにします。アプリケーション側では、デッドロックによるロールバックを検知し、トランザクションを再試行するロジックを実装することが一般的です。