はじめに
バックエンドアプリケーションが同時に多くのユーザーにサービスを提供するとき、データベースは一貫性のための中心的な戦場となります。最後の席を予約する2人のユーザー、返金処理と競合する決済確認、進行中の更新中にデータを読み取るレポート — これらすべてのシナリオがPostgreSQLのトランザクションと同時実行機構に触れます。対応を誤ると、気づかないうちにデータが破損したり、注文が重複したり、不可能な残高が生じたりします。
このレッスンでは、バックエンド開発者が同時実行の動作を推論するために知っておくべきことに焦点を当てます。PostgreSQLがトランザクション内で処理をラップする方法、同時実行トランザクション間の可視性を分離レベルがどのように変えるか、MVCCがリーダーとライターを互いにブロックさせない仕組み、そして行レベルロックが実際にどこで噛み合うかについてです。目標はデータベース管理の深い知識ではなく、本番環境のバグ報告に「2つのリクエストが同じことをした」「ダッシュボードに古い数字が表示された」と書かれたときに必要となる実践的なメンタルモデルです。
ACIDとトランザクション境界
PostgreSQLにおけるトランザクションとは、全体としてコミットされるか、全体としてロールバックされるかのいずれかである作業単位です。4つの保証 — Atomicity(原子性)、Consistency(一貫性)、Isolation(独立性)、Durability(永続性) — は、BEGINとCOMMITで文を囲んだ場合にデータベースが約束する内容を記述しています。
バックエンドでは、一般的な形は次のようになります。
2番目のUPDATEが失敗した場合、1番目もロールバックされます。金钱が debit されたが credit されていない状態は決して存在しません。ここでの原子性は「あったらいいな」レベルのものではなく、台帳の整合性を保つためのものです。
バックエンド開発者向けの実践的なポイント:PostgreSQLは、明示的にBEGINしない限り、すべての単一文を独自の暗黙的なトランザクションとして扱います。したがって、トランザクションブロックなしで2つの別々のUPDATE文を発行すると、それぞれが独立してコミットされます。これは、接続プーラーやデフォルトのautocommit設定のORMを経由してデータベースとやりとりするアプリケーションコードで、多くの「部分的に適用された」バグの根本原因です。
PostgreSQLの分離レベル
SQLは4つの分離レベルを定義しています:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。PostgreSQLはこのうち3つを実際の異なる動作として実装しています — READ UNCOMMITTEDはMVCCの内部動作によりREAD COMMITTEDと同じ動作をします。
選択するレベルによって、トランザクションが同時実行トランザクションから何を閲覧できるかが決まります。READ COMMITTEDがデフォルトであり、通常は望ましい選択です。各文は、その文が開始する前にコミットされたデータを参照します。2つのSELECTの間に他のトランザクションが更新をコミットした場合、2番目のSELECTはその新しい値を参照します — これは安定したビューを期待する人を驚かせることがあります。
REPEATABLE READはスナップショットをトランザクション開始時点に固定し、各文ごとには固定しません。SERIALIZABLEはさらに一歩進んで、シリアライゼーション違反を検出し、競合するトランザクションの1つをシリアライゼーション失敗エラーで中断します。アプリケーションはそれをリトライする準備ができていなければなりません。
🖥️ [ここにイラスト挿入の提案:4つの分離レベルを比較し、それぞれが同時実行の変更をトランザクションにどこまで許可するかを示す図]
MVCCと行レベルロック
PostgreSQLはMulti-Version Concurrency Control(マルチバージョン同時実行制御)を使用します。行をUPDATEするとき、データベースは古いバージョンをその場で上書きしません。新しい行バージョンを書き込み、古いバージョンをdeadとしてマークします。リーダーは自分のスナップショット取得時点でコミットされていたバージョンを見ます。これこそが、長時間実行されるレポートクエリが incoming な書き込みをブロックしない理由(また逆も同様)であり、それぞれが異なる行バージョンで動作するからです。
しかし、MVCCはすべてのブロッキングを排除するわけではありません。2つのトランザクションが同じ行をUPDATEしようとするとき、PostgreSQLは勝者を決める必要があり、敗者は最初のトランザクションがコミットまたはロールバックするまで行レベルロックで待機します。SELECT FOR UPDATEは、現在の値に依存するビジネスロジックを実行する前に、そのロックを明示的に取得する方法です。
SELECT FOR UPDATE に続く条件付き UPDATE というパターンは、2つのリクエストがそれぞれ値を読み取り、両方が安全だと判断し、両方がデクリメントしてしまうという古典的なレースコンディションの標準的な修正です。明示的なロックがなければ、両方のトランザクションは同じ元の在庫数を読み取り、在庫を過剰販売してしまいます。
よくある落とし穴と実践的な takeaways
バックエンドコードでは、3つの失敗パターンが常に出現します。1つ目として、長時間実行されるトランザクションはスナップショットを保持しタプルのクリーンアップを妨げるため、テーブルが肥大化し時間とともにパフォーマンスが低下します — トランザクションは速やかにクローズし、HTTPリクエストの開始時に開いて下流の呼び出しを越えて開いたままにしてはいけません。2つ目として、デッドロックは2つのトランザクションが逆順でロックを取得するときに発生します。コードベース全体でロック取得順序を一貫させるか、PostgreSQLに依存してデッドロックを検出し一方を中断させてリトライしてください。3つ目として、SERIALIZABLE下でのシリアライゼーション失敗を無視すると、データ破損につながります — データベースはあなたに対し、その操作が並行実行で実際に安全ではなかったと伝えています。あなたのリトライロジックは任意ではなく、契約の一部です。
実践的な考え方:分離レベルとロックの選択は、データベースチューニングの詳細ではなく、アプリケーション設計の一部として扱ってください。重要なクリティカルセクションに SELECT FOR UPDATE を明示したデフォルトのREAD COMMITTEDが、ほとんどの同時実行バグをきれいに解決します。スループットよりも正確性が重要で、失敗を処理できる場合はSERIALIZABLEを検討してください。
まとめ
トランザクションは複数の文をアトミックな単位に束ねます。分離レベルは同時実行トランザクションが互いの未コミット作業をどこまで見えるかを制御します。MVCCは複数の行バージョンを保持することでPostgreSQLにノンブロッキングな読み書きを提供します。一方、行レベルロックは2つのトランザクションが同じ行を対象としたときの競合を解決します。バックエンド開発者にとって、具体的なツールはBEGIN/COMMITの規律、レースコンディションを起こしやすい更新のためのSELECT FOR UPDATEパターン、そしてデッドロックを避けるための一貫したロック順序です。分離レベルは意図的に選んでください — デフォルトはほとんどのケースで機能しますが、デフォルトの動作が十分に厳密ではない状況のためにREPEATABLE READとSERIALIZABLEが用意されています。
レッスンのチェックポイント
学んだ内容を復習し、回答へのフィードバックを受けましょう。