データベース並行性とトランザクション分離レベル
データベースシステムにおいて、トランザクションはデータの整合性を維持するための基礎です。特に、複数のユーザーやアプリケーションが並行してデータにアクセスし、変更を加える環境ではその重要性が増します。分離レベル(Isolation levels)は、あるトランザクションが他の並行トランザクションによる操作からどの程度影響を受けないかを定義するものです。
本章では、最も一般的な3つの分離レベル、リードコミッティド(Read Committed)、リピータブルリード(Repeatable Read)、シリアライザブル(Serializable)について深く掘り下げます。それぞれの特性、潜在的な問題、そしてデータの一貫性にどのような影響を与えるかを解説します。
1. 分離レベルの理解
分離レベルは、並行するトランザクション同士が互いにどの程度隔離されるかを制御します。分離レベルを高く設定するほど、並行性に起因する問題に対する保護は強固になりますが、同時にコンカレンシー(並行性)のパフォーマンスが低下し、データベースシステムのオーバーヘッドが増大します。各分離レベルは、「データの一貫性」と「システム性能」の間で異なるトレードオフを提供します。それでは、一つずつ詳しく分析していきましょう。
2. リードコミッティド
リードコミッティド(Read Committed)は、PostgreSQLを含む多くのデータベースでデフォルト設定となっている分離レベルです。このレベルでは、トランザクションは他のトランザクションが既にコミットしたデータのみを読み取ることができるよう保証されます。つまり、他のトランザクションによる未コミットの「仕掛品」データを見ることはなく、これによってダーティリード(Dirty Reads)を完全に防止します。
コア特性:
- ダーティリードの防止: コミット済みのデータのみを読み取ります。
- 不可再現読(Non-Repeatable Reads)の発生可能性: あるトランザクションが同じ行を2回読み取る間に、別のトランザクションがその行を更新してコミットした場合、2回の読み取り結果が異なることがあります。
- ファントムリード(Phantom Reads)の発生可能性: 他のトランザクションが新しく挿入してコミットした、クエリ条件に合致する「幽霊(ファントム)」のようなデータ行が見えることがあります。
2.1 ケーススタディ
2つの並行トランザクションT1とT2が、id、name、price を持つ products テーブルを操作している状況を想定します。
初期状態: products テーブルに1つの製品があります(id = 1, name = 'Laptop', price = 1200)。
ステップ分解:
- トランザクション T1: トランザクションを開始し、
id = 1の製品価格を読み取り、1200を得ます。 - トランザクション T2: トランザクションを開始し、
id = 1の製品価格を1300に更新してコミットします。 - トランザクション T1: 再び
id = 1の価格を読み取ると、今度は1300が返されます。T1はこの更新後の価格を使用して後続の処理を続けます。
このシナリオでは、T1の2回の読み取り操作の間にT2が変更をコミットしたため、価格が変化しており、T1は「不可再現読」を経験したことになります。
2.2 SQL シナリオ実戦
-- セッション 1 (トランザクション T1)
BEGIN;
SELECT price FROM products WHERE id = 1; -- 1200を返す
-- (コンテキストスイッチ:セッション 2へ)
-- セッション 2 (トランザクション T2)
BEGIN;
UPDATE products SET price = 1300 WHERE id = 1;
COMMIT;
-- (コンテキストスイッチ:セッション 1へ戻る)
SELECT price FROM products WHERE id = 1; -- 1300を返す (不可再現読が発生)
COMMIT;解析: トランザクション T1が開始され、初期価格(1200)を読み取ります。その後、トランザクション T2が価格を1300に更新してコミットします。T1が再び価格を読み取ったとき、更新後の値(1300)が見えてしまいます。これが「不可再現読」の典型的な例です。
3. リピータブルリード
リピータブルリード(Repeatable Read)は、リードコミッティドよりも強力な保証を提供します。一つのトランザクション内で一度読み取ったデータは、そのトランザクションが終了するまで、何度読み直しても値が変わらないことが保証されます。これにより、不可再現読を阻止できます。ただし、標準SQLの定義では、依然としてファントムリードが発生する可能性があります。
コア特性:
- ダーティリードの防止: 同様に未コミットのデータ読み取りを防ぎます。
- 不可再現読の防止: トランザクションがある行を読み取ると、そのトランザクション期間中、他のコミット済みトランザクションがどのような変更を加えようとも、常に同じデータが見えます。
- ファントムリードの発生可能性: 他のトランザクションが挿入した、検索条件に一致する新しいデータ行が見える可能性があります。
3.1 ケーススタディ
2つの並行トランザクションT1とT2が、id、name、salary を持つ employees テーブルを操作します。
初期状態: employees テーブルに1名の従業員がいます(id = 1, name = 'Alice', salary = 60000)。
ステップ分解:
- トランザクション T1:
Repeatable Read分離レベルでトランザクションを開始。id = 1の給与を読み取り、60000を得ます。 - トランザクション T2: トランザクションを開始。
id = 1の給与を65000に更新してコミットします。 - トランザクション T1: 再び id = 1 の給与を読み取りますが、依然として60000が返されます。T1は最初に読み取った給与データのまま処理を続行します。
この例では、T1は不可再現読を経験しませんでした。Repeatable Read レベルによって、たとえT2が変更をコミットしていても、T1はトランザクション期間中、データの一貫したビュー(スナップショット)を見続けることができるからです。
3.2 ファントムリードのケースと SQL 実戦
初期状態: employees テーブルに最初は5名の従業員がいます。
-- セッション 1 (トランザクション T1)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT salary FROM employees WHERE id = 1; -- 60000を返す
-- (コンテキストスイッチ:セッション 2へ)
-- セッション 2 (トランザクション T2)
BEGIN;
UPDATE employees SET salary = 65000 WHERE id = 1;
COMMIT;
-- (コンテキストスイッチ:セッション 1へ戻る)
SELECT salary FROM employees WHERE id = 1; -- 依然として60000を返す (リピータブルリード成功)
COMMIT;
-- ファントムリード (Phantom Read) の例:
-- セッション 1 (トランザクション T1)
BEGIN;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
SELECT COUNT(*) FROM employees; -- 5を返す
-- (コンテキストスイッチ:セッション 2へ)
-- セッション 2 (トランザクション T2)
BEGIN;
INSERT INTO employees (id, name, salary) VALUES (6, 'Eve', 70000);
COMMIT;
-- (コンテキストスイッチ:セッション 1へ戻る)
SELECT COUNT(*) FROM employees; -- 6を返す (ファントムリードが発生)
COMMIT;解析: 前半部分では、T1がリピータブルリード下で初期給与(60000)を読み取ります。T2が更新・コミットしても、T1の再読込結果は変わりません。後半部分では、T1が初期の従業員数(5名)をカウントします。その後、T2が新しい従業員を挿入してコミットします。T1が再びカウントを行うと、新しい従業員が見えてしまい(6名になる)、これが「ファントムリード」です。
4. シリアライザブル (Serializable)
シリアライザブル(Serializable)は、最高レベルの分離レベルです。最も厳格な保証を提供し、並行するトランザクションの実行結果が、あたかもそれらが一つずつ順番に(直列に)実行されたかのように見えるよう制御します。これにより、ダーティリード、不可再現読、ファントムリードが完全に排除されます。PostgreSQLでは、Serializable はシリアライザブル・スナップショット分離 (SSI) という手法で実装されており、従来のロックベースの直列化よりも高い並行性を許可します。
コア特性:
- ダーティリードの防止: 未コミットデータは絶対に読み取りません。
- 不可再現読の防止: トランザクション期間中、データビューは完全に一定です。
- ファントムリードの防止: 他のトランザクションによる新行の挿入は現在のトランザクションに影響しません。
- 直列化エラー (Serialization Errors): 並行操作が直列化のルールを破壊するとデータベースが検知した場合、データベースは強制的に一方のトランザクションをロールバックし、直列化エラーをスローします。
4.1 ケーススタディと SQL 実戦
T1とT2の両方が inventory(在庫)テーブル内の特定製品の有効数量を更新しようとする状況を考えます。
初期状態: inventory テーブルに製品 id = 1, quantity = 10 があります。
-- セッション 1 (トランザクション T1)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT quantity FROM inventory WHERE id = 1; -- 10を返す
-- (処理時間をシミュレート。在庫から3つ差し引く計算を行い、7に更新する準備をする)
-- (コンテキストスイッチ:セッション 2へ)
-- セッション 2 (トランザクション T2)
BEGIN;
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT quantity FROM inventory WHERE id = 1; -- 10を返す
-- (処理時間をシミュレート。在庫から5つ差し引く計算を行い、5に更新する準備をする)
UPDATE inventory SET quantity = 5 WHERE id = 1;
COMMIT;
-- (コンテキストスイッチ:セッション 1へ戻る)
UPDATE inventory SET quantity = 7 WHERE id = 1;
COMMIT; -- ここで直列化エラー (Serialization error) が発生する可能性がある解析: 2つのトランザクションが共に初期数量(10)を読み取り、それぞれの計算に基づいて更新を試みます。両者が同じデータを読み取った上で変更を加えようとしたため、データベースは潜在的な直列化の異常を検知します。シリアライザブルを維持するために、データベースはいずれか一方のコミットを許可し、もう一方を強制的にロールバックさせます。どちらがロールバックされるかは、データベース内部の並行制御メカニズムによって決定されます。
5. 適切な分離レベルの選択方法
分離レベルの選択は、アプリケーションの具体的なビジネス要件によって決まります。
- Read Committed (リードコミッティド): 高いスループットが求められ、ビジネスロジック上、稀に発生する不可再現読やファントムリードを許容できるアプリケーションに適しています。
- Repeatable Read (リピータブルリード): 一つのトランザクション内で複数回の読み取りデータが一致することを保証する必要があり、かつファントムリードは許容できるシナリオに適しています。
- Serializable (シリアライザブル): 財務決済や高精度な在庫管理など、データの一貫性要件が極めて厳格で、いかなる並行異常も許されないシナリオに適しています。ただし、このレベルを使用するとシステムの並行処理能力が著しく低下し、プログラム側で直列化エラー発生時のリトライ処理を実装する負荷が増えることに注意してください。
実際の開発では多くのアプリケーションがデフォルトの Read Committed を使用しますが、開発者としては利害を慎重に比較検討し、特定の業務モジュールごとに最適な分離レベルを選択する必要があります。