Serializable分離レベルとそのパフォーマンスへの影響の理解
James Reed
Infrastructure Engineer · Leapcell

はじめに
データベース管理の複雑な世界では、同時実行操作におけるデータの一貫性を確保することは、極めて重要な課題です。アプリケーションの規模が拡大し、ユーザーアクティビティが激化するにつれて、複数のトランザクションが同時に同じデータにアクセスし、変更しようとすることがよくあります。これらの相互作用を制御するための適切なメカニズムなしでは、データの整合性は著しく損なわれ、不正確な結果、更新の損失、あるいはデータの破損につながる可能性があります。ここでデータベースの分離レベルが登場し、データの一貫性の重要な守護者として機能します。これらのレベルの中でも、Serializable
分離レベルは最も厳格であり、最高レベルのデータ整合性を提供します。しかし、この堅牢性には大きなトレードオフがあり、パフォーマンスに影響を与えることがよくあります。この記事は、Serializable
分離レベルを深く探求し、その根本原理、実際の実装を解き明かし、そのパフォーマンスへの影響を批判的に検討し、その賢明な応用のためのロードマップを提供することを目的としています。
Serializable分離レベルの理解
Serializable
の複雑な部分に入る前に、データベースの同時実行制御の基礎となるいくつかの基本的な概念を明確にしましょう。
主要な用語
- トランザクション (Transaction): データベースにアクセスし、場合によっては変更する、単一の論理的な作業単位。トランザクションは、データ整合性と一貫性を維持するように設計されています。これらは、ACID特性(原子性、一貫性、分離性、永続性)によって特徴づけられます。
- 同時実行制御 (Concurrency Control): データの整合性を維持し、競合を予測可能な方法で解決するように、同時データベース操作を管理する原則。
- 分離レベル (Isolation Levels): SQL(および多くの場合、特定のデータベースシステムによって拡張される)によって定義された標準のセットであり、他の同時トランザクションから1つのトランザクションのデータ変更がどのように見えるかを指定します。より高い分離レベルは、同時実行異常に対する保護を強化しますが、通常はより高いパフォーマンスコストがかかります。
- 同時実行異常 (Concurrency Anomalies): 適切な分離なしに複数のトランザクションが同時に実行される場合に発生する可能性のあるさまざまな種類のエラーまたは予期しない動作。一般的な異常には以下が含まれます:
- ダーティリード (Dirty Reads): トランザクションが、コミットされていない別のトランザクションによって書き込まれたデータを読み取ること。
- 再現不能リード (Non-Repeatable Reads): トランザクションが以前に読み取ったデータを再度読み取り、別のコミットされたトランザクションによって変更されていることを見つけること。
- ファントムリード (Phantom Reads): トランザクションが、行のセットを返すクエリを再度実行し、別のコミットされたトランザクション(例:新しい行の追加または既存の行の削除)のために、クエリを満たす行のセットが変更されたことを見つけること。
- 更新の喪失 (Lost Updates): 2つのトランザクションが同じデータを読み取り、それを変更し、書き戻します。一方の更新は、もう一方のトランザクションが最初の更新を考慮せずに上書きするため、「失われます」。
- シリアル実行 (Serial Execution): トランザクションを、重複なく、1つずつ実行すること。これは、本質的に一貫性を保証しますが、スループットが非常に悪いため、同時実行システムでは実用的ではありません。
- シリアライゼーション (Serialization): 同時実行トランザクションが、何らかの順序でシリアルに実行された場合と同じ結果を生成することを保証するプロセス。
Serializable分離レベルとは何か?
Serializable
分離レベルは、SQL標準で定義されている最も強力な分離レベルです。これは、同時に実行されるトランザクションが、何らかの順序で逐次的に(シリアルに)実行された場合と同じ結果を生成することを保証します。本質的に、ダーティリード、再現不能リード、ファントムリードを含むすべての一般的な同時実行異常を排除します。トランザクションが、その存続期間中、データベースへの排他的アクセス権を持っているかのように動作することを保証します。
Serializable分離レベルはどのように機能するか?
データベースシステムは通常、2相ロック (2PL) または 楽観的同時実行制御 (OCC) のバリアントを通じてSerializable
分離レベルを実現します。多くの場合、よりモダンなシステムではマルチバージョン同時実行制御 (MVCC) 技術と組み合わされます。
2相ロック (2PL)
純粋な2PLでは、トランザクションはデータ項目にロック(読み取りには共有、書き込みには排他)を取得します。ロックを取得できる拡張フェーズと、ロックを解放できる縮小フェーズがあります。2PLの重要なルールは、トランザクションがロックを解放すると、新しいロックを取得できなくなることです。Serializable
分離レベルの場合、2PLは厳格2PLでなければなりません。これは、すべてのロックがトランザクションがコミットまたはアボートされるまで保持されることを意味します。ファントムリードを防ぐために、Serializable
はしばしば述語ロックまたは範囲ロックを採用します。これらは、個々の行だけでなく、指定された範囲内または特定の述語に一致する行が追加または削除される可能性もロックします。
銀行振込の例を考えてみましょう:
-- トランザクションA: アカウント1からアカウント2へ100ドルを送金 BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT balance FROM Accounts WHERE account_id = 1 FOR UPDATE; -- アカウント1をロック UPDATE Accounts SET balance = balance - 100 WHERE account_id = 1; SELECT balance FROM Accounts WHERE account_id = 2 FOR UPDATE; -- アカウント2をロック UPDATE Accounts SET balance = balance + 100 WHERE account_id = 2; COMMIT; -- トランザクションB: アカウント1、2、3の合計残高を確認 BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; SELECT SUM(balance) FROM Accounts WHERE account_id IN (1, 2, 3); COMMIT;
厳格2PLを使用したSerializable
分離レベルでは、トランザクションAが最初に開始すると、アカウント1と2に排他ロックを取得します。これらのアカウントを読み取ろうとするトランザクションBは、トランザクションAがコミットされるまでブロックされます。トランザクションAがコミットされると、トランザクションBは更新された残高を読み取ることができます。これにより、トランザクションBが中間状態(ダーティリード)を見たり、後でデータを再読み取りした場合に異なる合計を見たりすることがなくなります(再現不能リード)。トランザクションBが残高の合計を読み取ろうとし、関連するすべてのアカウントに共有ロックを取得した場合、トランザクションAがそれらのアカウントのいずれかを変更しようとすると、トランザクションBが終了するまでブロックされる可能性があり、デッドロックにつながる可能性があります。
スナップショット分離(MVCCデータベースの「Serializable」をよく支えている)
PostgreSQLやOracleなどの多くの最新のリレーショナルデータベースは、マルチバージョン同時実行制御 (MVCC) と、スナップショット分離の上に構築された追加のチェックレイヤーを組み合わせて、「Serializable」分離レベル(またはOracleのSERIALIZABLE
などの同等のもの)を実装しています。
- MVCC: MVCCデータベースは、データをその場で上書きするのではなく、行が更新されるたびにその行の新しいバージョンを作成します。リーダーは通常、トランザクションが開始された時点からのデータベースの一貫した「スナップショット」を表示し、ライターをブロックしません。これにより、本質的にダーティリードと再現不能リードが防止されます。
- スナップショット分離 (Snapshot Isolation): スナップショット分離で動作するトランザクションは、トランザクション開始時に存在したデータベースのスナップショットを表示します。スナップショットが取得された後にコミットされた他のトランザクションによる書き込みは表示されません。これにより、ダーティリードと再現不能リードが防止されます。しかし、スナップショット分離だけでは、ファントムリードや「書き込みスキュー」と呼ばれる特定の異常を防ぐことはできません。
MVCC/スナップショット分離の上に完全なSerializable
分離を達成するために、データベースはコミット時に検証の追加レイヤーを追加します。この検証は、コミットされたトランザクションの変更(書き込み)が、スナップショット取得後に同時にコミットされた他のトランザクションと、シリアライゼーションに違反するような方法で競合していないかを確認します。そのような競合が検出された場合、更新は拒否され、トランザクションは通常アボートされ、アプリケーションはそれを再試行する必要があります。これはしばしばSerializable Snapshot Isolation (SSI) と呼ばれます。
SSIを使用したMVCC/スナップショット分離のシナリオで、銀行振込の例を再度見てみましょう:
-- トランザクションA: アカウント1からアカウント2へ100ドルを送金 BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- スナップショットから現在の残高を読み取る SELECT balance FROM Accounts WHERE account_id = 1; SELECT balance FROM Accounts WHERE account_id = 2; -- 計算を実行 UPDATE Accounts SET balance = balance - 100 WHERE account_id = 1; UPDATE Accounts SET balance = balance + 100 WHERE account_id = 2; COMMIT; -- コミット時に競合検出が発生。 -- トランザクションC: アカウント2からアカウント3へ50ドルを送金 BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE; -- スナップショットから現在の残高を読み取る SELECT balance FROM Accounts WHERE account_id = 2; SELECT balance FROM Accounts WHERE account_id = 3; -- 計算を実行 UPDATE Accounts SET balance = balance - 50 WHERE account_id = 2; UPDATE Accounts SET balance = balance + 50 WHERE account_id = 3; COMMIT; -- コミット時に競合検出が発生。
トランザクションAとトランザクションCがほぼ同時に開始され、両方ともアカウント2を変更しようとした場合、コミット時にシリアライゼーション競合のために一方(または両方)が失敗します。例えば、トランザクションAが先にコミットされた場合、トランザクションCは、コミットを試みたときに、account_id = 2
のその読み取りが、シリアライゼーションに違反するような方法で(つまり、同じアカウントのAの変更と競合する)、「古い」スナップショットに基づいていたことを検出します。トランザクションCはアボートされ、アプリケーションはそれを再試行する必要があります。
パフォーマンスへの影響
Serializable
分離レベルは、データ整合性のためのゴールドスタンダードですが、その実装は必然的に大幅なパフォーマンスオーバーヘッドを導入します。
-
ロックオーバーヘッドの増加(2PLベースのシステムの場合):
- ロック保持時間の延長: ロックはトランザクションの終了時(コミットまたはロールバック)まで保持されます。これにより、他のトランザクションがより長い期間ブロックされるため、同時実行性が低下します。
- ロック競合の増加: より多くのトランザクションが同じロックを競合し、待機時間が増加します。
- デッドロック: 2つ以上のトランザクションが互いに保持しているロックを相互に待機している場合、デッドロックが発生します。データベースシステムはデッドロックを検出し、解決するためにトランザクションのいずれか1つをアボートし、これは無駄な作業と再試行につながります。
- スループットの低下: ロック保持と競合の合計効果は、処理できるトランザクション/秒の数を最終的に制限します。
-
楽観的同時実行制御のオーバーヘッド増加(SSIを持つMVCCベースのシステムの場合):
- トランザクションのアボート/再試行: MVCCはブロッキングを減らしますが、
Serializable
分離レベルのコミット時検証は、シリアライゼーションに違反するトランザクションを積極的にアボートします。アプリケーションは、アボートされたトランザクションを再実行するように設計する必要があり、CPUサイクルと場合によってはネットワークリソースを繰り返し消費します。 - レイテンシの増加: 再試行する必要があるトランザクションは、正常にコミットされるまでに時間がかかり、ユーザーエクスペリエンスに影響を与えます。
- 競合検出のためのCPUオーバーヘッド: データベースシステムは、コミット時にシリアライゼーション競合を検出するために追加のチェックを実行する必要があり、CPUリソースを消費します。
- ストレージオーバーヘッド (MVCC): MVCCシステムは一般的に複数バージョンの行を保存し、より多くのディスクスペースを消費し、古いバージョンのガベージコレクションのためのI/O操作を増加させる可能性があります。
- トランザクションのアボート/再試行: MVCCはブロッキングを減らしますが、
-
スケーラビリティの課題:
- ホットスポット: 多くのトランザクションによって頻繁にアクセスまたは変更されるデータ項目は、「ホットスポット」になります。
Serializable
分離レベルでは、これらのホットスポットは深刻なボトルネックになり、トランザクションはアクセスを待機するためにキューに入れられ、スケーラビリティが大幅に制限されます。 - グローバルロック: 一部の分散データベースアーキテクチャでは、グローバルのシリアライゼーションを達成するために、グローバルな調整とロッキングメカニズムが必要になる場合があります。これにより、さらに大きなオーバーヘッドとレイテンシが生じます。
- ホットスポット: 多くのトランザクションによって頻繁にアクセスまたは変更されるデータ項目は、「ホットスポット」になります。
アプリケーションシナリオ
Serializable
分離レベルは、リソースのオーバーヘッドが大きいにもかかわらず、絶対的なデータ整合性が譲れない特定のシナリオでは不可欠です。
- 金融取引: 銀行システム、株式取引所、決済ゲートウェイは、完璧な精度を必要とします。単一の不正確な合計または送金は、深刻な財務的影響を与える可能性があります。例えば、同時デビットとクレジットがあっても、すべての口座の合計が変更されないようにするには、
Serializable
分離レベルが必要です。 - 在庫管理: 在庫レベルを正確に把握して、過剰販売や過小販売を防ぐ必要があるシステム、特に限定在庫品の場合。2人の顧客が同時に最後のアイテムを購入しようとした場合、
Serializable
は1人だけが成功することを保証します。 - 重要な監査とレポート: レポートや監査が、同時アクティビティが発生していないかのように一貫性があることが保証されたデータベースのスナップショットを必要とする場合、
Serializable
が唯一安全な選択肢です。 - 複雑な依存関係を持つバッチ処理: 大規模なデータセットの読み取り、計算の実行、更新の書き込みを含む複雑なバッチジョブでは、すべての途中状態が一貫しており、プロセスの途中で「ファントム」データが表示されないことを保証することが重要です。
コード例 (PostgreSQL with SSI)
ここでは、PostgreSQL(Serializable Snapshot Isolationを使用)を使用した単純なPythonアプリケーションと、潜在的なシリアライゼーション競合および再試行メカニズムを説明します。
データベースセットアップ:
CREATE TABLE products ( id SERIAL PRIMARY KEY, name VARCHAR(255) NOT NULL, stock INT NOT NULL ); INSERT INTO products (name, stock) VALUES ('Laptop', 10); INSERT INTO products (name, stock) VALUES ('Mouse', 20);
Pythonアプリケーション (psycopg2
を使用):
import psycopg2 from psycopg2.errors import SerializationFailure import time import threading DATABASE_URL = "dbname=test user=postgres password=root" def buy_product(product_id, quantity, thread_id): retries = 0 while retries < 5: # 複数回の再試行を許可 conn = None try: conn = psycopg2.connect(DATABASE_URL) conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_SERIALIZABLE) cur = conn.cursor() print(f"Thread {thread_id}: Attempting to buy {quantity} of product {product_id} (retry {retries})") # 一部の処理時間をシミュレート time.sleep(0.1) cur.execute("SELECT stock FROM products WHERE id = %s FOR NO KEY UPDATE", (product_id,)) # FOR NO KEY UPDATE は行に対する共有ロックを提供し、シリアライゼーションを助けます current_stock = cur.fetchone()[0] if current_stock >= quantity: new_stock = current_stock - quantity cur.execute("UPDATE products SET stock = %s WHERE id = %s", (new_stock, product_id)) conn.commit() print(f"Thread {thread_id}: Successfully bought {quantity} of product {product_id}. New stock: {new_stock}") return True else: print(f"Thread {thread_id}: Insufficient stock for product {product_id}. Current: {current_stock}") conn.rollback() return False except SerializationFailure as e: print(f"Thread {thread_id}: SerializationFailure detected. Retrying... Error: {e}") if conn: conn.rollback() retries += 1 time.sleep(0.5) # 即時の再衝突を避けるために再試行前に待機 except Exception as e: print(f"Thread {thread_id}: An unexpected error occurred: {e}") if conn: conn.rollback() return False finally: if conn: conn.close() print(f"Thread {thread_id}: Failed to buy product {product_id} after {retries} retries.") return False # 同時購入をシミュレート if __name__ == "__main__": initial_stock = 0 conn_check = None try: conn_check = psycopg2.connect(DATABASE_URL) cur_check = conn_check.cursor() cur_check.execute("SELECT stock FROM products WHERE id = 1") initial_stock = cur_check.fetchone()[0] print(f"Initial stock for product 1: {initial_stock}") finally: if conn_check: conn_check.close() threads = [] # 初期在庫10から、それぞれ6台のラップトップを購入しようとする2つのスレッド t1 = threading.Thread(target=buy_product, args=(1, 6, 1)) t2 = threading.Thread(target=buy_product, args=(1, 6, 2)) threads.append(t1) threads.append(t2) for t in threads: t.start() for t in threads: t.join() # 最終在庫を確認 final_stock = 0 try: conn_check = psycopg2.connect(DATABASE_URL) cur_check = conn_check.cursor() cur_check.execute("SELECT stock FROM products WHERE id = 1") final_stock = cur_check.fetchone()[0] print(f"Final stock for product 1: {final_stock}") finally: if conn_check: conn_check.close() # 在庫が正しく処理されたことをアサート # 期待値: 1つのトランザクションが成功 (6個)、もう1つは失敗または再試行して失敗 # 初期値: 10。最終期待値: 4 (1つ成功の場合) または 10 (両方失敗または片方のみ部分的に成功し、もう片方が失敗した場合) # ここでの重要な点は、-2 (10 - 6 - 6) にならないようにすることです expected_stocks_if_one_succeeds = initial_stock - 6 if final_stock == expected_stocks_if_one_succeeds: print("Stock accurately managed: one transaction successfully processed.") elif final_stock == initial_stock: print("Stock unchanged: both transactions failed to complete fully due to insufficient stock or conflicts.") else: print("Unexpected final stock. Check logic or database state.")
この例では、2つのスレッド(t1
とt2
)が、初期在庫10の製品1を6台購入しようとしています。PostgreSQLのSerializable
分離レベル(SSI)では、両方のトランザクションが開始されます。両方とも在庫を10として読み取ります。
t1
が最初にコミットされた場合、在庫は4になります。t2
がコミットを試みたとき(在庫10を読み取り、4に更新しようとした)、t2
が在庫10を読み取った時点のスナップショットが、t1
の書き込みと競合する形でシリアライゼーションに「一貫性がなくなった」と検出され、SerializationFailure
が発生する可能性が高いです。t2
はアボートされ、再試行する必要があります。再試行時、t2
は在庫を4として読み取り、在庫不足を正しく判断します。- 両方のトランザクションが同時に更新を試み、コミット中に競合が発生する可能性もあります。
ここでの重要な点は、最終的なstock
が(10 - 6 - 6 = -2)のようになることは決してなく、適切な分離がない場合に発生する可能性があることです。1つのトランザクションは成功し(在庫を4に減らす)、もう1つのトランザクションは在庫不足のため失敗します(再試行後、すぐに失敗しない場合)。
結論
Serializable
分離レベルは、データ整合性のための究極の防御策であり、同時実行トランザクションがシリアル実行と同様の結果を生成することを保証します。これは、金融取引の精度、正確な在庫管理、または厳格なデータ整合性が最優先されるアプリケーションに不可欠であり、一般的な同時実行異常をすべて効果的に防止します。しかし、この絶対的な整合性には大きなコストが伴います。スループットの低下、レイテンシの増加、リソース消費の増加、およびトランザクション再試行の複雑さです。そのため、これは慎重に扱われるべき強力なツールです。その適用は、データ不整合のコストがパフォーマンスオーバーヘッドをはるかに上回り、他のより軽量な分離レベルでは不十分な、これらの重要な操作に限定されるべきです。