【実務・中級編】 トランザクション分離レベルの実装 – PostgreSQL

PostgreSQLのトランザクション分離レベル、その裏側とSSIで直列化可能性をガッチリ掴む方法

やっほー!みんな元気?今日はPostgreSQLのトランザクション分離レベルについて、ちょっと深掘りして話そうと思うんだ。SQL標準で定められてるRead Committed、Repeatable Read、Serializable、そしてPostgreSQLが誇るSerializable Snapshot Isolation(SSI)について、単なる仕様の説明じゃなくて、現場で「なるほど!」って思ってもらえるように、内部実装のイメージと具体的な使い方を交えて解説していくよ。

教科書みたいな硬い話じゃなくて、現場の先輩が後輩に「これ、マジで知ってると便利だよ」って感じで、コーヒーでも飲みながら聞くようなつもりで読んでみてほしいな。

なんでトランザクション分離レベルなんてあるんだっけ?

まず、なんでこんなややこしい話をするかっていうと、複数の人が同時にデータベースを触る(トランザクションを実行する)ときに、お互いの操作で「あれ?なんかデータおかしいぞ?」ってなっちゃうのを防ぐためなんだ。これを「同時実行制御」って言うんだけど、その「どのくらいお互いの影響を無視するか」を決めるのがトランザクション分離レベルってわけ。

SQL標準には、大きく分けて以下の3つがある。

  • Read Committed: 他のトランザクションがコミットしたデータだけを読む。一番緩い設定。
  • Repeatable Read: あるトランザクション内で同じSELECT文を何度実行しても、同じ結果を返す。Read Committedよりちょっと厳しめ。
  • Serializable: トランザクションを順番に実行したときと同じ結果になるように保証する。一番厳しい。

でもね、厳しくすればするほど、パフォーマンスに影響が出たり、トランザクションが「デッドロック」しやすくなったりするんだ。だから、バランスが大事なんだよね。

PostgreSQLの「Read Committed」はどんな仕組み?

PostgreSQLのデフォルト設定は「Read Committed」なんだけど、これ、実は結構賢くできてるんだ。

MVCC(Multi-Version Concurrency Control)って知ってる?

PostgreSQLは、MVCCっていう仕組みで動いてる。これは、データを更新するときに、古いバージョンをすぐに消さずに、新しいバージョンと一緒に保持しておく考え方なんだ。

Read Committedのイメージはこんな感じ。

1. トランザクションAがデータを読み込む。
2. その時点で「コミット済み」で「自分が見ても良い」データ(=自分より前にコミットされたデータ)を見る。
3. トランザクションBがそのデータを更新してコミットする。
4. トランザクションAがもう一度同じデータを読み込む。
5. 今度は、トランザクションBがコミットした新しいバージョンのデータが見える。

つまり、トランザクション内で同じSELECT文を2回実行しても、間に他のトランザクションのコミットが入ると、違う結果が見えちゃう可能性があるってこと。これが「Read Committed」の挙動だね。

具体例を見てみようか。

— セッション1
BEGIN;
SELECT FROM accounts WHERE id = 1;
— この時点では balance は 1000

— セッション2
UPDATE accounts SET balance = 1200 WHERE id = 1;
COMMIT;

— セッション1 (続き)
SELECT FROM accounts WHERE id = 1;
— ここで balance は 1200 になっている!
COMMIT;

こんな感じで、セッション1は最初1000を見てたのに、セッション2のコミットを挟んだら1200が見える。これがRead Committed。シンプルで分かりやすいけど、この後で「え、さっきと違うじゃん!」って困ることもあるんだ。

「Repeatable Read」はどう違うの?

Repeatable Readは、一つのトランザクション内では、同じSELECT文の結果は絶対に変わらないことを保証してくれる。さっきの例でいうと、セッション1ではずっと balance が1000のままになるはずだ。

PostgreSQLでは、Repeatable Readを実現するために、トランザクションの開始時点での「スナップショット」を使うんだ。

スナップショットって?

トランザクションが開始されると、PostgreSQLはその時点での「データベースの最新のコミット済み状態」を記録する。これを「スナップショット」と呼ぶ。Repeatable Readでは、トランザクション内で実行される全てのクエリが、この開始時点のスナップショットを参照するんだ。

さっきの例でRepeatable Readを設定した場合を想像してみよう。

— セッション1 (Repeatable Readで開始)
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;
BEGIN;
SELECT FROM accounts WHERE id = 1;
— この時点では balance は 1000

— セッション2
UPDATE accounts SET balance = 1200 WHERE id = 1;
COMMIT;

