PostgreSQL は、真の直列性を達成するために、伝統的な二相ロックのパフォーマンスペナルティなしに、述語ロックと直列化グラフテストを使用して、Serializable Snapshot Isolation (SSI) を実装します。40001 エラー (serialization_failure) は、特に書き込みスキューまたは読み込み-書き込み競合中に発生します。ここでは、2 つのトランザクションが rw 依存関係サイクルを確立します。たとえば、トランザクション A が述語を満たす行(例: WHERE color = 'red')を読み、トランザクション B が非重複の述語を満たす行(例: WHERE color = 'blue')を読み取ります。そして、A が行を 'blue' に更新し、B が行を 'red' に更新します。どちらのトランザクションも互いをブロックしませんが、結果は直列化できません。
このパターンは、直列化グラフにおける危険な構造を示しています。2 つの連続した rw-反依存関係が潜在的なサイクルを形成します。PostgreSQL はこれを検出し、異常な状態を防ぐために 1 つのトランザクションを中止します。この問題は微妙で、トランザクションが異なる物理行を変更する可能性があるため、低い隔離レベルで使用される行ロックメカニズムにはこの競合が見えません。
義務づけられる解決策は、アプリケーションが楽観的な再試行ループを実装することです。SQL EXCEPTION '40001' をキャッチすると、アプリケーションは現在のトランザクションをロールバックし、指数バックオフで完全な操作を再試行する必要があります。デッドロックとは異なり、通常は即座に再試行することで解決されますが、高い競合状況下での直列化失敗は、雷のような大群を防ぐためにジッター遅延から恩恵を受けます。
-- PL/pgSQL でのアプリケーション再試行ロジックの例 DO $$ DECLARE retries INT := 0; max_retries INT := 3; BEGIN WHILE retries < max_retries LOOP BEGIN SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE; PERFORM * FROM inventory WHERE category = 'electronics' AND count > 0; UPDATE inventory SET count = count - 1 WHERE item_id = 123; COMMIT; EXIT; EXCEPTION WHEN SQLSTATE '40001' THEN ROLLBACK; retries := retries + 1; PERFORM pg_sleep(power(2, retries) * 0.1); -- 指数バックオフ END; END LOOP; END $$;
コンサートチケットの交換プラットフォームでは、ユーザーがチェック-アクション ロジックを介して席のカテゴリを交換できました。トランザクション A は VIP 席が利用可能であることを確認し、その後保留中の VIP 席をスタンダードにダウングレードしました。同時に、トランザクション B はスタンダードの利用可能性を確認し、スタンダード席を VIP にアップグレードしました。READ COMMITTED の下で、2 つのトランザクションはどちらも利用可能性を真と見なし、更新を実行し、システムは両方のカテゴリでマイナスの在庫になりましたが、各トランザクションは制約をチェックしていました。
3 つのソリューションが設計されました。最初のものは明示的な SELECT FOR UPDATE ロックを使用しましたが、これは利用可能性のクエリがゼロ行を返すと失敗し、ロックを取得せず、システムをファントム挿入に対して脆弱にしました。2 番目のアプローチは、pg_try_advisory_lock() を使用してアドバイザリ ロックを実装し、席のカテゴリへのアクセスを直列化しました。これにより競合が防止されましたが、複雑なロックオーダリングリスクを引き起こし、すべてのカテゴリチェックの直列化によりスループットが 40% 減少しました。
3 番目の解決策は、アプリケーションレベルの再試行ループを使用した SERIALIZABLE 隔離を採用しました。これは、手動ロック管理なしで正しさを保証し、同時スワップの頻度が読み取り操作に対して低いため、再試行のオーバーヘッドが許容可能であるため選択されました。実装は、SQLState 40001 の SQLException をキャッチし、100ms * 2^attempt を待機し、トランザクションを再実行する JDBC 再試行ハンドラを使用しました。これにより、オーバーブッキングのインシデントが完全に排除されましたが、ピーク販売ウィンドウ中の p99 レイテンシは 15ms 増加しました。
Serializable 隔離における述語ロックと Repeatable Read における行ロックの正確な違いは何ですか?
Repeatable Read は、クエリによって実際に返された行をロックすることで非再現可能な読み取りを防ぎますが、ファントム読み取りは防ぎません。ファントム読み取りとは、他のトランザクションによって挿入された新しい行であり、クエリの WHERE 句を満たすものです。Serializable 隔離は、検索範囲自体をロックする述語ロックを使用し、クエリの述語に一致する挿入を防ぎます。このクエリが実行されたときに存在しなかった行でもです。候補者はしばしばこれを混同し、Repeatable Read がファントム読み取りを防ぐと誤って考えたり、Serializable が既存の行のみをロックすると誤解したりします。
サイクルが検出されたときに、直列化グラフテストアルゴリズムは中止するトランザクションをどうやって決定しますか?
PostgreSQL は、「最初のコミッターが勝つ」という戦略を使用し、危険な構造の検出と組み合わせています。同時トランザクション間で rw-競合 (読み込み-書き込み依存関係) が形成されると、システムはこのエッジが直列化グラフ内でサイクルを完了するかどうかを追跡します。サイクルを完了するトランザクションは、SQLSTATE 40001 で中止されます。選択はトランザクションの年齢ではなく、グラフ構造に基づいて決定的であり、検出されたサイクル内でロールバックが最も安価または最近のトランザクションの中止を好みます。このことは、無効な履歴を防ぐための予防的な中止であって、デッドロック(ロックを待機する)ではないことを理解することが、適切なエラーハンドリングには不可欠です。
なぜ SELECT FOR UPDATE が Serializable 隔離で競合が検出されるシナリオで直列化失敗を防ぐことに失敗する可能性があるのですか?
SELECT FOR UPDATE は、実行時点で存在する行に対してのみ ROW SHARE ロックを取得します。チェック-アクションパターンでは、最初のクエリがゼロ行を返す場合(例:利用可能な座席がゼロか確認する)、FOR UPDATE はまったくロックを取得せず、別のトランザクションが競合行を挿入するのを許可します。Serializable 隔離は、ゼロ行の結果が有効な読取セットを構成し、同時挿入によって無効になったため、これを述語競合として検出します。候補者はしばしばFOR UPDATEが包括的な保護を提供すると誤って仮定し、初期の述語が何も一致しない場合にファントム挿入に対する防御が提供されていないことに気付いていません。