質問の歴史。
アドバイザリーロックは、PostgreSQL 8.2で初めて登場し、MVCC タプル可視性システムの外部で動作する軽量のアプリケーションレベルの同期原始を提供しました。これらは、テーブルベースのロックが意味的に不適切またはパフォーマンス上の障害となるようなキュー処理や冪等取り込みのワークフローのために設計されました。行レベルのロックは特定のテーブルタプルに結び付けられ、xmax システム列に記録されるのに対し、アドバイザリーロックは共有メモリロックマネージャ内に完全に存在し、デッドタプルやWAL トラフィックを生じることなく抽象リソースへのアクセス管理メカニズムを提供します。
問題。
高競合な冪等取り込みパイプラインでは、ビジネスキー(例: 外部UUID)のユニーク性を従来のINSERT ... ON CONFLICTやSELECT FOR UPDATEを通じて強制することは、深刻なボトルネックを引き起こします。行レベルのアプローチは、ロックビットを設定するためにヒープへの書き込みを必要とし、これがテーブルを肥大化させ、VACUUMの圧力を加速し、競合解決中のユニークインデックスにホットスポットを引き起こします。課題は、ストレージ層に触れることなくハッシュ化されたビジネスキーのような論理エンティティに対して相互排除を提供し、ロック失敗が永続的な接続プールにリソースを漏らさないことを保証することです。
解決策。
重要なプロパティは、アドバイザリーロックが共有メモリ内のLOCKTAG ハッシュテーブルにのみ格納され、LOCKMETHOD_ADVISORYを使用しているため、基盤となるリレーションページを決して変更しないことです。アプリケーションは、pg_advisory_xact_lock(hashtext(business_key))を使用することで、COMMITまたはROLLBACK時に自動的に解放されるトランザクションスコープのミューテックスを取得し、セッションレベルのpg_advisory_lockに関連するロック漏れを防ぎます。このアプローチは、ロックがメモリ内の軽量エントリとしてのみ存在するため、テーブルの膨張やインデックス競合を排除します。以下に示します。:
BEGIN; -- ハッシュ化されたビジネスキーのトランザクションバウンドロックを取得 SELECT pg_advisory_xact_lock(hashtext('a1b2c3d4')); -- 挿入は安全; 他のセッションがロックを保持する場合でもユニークインデックスの競合はありません INSERT INTO events (business_key, payload) VALUES ('a1b2c3d4', '{"event":"click"}') ON CONFLICT (business_key) DO NOTHING; COMMIT;
テレメトリ会社のデータプラットフォームチームは、KafkaからPostgreSQLに取り込まれる50,000件のイベントの正確な処理を保証する必要がありました。各イベントは、冪等性キーとして機能するクライアント生成のUUIDを含んでいました。ユニークUUID列に対してINSERT ... ON CONFLICT DO NOTHINGを使用した最初の負荷テストは、ユニークB-treeインデックス上のスピンロック競合による深刻な尾部レイテンシを引き起こし、HOT 更新失敗から急速に膨張しました。ピーク時間中のWAL生成率は倍増し、レプリケーション遅延やストレージ容量を脅かしました。
提案された修正の一つは、SELECT * FROM events WHERE business_key = $1 FOR UPDATEを使用してキーの存在を事前確認し、結果が空の場合にのみ挿入することでした。この方法は重複を防ぎましたが、すべてのライターが既存の行またはサロゲート予約行のいずれかで行ロックを取得しなければならず、予約テーブルのページに大規模なホットスポットを作成しました。このアプローチは、15分ごとにデッドタプルを再取得するためにVACUUMを必要とし、ロックを保持することでチェックと挿入の間のレース条件を防ぐことができず、スループットを著しく制限しました。
アーキテクチャチームは、SETNX操作を使用して挿入を制御するために外部Redisキャッシュへの調整を移行することを提案しました。これによりデータベースの膨張が排除され、PostgreSQLの負荷が減少しましたが、重要な障害モードが導入されました: Redisクラスタとデータベース間のネットワークパーティションにより、Redisロックが期限切れになった際に、PostgreSQLトランザクションがまだコミットされていない場合に重複挿入を許可する可能性がありました。さらに、2つの分散システム間での一貫性を維持することは運用上の複雑さを追加し、Redlockや類似のアルゴリズムの実装を必要とし、操作ごとに約5ミリ秒のレイテンシ増加を引き起こしました。
選択された設計は、pg_advisory_xact_lock(hashtext(business_key))を通じてPostgreSQLのネイティブアドバイザリーロックを利用し、挿入を試みる前にハッシュ化されたUUIDのトランザクションバウンドロックを取得しました。これらのロックは共有メモリ内のみに存在し、ヒープに触れないため、ストレージオーバーヘッドはゼロであり、トランザクション終了時に自動的に解放され、セッションレベルのロックで見られるロック漏れを防ぎます。検出不能なデッドロックを避けるために、アプリケーションレイヤーは、すべてのUUIDをロックを取得する前にハッシュ化された整数値でソートし、すべての競合ワーカー間でグローバルな順序プロトコルを確保しました。
アドバイザリーロックは、外部依存関係なしに厳格な正確性を維持しながら、最低レイテンシ(サブミリ秒の取得)とゼロのストレージ副作用を提供したため選ばれました。Redisアプローチとは異なり、ロックのライフタイムはデータベーストランザクションにバインドされており、ロック取得と挿入コミットの間の原子性を保証します。SELECT FOR UPDATEとは異なり、テーブルの膨張は生成されず、生のON CONFLICTとは異なり、競合する並列挿入によってユニークインデックスがストレスを受けることはありませんでした。ヒープアクセスの前に直列化が行われたためです。
展開後、取り込みパイプラインは80,000件のイベントを毎秒処理し、p99 レイテンシは10ミリ秒未満に抑えられ、競合ピーク中の以前の200ミリ秒のスパイクと比較されました。テーブルの膨張は微小なレベルに緩和され、autovacuumはオフピーク時のみ実行され、WALボリュームは40%減少し、アーカイブストレージコストとレプリカ遅延が大幅に削減されました。このシステムは、複数のデータベース再起動や接続プールの混乱を通じて、単一の重複イベントやデッドロックによるタイムアウトなしで正確な一回のセマンティクスを維持しました。
なぜ、pg_advisory_lock(セッションスコープ)を使用する代わりにpg_advisory_xact_lockを使うことが、高スループットのワーカーアーキテクチャで接続プールの枯渇と重複取り込みのリスクをもたらすのか?
候補者はしばしば、pg_advisory_lockが明示的にロック解除されるか、セッションが接続されている限り持続することを認識していない。また、トランザクションが中断された場合でも、長寿命の接続を再利用するプール環境では、ロック解除呼び出しを迂回する論理エラーや例外がロックを無制限に保持し、同じビジネスキーを処理する次のワーカーが永遠に待機することになります。したがって、pg_advisory_xact_lockを使用すべきです。ロックの寿命をトランザクション境界に結び付け、自動的に解放され、ミューテックス漏れを防ぎ、ワーカープールを枯渇させ、取り込みパイプラインを停止させることを保証します。
複数のアドバイザリーロックを取得する際に全体の順序保証がない場合、検出不能なデッドロックが発生し、どの具体的なアプリケーションパターンがこの危険を排除しますか?
行レベルのデッドロックとは異なり、PostgreSQLのdeadlock_timeout検出器は犠牲者トランザクションを殺すことによって解決しますが、アドバイザリーロックのデッドロックはユーザー定義の名前空間内で発生するためエンジンには見えません。もしワーカーAがリソースXのロックをその後Yをロックした場合、ワーカーBがYをロックし、その後Xをロックした場合、両方のセッションはエラーなしで無限に待ちます。必須のパターンは、アプリケーション全体であらゆるロック要求を発行する前に、すべてのリソース識別子(例: **hashtext(uuid)**値)を厳密な単調順序(昇順または降順)でソートすることです。このグローバルな順序付けにより、待機グラフは非循環的なままであり、円状の依存関係が不可能となり、サイレントハングのリスクを排除します。
1つのトランザクションが保持できるアドバイザリーロックの数を制限する共有メモリの制約は何ですか?また、max_locks_per_transactionを超えると、行レベルのロック枯渇と比較してどのように現れますか?
多くの候補者はアドバイザリーロックが無限であると仮定していますが、それらはmax_locks_per_transaction設定パラメータ(デフォルトは64)によって支配される共有ロックテーブルのエントリを消費します。この制限を超えるロックを持つと、ERROR: out of shared memory (SQLSTATE 53200)が発生し、トランザクションが即座に中断されます。これは、通常、行レベルのロックでは制限を超えるとロックのアップグレードやlock_timeoutに応じて待機が発生しますが、固定の共有メモリプールが枯渇することはありません。この問題を軽減する手段として、オペレーションを小さなサブトランザクションにバッチ処理するか、複合ハッシュを使用して単一のアドバイザリーロックキーの下に複数の論理リソースを集約することです。