— セッション1 (続き)
SELECT FROM accounts WHERE id = 1;
— ここでも balance は 1000 のまま!
COMMIT;

どう?セッション1では、セッション2でデータが更新されても、自分のトランザクションが開始された時点のスナップショットを見ているから、ずっと1000のままなんだ。これは、同じデータに対する複数回の読み込みで一貫性を保ちたい場合にすごく便利だよね。

でも、ここで注意点。Repeatable Readでも、他のトランザクションがINSERTした新しい行は、自分のトランザクションからは見えなかったりするんだ。これは「ファントムリード」って呼ばれる現象で、Repeatable Readでは防げないんだ。

「Serializable」で直列化可能性をガッチリ!…だけど…

SQL標準の「Serializable」は、複数のトランザクションが同時に実行されたとしても、まるでそれらが一つずつ順番に実行されたかのような結果を保証してくれる、最強の分離レベルだ。これさえ設定しておけば、もうデータの一貫性で悩むことはなくなる…はずなんだけど、実際はそう甘くもないんだ。

Serializableの裏側:2フェーズロック(2PL)とその問題

PostgreSQLのSerializableは、基本的には2フェーズロック(2PL)っていう仕組みで実現されている。これは、トランザクションがデータを読み書きする際に、「ロック」を取るんだ。

  • 共有ロック(Sロック): データを読み込むときに取る。他のトランザクションは読み込めるけど、書き込めない。
  • 排他ロック(Xロック): データを書き込むときに取る。他のトランザクションは読み書きできなくなる。

2PLでは、トランザクションの実行中にロックをどんどん取っていく(第1フェーズ)。そして、トランザクションがコミットするかロールバックする直前に、取ったロックを全て解放する(第2フェーズ)。

この仕組みのおかげで、Serializableではファントムリードも防げるし、データの一貫性はかなり高まる。

じゃあ、なんで「実際はそう甘くない」って言ったか?

それは、デッドロックが起きやすいからなんだ。

— セッション1
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance – 100 WHERE id = 1; — id=1にXロック取得
— ここでセッション2に切り替わる

— セッション2
BEGIN TRANSACTION ISOLATION LEVEL SERIALIZABLE;
UPDATE accounts SET balance = balance + 100 WHERE id = 2; — id=2にXロック取得
— ここでセッション1に切り替わる

— セッション1
UPDATE accounts SET balance = balance + 100 WHERE id = 2; — id=2のXロックがセッション2に取られているので待機
— セッション1が待機している間に、セッション2はセッション1がid=1にロックを取っていることを知らない

— セッション2
UPDATE accounts SET balance = balance – 100 WHERE id = 1; — id=1のXロックがセッション1に取られているので待機
— セッション2も待機…

— 結果:お互いが相手のロックを待ってしまい、永遠に終わらない状態(デッドロック)に!

こんな風に、お互いが相手のロックを待ってしまうと、デッドロックが発生して、どちらかのトランザクションが強制的にロールバックされることになる。Serializableは安全だけど、アプリケーション側でデッドロックのリカバリ処理をしっかり書く必要が出てくるんだ。これが現場では結構ツラいところなんだよね。

PostgreSQLの切り札!Serializable Snapshot Isolation (SSI)

さて、ここからが本番!PostgreSQL 9.1から導入された Serializable Snapshot Isolation (SSI) の話だよ。これは、SQL標準のSerializableと同等の直列化可能性を保証しつつ、デッドロックを劇的に減らしてくれる、まさに革命的な仕組みなんだ。

SSIの裏側:読み込みによる書き込みの検出

SSIは、MVCCをベースに、さらに「読み込み」と「書き込み」の間の依存関係を検知して、直列化可能性を保証する。具体的には、以下の2つのメカニズムが鍵になる。

1. Read Skew Detection: Repeatable Readでも防げない「Read Skew」を防ぐ。これは、あるトランザクションが複数のデータを読み込んでいる最中に、別のトランザクションがその一部を更新・コミットすることで発生する「読み取りの不整合」のこと。SSIでは、読み込んだデータに依存する書き込みがあった場合に、それを検出してロールバックさせる。
2. Write Skew Detection: Serializableでデッドロックの原因になりやすかった「Write Skew」を防ぐ。Write Skewとは、2つのトランザクションが、それぞれ異なるデータを更新しようとするんだけど、お互いの更新が完了したと仮定すると、依存関係がおかしくなる現象。SSIでは、このWrite Skewを検出して、どちらかのトランザクションをロールバックさせる。

SSIのすごいところは、デッドロックを減らしつつ、Serializableと同等の保証をしてくれること!

SSI、どうやって使うの?

使い方は超簡単。トランザクションの分離レベルを `SERIALIZABLE` に設定するだけ!PostgreSQLが賢くSSIを使ってくれる。

— セッション1
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
SELECT FROM accounts WHERE id = 1; — スナップショット取得
— … 他の処理 …
COMMIT;

— セッション2
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
BEGIN;
UPDATE accounts SET balance = balance – 100 WHERE id = 1; — id=1に書き込み
UPDATE accounts SET balance = balance + 100 WHERE id = 2; — id=2に書き込み
COMMIT;

この例で、もしセッション1が `id=1` を読み込んだ後に、セッション2が `id=1` を更新してコミットしようとしたとする。SSIは、セッション1の読み込みが、セッション2の書き込みに依存していることを検知する。もし、セッション1が `id=1` を更新しようとしたり、あるいは `id=1` の結果に依存するような処理をしていた場合、セッション1かセッション2のどちらかがロールバックされることになる。

重要なのは、デッドロックで止まるのではなく、どちらかが「SERIALIZATION FAILURE」というエラーでロールバックされるってこと。 アプリケーション側でこのエラーを捕捉して、リトライ処理を実装すれば、 Serializable と同等の堅牢性を持ちながら、ほとんどのデッドロックを回避できるんだ。

Read Skewの例:

— セッション1 (SERIALIZABLE)
BEGIN;
SELECT balance FROM accounts WHERE id = 1; — balance=1000
SELECT balance FROM accounts WHERE id = 2; — balance=2000
— ここでセッション2が id=1 の balance を 1200 に更新してコミット
— セッション1が再度 id=1 を見ると 1200 になっている。
— これは Read Skew の可能性があるので、セッション1はロールバックされる。
COMMIT; — ここで “serialization failure” が発生する可能性

Write Skewの例:

— セッション1 (SERIALIZABLE)
BEGIN;
UPDATE accounts SET balance = balance – 100 WHERE id = 1; — id=1に書き込み
— ここでセッション2が id=2 を更新してコミット
— セッション1が id=2 に書き込もうとすると、セッション2の書き込みに依存する可能性を検知。
COMMIT; — ここで “serialization failure” が発生する可能性

SSIの落とし穴:パフォーマンスとリトライ

SSIは素晴らしいんだけど、万能じゃない。

  • パフォーマンスへの影響: SSIは、トランザクション間の依存関係をより詳細にチェックするため、Read CommittedやRepeatable Readに比べて、 overhead が大きくなることがある。特に、複雑なクエリや大量のデータ操作を行うトランザクションでは、パフォーマンスの低下に注意が必要。
  • リトライ処理の実装: SSIでSERIALIZATION FAILUREが発生した場合、アプリケーション側でそのエラーを捕捉し、トランザクションをリトライする処理を実装する必要がある。これが面倒に感じることもあるかもしれない。しかし、デッドロックを自分でデバッグするよりは、ずっと楽だと個人的には思う。

まとめ:どの分離レベルを選べばいい?

結局、どの分離レベルを選べばいいのか?って話になるよね。

  • Read Committed: ほとんどのWebアプリケーションで十分な場合が多い。シンプルでパフォーマンスも良い。ただし、Read SkewやWrite Skewの可能性は残る。
  • Repeatable Read: 同じトランザクション内で同じデータを複数回読み込む際に、一貫性を保ちたい場合に有効。ファントムリードには注意。
  • Serializable (SSI): データの一貫性が最優先される場合、例えば金融系のシステムや、複雑なビジネスロジックを持つアプリケーションで、直列化可能性を保証したい場合に最適。デッドロックの心配が減り、リトライ処理を実装すれば安定して運用できる。

個人的には、特別な理由がない限り、まずはRead Committedで始めて、もしデータの一貫性で問題が発生したり、より高い保証が必要だと判断したら、Serializable(SSI)を検討するのが良いアプローチだと思う。SSIは、その強力さゆえに、パフォーマンスへの影響やリトライ処理の実装コストも考慮する必要があるからね。

最後に

PostgreSQLのトランザクション分離レベル、特にSSIは、データベースの奥深い部分だけど、知っておくと「なぜこの挙動になるんだ?」とか、「どうすればもっと安全にできるんだ?」っていう疑問が解消されるはずだ。

今回話した内容が、みんなのPostgreSQLライフをより豊かにする助けになれば嬉しいな!もし分からなかったり、もっと深掘りしたいことがあったら、いつでも気軽に聞いてくれよ!じゃあ、またね!

コメント

タイトルとURLをコピーしました