Amazon Web Services ブログ
PostgreSQL パフォーマンス向上 : ロックマネージャー競合の診断と対策
(この記事は、Improve PostgreSQL performance: Diagnose and mitigate lock manager contention を翻訳したものです。)
ワークロードが拡張するにつれて、データベースの読み取り操作が予期せず遅くなっていませんか?PostgreSQL ベースのシステムを運用している多くの組織では、すぐには明らかにならないパフォーマンスのボトルネックに遭遇する事があります。多数のパーティションやインデックスを持つテーブルに対して多くの同時読み取り操作がアクセスすると、PostgreSQL の高速パスロック機能を使い果たし、システムが共有メモリロックを使用せざるを得なくなることがあります。高速パスから共有メモリロックへの切り替えは、ロックマネージャーにおいて軽量ロック (LWLock) の競合を生み出し、読み取り専用操作であってもデータベースのパフォーマンスに影響を与えます。
本投稿では、読み取り集約型のワークロードが、高速パスロックの制限を超えることによって LWLock 競合を引き起こす仕組みを探求します。これは、PostgreSQL エンジンとそのロック機構に基づく任意のシステムで発生する可能性がある問題です。デモンストレーション目的で本投稿では Aurora PostgreSQL 互換エディションを使用します。実際的な実験を通じて、パーティションスキャン、インデックスの使用、複雑な結合がロック動作にどのような影響を与えるかを実演します。また、ワークロードが低速パスロックに移行するタイミングを特定する方法と、より良いパフォーマンスのためにクエリ設計とスキーマ構造を最適化する具体的な技術を実装する方法も示します。
読み取り集約型のワークロードが増加するにつれて、データベース管理者 (DBA) は、データベースを健全に保つために LWLock 競合を監視し、対処する必要があります。同様に、開発者は過度の競合を引き起こすパターンを避けなければなりません。データベースが高速パスから低速パスロックに移行すると、スループットは大幅に低下する可能性があります (本投稿のテストでは最大 34 パーセントのパフォーマンス差を示しています)。このスループット低下は、LWLock:lock_manager などの待機イベントを監視し、pg_locks ビューを確認してワークロードを実行しているバックエンドプロセスの高速パススロットが枯渇しているかどうかを確認することで特定できます。これらのボトルネックに対処するには、効果的なパーティションプルーニング、慎重なインデックス管理、PostgreSQL のバックエンドプロセスあたり 16 スロットの高速パス制限内にワークロードを収めるシンプルな結合パターンなどの戦略が必要です。
LWLock 競合の理解
LWLock は、共有メモリ構造へのアクセスを調整するために PostgreSQL で使用される同期の仕組みです。ヘビーウェイトロック (ユーザー主導のデータベースオブジェクトレベルのロック) とは異なり、LWLock は軽量で高パフォーマンスに最適化されており、共有データへの同時アクセスを管理しながら低いオーバーヘッドを提供します。
LWLock 競合は、複数のプロセスが共有メモリ内のロックデータ構造上の同じ LWLock を取得しようと競合する際に発生し、遅延を引き起こします。この競合は通常、多くのバックエンドプロセスが次のような頻繁に共有されるリソースにアクセスする必要がある場合に発生します。
- Buffer Manager – 読み取り/書き込み操作中に共有バッファを保護する
- Lock Manager – ロック関連のデータ構造へのアクセスを調整する
- WAL management – 先行書き込みログ (WAL) への書き込みを同期する
LWLock 競合は、同時データベース接続数が増加するにつれて増大する可能性があります。
これは特に、Aurora PostgreSQL 互換エディションが、高並列処理、多数のパーティションを持つテーブル、または多数のインデックスを持つテーブルを含むワークロードの高スループット環境で顕著です。
テーブルに対して SQL クエリを実行する際、PostgreSQL はそのテーブルと関連するインデックスに対してロックを取得しようとします。これがパーティションテーブルの場合、SQL クエリによってアクセスされるテーブルパーティションに対してロックが取得されます。ロックをより高速かつ効率的にするため、システムが他のロックとの競合がないことを迅速に確認できる場合、制限の少ない弱いロック (AccessShareLock、RowShareLock、RowExclusiveLock など) に対して高速パスロックが使用されます。
高速パスロック: 動作メカニズム
PostgreSQL では、高速パスロッキング機構により、競合しない操作は共有メモリロックハッシュテーブルとそれに関連する LWLocks をバイパスすることができます。高速パスロッキングは、最も一般的な使用例、即ち、同じリレーションに競合する強力なロックが存在しない限り、弱いロックを取得する頻繁な同時クエリ向けに設計されています。
高速パスロックの動作は以下の通りです。
- セッション別キャッシュ – 各バックエンドプロセスは、高速パスロックを格納するために、プライベート
PGPROC
構造内に最大 16 個のスロット (デフォルトでFP_LOCK_SLOTS_PER_BACKEND = 16
) を割り当てます。 - 迅速な高速パス適格性チェック –
SELECT * FROM my_table;
を実行すると、PostgreSQL は小さなバックエンド別の LWLock (MyProc->fpInfoLock
) を取得して、現在の SQL クエリに高速パスロック機構が使用できるか、以下の項目をチェックします。- ロックモードが適格な弱いモードであること
- 他のセッションが競合するロックを保持していないこと
- バックエンドがローカルバックエンドメモリ配列の 16 スロットをすべて使用していないこと
- ローカル許可 – 上記の高速パス適格性チェックがパスすると、
FastPathGrantRelationLock()
がバックエンドのローカルキャッシュにロックを格納します。共有メモリベースのロックハッシュテーブルを保護する共有メモリベースの LWLock は取得されず、関数は成功して即座にリターンします。
実際には、これはトランザクションがアクセスする最初の 16 個の一意のテーブル (またはインデックス) では、ロックマネージャーのオーバーヘッドはほぼゼロになることを意味します。
高速パスキャッシュは小さく、16 個のロックを超えたり、より強いロックモードを要求したりすると、PostgreSQL は低速パスにフォールバックしなければなりません。
- 既に取得済みの 16 個の高速パスロックを超える高速パスロックのすべての要求は、
FastPathTransferRelationLocks()
を使用して共有ロックテーブルに移行されます - ロックタグ(リレーション OID とロックモードを含む)は、共有メモリロックハッシュテーブルの 16 個のロックパーティションのうちの 1 つにハッシュ化されます
- PostgreSQL は次に、パーティション LWLock (
LWLockAcquire(partitionLock, LW_EXCLUSIVE)
) を取得し、共有ハッシュテーブルを更新し、LWLock を解放します
それ以降、テーブルからインデックスまでの追加のロック取得は、ロックマネージャーを通じて行われ、同時実行下でLWLock:LockManager
待機イベントを生成します。
高速パス最適化から低速パス競合への移行を理解することで、高速パスの制限内に留まるクエリとスキーマを設計し、ロックマネージャーのボトルネックを完全に回避できます。
実験概要
以下のセクションでは、3 つの実験を通じて LWLock 競合の詳細を説明します。
- パーティション化されたテーブルでのロックを観察する
- 使用されていない、または不要な複数のインデックスを持つパーティション化されていないテーブルでのロックを観察する
- マルチ結合クエリでのロック動作を観察する
それぞれの実験では、スキーマのセットアップ、ワークロードの実行、PostgreSQL システムビューによるロック監視、および pgbench を使用した同時実行の影響分析を実演します。すべての実験は、db.r7g.4xlarge インスタンスを使用して Aurora PostgreSQL 互換エディション (PostgreSQL 16.6 と互換性あり) で実施しました。
前提条件
実験を開始する前に、以下が必要となります。
- Aurora PostgreSQL データベースへのアクセス権を持つ AWS アカウント
- AWS Management Console へのアクセス権
- Aurora PostgreSQL インスタンスへの接続性を持つ Amazon Elastic Compute Cloud (Amazon EC2) インスタンス
- Amazon EC2 インスタンス上にインストールされた PostgreSQL クライアント (psql など)
- Amazon EC2 インスタンス上にインストールされた pgbench
- Aurora PostgreSQL クラスターを作成・管理するための AWS Identity and Access Management (IAM) 権限
実験 1 : パーティション化されたテーブルでのロックを観察する
この実験では、3 つの主要なテストシナリオを実行し、パーティション化されたテーブルを扱う際の PostgreSQL のロック動作を調査します。
orders
テーブルのすべてのパーティションをクエリして、PostgreSQL がパーティション全体にわたってロックをどのように処理するかを観察します- 特定のパーティションにアクセスするためにパーティションプルーニングを使用して、より的を絞ったアプローチを検証します
- 実世界のワークロードをシミュレートするため に pgbench を使用して、高い同時実行下で両方のアプローチをストレステストします
これらのクエリを通じて、PostgreSQL の高速パスロック最適化がどのように動作するか、高速パススロットが枯渇したときに何が起こるか、および同時実行ワークロード下でパーティションプルーニングがいかにパフォーマンスを改善できるかを実演します。order_ts
タイムスタンプ列を使用して月単位でデータがパーティション化されたorders
テーブルを使用します。
この実験では、以下の点について重要な洞察が明らかになります。
- PostgreSQL が読み取り専用操作中にロックをどのように管理するか
- 高速パスと低速パスロックの影響
- パーティションプルーニングがどのようにロック競合を軽減できるか
- 高並行性環境におけるパフォーマンスへの影響
スキーマ準備
好みの PostgreSQL クライアント (psql など) を使用して、EC2 インスタンスから Aurora PostgreSQL インスタンスに接続し、12 の月次子パーティションを持つorders
テーブルを作成します。以下の SQL コードを実行してください。
テスト 1 : Orders テーブルの全パーティションをクエリして、ロック動作を観察する
それでは、トランザクションを開始し、パーティション化されたorders
テーブルのすべてのパーティションに対してクエリを実行します。パーティションプルーニング無でクエリを実行すると、PostgreSQL はすべてのパーティションにアクセスする必要があり、ロックのオーバーヘッドが大幅に増加します。このテストを開始するには、Aurora PostgreSQL データベースへの新しい接続を開き、以下のコマンドを実行します (これをセッション 1 と呼びます) 。
上記のSQL ステートメントは、12 のパーティションすべてをスキャンするトランザクションを開始します。COMMIT
、ROLLBACK
またはEnd
コマンドを実行せず、トランザクションを開いたままにしてロックを維持してください。
セッション 1 のトランザクションが開いたままの状態で、2 番目のセッション (これをセッション 2 と呼びます) を開き、データベース内のロックの状態を確認するために次の SQL クエリを実行してください。
前述の出力の最後の 10 行で fastpath
列の値がt
(True) とf
(False) のうち、f になっていることに注目してください。また、返された行の総数は 26 です。値 t は、16 の高速パススロットが使い果たされ、残りのパーティション/インデックスの AccessShareLock
が共有メモリロックハッシュテーブル (低速パス) に移行されたことを意味します。トランザクションが完了すると、これらのロックは解放されます。
テスト 2 : 注文テーブルの特定のパーティションをクエリし、ロック動作の変化を観察する
セッション 1 として、以下に示すように、新しいトランザクション内でパーティションプルーニングアプローチを使用するクエリを実行します。より多くのパーティションにアクセスし続けると、追加の高速パスロックが獲得されます。
セッション 1 のトランザクションが開いたままの状態で、前回のテストで利用したセッション 2 で以下の SQL 文を実行して、データベース内のロックの状態を確認してください。
この出力は、すべてのロックが高速パスロックであることを示しており、fastpath
列の値がt
(True) になっています。これにより、低速パスロックを取得する必要がなくなります。
これは高速パスの最適化を示していますが、同時実行を伴うシナリオについては説明していません。同時実行を伴うシナリオでは、各バックエンドが高速パススロットを使い切るか、16 スロットの制限内に留まるかのいずれかになります。この特定のシナリオを詳しく見ていきましょう。pgbench を使用してマルチユーザーワークロードをシミュレートします。
テスト 3.1 : Orders テーブルの全パーティションに複数クエリを同時実行し、ロック動作の変化を観察する
パーティションプルーニングせずにパーティションにアクセスする複数の読み取りワークロードをシミュレートするには、以下の pgbench コマンドを使用します。このコマンドは複数のスレッドにわたってSELECT count(*) FROM orders
クエリを継続的に発行します。このテストでは、トランザクションが高速パススロットを使い果たし、メインロックマネージャーを通じてロックの獲得を強制する (LWLock:LockManager
の待機をトリガーする) 高い並列度の下で、PostgreSQL の高速パスロック最適化がどのように動作するかを評価します。
pgbench
では、-c
と-j
オプションを使用してベンチマークワークロードの同時実行性と並列性を制御します。-c オプションは同時クライアント数を指定し、同時にアクティブになるシミュレートされたユーザーセッションまたはデータベース接続の数を意味します。この数値によって、PostgreSQL データベースに適用される負荷のレベルが決まります。-j
オプションはpgbench
がこれらのクライアント接続を管理するために使用するワーカースレッドの数を定義します。各スレッドは全クライアントの一部を処理し、ワークロードはスレッド間で均等に分散されます。これにより pgbench
はマルチコアシステムをより効率的に使用し、クライアント側のボトルネックを回避できます。
実行時に認証情報を入力せずに pgbench
コマンドを実行するには、次の環境変数を設定します:PGHOST
(Auroraクラスターのエンドポイント)、PGPORT
(ポート番号、例えば 5432)、PGDATABASE
(データベース名、例えば postgres)、PGUSER
(データベースユーザー)、およびPGPASSWORD
(データベースユーザーのパスワード)。
上記の pgbench コマンドは、transaction.sql で定義されたトランザクションを実行する 100 の同時クライアント (-c 100
) を 15 分間または 900 秒 (-T 900)
シミュレートします。
transaction.sql
ファイルには、experiment_1
スキーマへの検索パスの設定とともに、次の SQL が含まれています。
前回のテストのセッション 1 ターミナルから、次の pgbench コマンドを実行してください。このコマンドは 15 分で完了します。このコマンドの実行中、Amazon CloudWatch Database Insights のデータベースの待機イベントを監視します。
このワークロードでは、1 秒あたりの平均トランザクション数 (tps) は 46,672 に達し、テスト時間の 15 分間で 4,200 万のトランザクションが処理されました。
CloudWatch Database Insights の以下のスクリーンショットは、アクティブなセッションがインスタンスの CPU 容量を超え、著しいロック競合を伴う高負荷を経験しているデータベースを示しています。
上のスクリーンショットは、db.r7g.4xlarge インスタンスで実行されている Aurora PostgreSQL 16.6 クラスターが、CPU 使用率とロック競合の両方による高いデータベース負荷を示しています。平均アクティブセッション (AAS) は約 21 で維持されており、これはインスタンスの 16 vCPU 容量よりも高い値です。負荷の 66 パーセントは CPU 使用率に起因していますが、34 パーセントは LWLock:LockManager
の待機に費やされており、PostgreSQL 内部ロック構造の競合を示しています。
次に、パーティションプルーニングを使用してパフォーマンスを評価します。
テスト 3.2 : パーティションプルーニングを使用して、Orders テーブルの特定のパーティションに複数クエリを同時実行する
パーティションプルーニングを使用していない前回のテストと対比するために、ここではパーティションプルーニングがどのようにパフォーマンスを向上させるかを実証します。このワークロードでは、この実験に使用されるtransaction.sql
ファイルには PL/pgSQL が含まれています。これは単純な SQL クエリの代わりに使用され、パーティション分割されたテーブルと実行時に生成される値を処理する際に、PostgreSQL で効率的なパーティションプルーニングを提供します。以下のクエリのように SQL を使用することもできますが、order_ts
に対するフィルターは共通テーブル式 (CTE) 内のランダムに生成された日付から派生しており、最適な実行計画を作成するクエリプランナーは、クエリ計画時に order_ts
値を決定できません。その結果、PostgreSQL はすべてのパーティションを考慮する必要があり、すべてのパーティションの不必要なロックとスキャンにつながります。しかし、ランダムな日付を計算し EXECUTE
を使用してクエリを動的に構築する PL/pgSQL ブロックに切り替えることで、実際の日付値が SQL 文字列に直接注入されます。これにより、クエリプランナーの観点からフィルターが定数に変換され、効果的なパーティションプルーニングが可能になり、関連するパーティションのみがアクセスされロックされることが保証されます。
以下は、上記で説明した CTE ベースの SQL クエリで、すべてのパーティションをロックする可能性があります。
上記で説明した効果的なパーティションプルーニングを実現するために、以下の PL/pgSQL アプローチを使用した SQL を使用してください。
前回のテストと同じ手順に従い、セッション 1 のターミナルから pgbench コマンドを実行してください。このコマンドは 15 分でテストを完了し、コマンドの実行中、CloudWatch Database Insights のデータベースの待機イベントを監視します。
上記の pgbench 出力に示されているように、このワークロードは 1 秒あたり平均 59,255 トランザクションを達成し、15 分間のテスト期間中に約 5,330 万トランザクションが処理されました。ロック競合がない状態では、システムは 1,100 万トランザクションを追加で処理出来た事になります。
CloudWatch Database Insights の以下のスクリーンショットは、パーティションプルーニングを実装した後の改善されたデータベースパフォーマンスを示しており、安定した負荷パターンとロック競合がないことがわかります。
前述のワークロードにパーティションプルーニングを導入したことで、Aurora PostgreSQL 16.6 クラスターのパフォーマンスは大幅に向上しました。以前は、ワークロードは CPU 使用率と並んでデータベース負荷の約 34 パーセントを消費するLWLock:LockManager
の待機イベントによって特徴付けられていました。
対照的に、現在のワークロードパフォーマンスはバランスの取れたワークロードを示しています。平均アクティブセッション (AAS) は最大 vCPU 閾値を下回っており、待機は Timeout:SpinDelay
のごく一部が観測されているのみです。CPU は最適化された OLTP システムでは予想される通り、負荷の主要な要因となっています。そしてロック競合は大幅に減少しました。これは、パーティションプルーニングによって取得されるロックの数を削減し、各セッションが関連するパーティションとセッションごとの高速パスロックのみにアクセスするようになり、同時実行性が大幅に向上したことを意味します。パーティションプルーニングにより、AAS は最大 vCPU 閾値を下回ったままでした。
実験 2 : 複数の未使用または不要なインデックスを持つ非パーティションテーブルのロックを観察する
この実験では、PostgreSQL が複数の B-tree インデックスを持つ非パーティション化テーブルでのロック動作をどのように処理するかを調査します。ここでは、使用されていないまたは不要なインデックスの影響に焦点を当てるためにパーティション化されていないテーブルを使用しています。E コマースまたは在庫システムを表すitems
テーブルを使用して、2 つの重要なテストシナリオを探ります。
- まず、インデックスのみのスキャンを使用して単純なクエリを実行し、以下を観察します
- PostgreSQL が複数の未使用または不要なインデックス全体でロックをどのように管理するか
- 高速パススロットが使い果たされた場合に何が起こるか
- 20 個の B-tree インデックスを持つことがロック取得に与える影響
- 次に、高い同時実行性の下でシステムのストレステストを行い、以下を実証します
- 過剰なインデックスがロックマネージャーの動作にどのように影響するか
- 複数のインデックスによるロック競合のパフォーマンスへの影響
- インデックス数と
LWLock:LockManager
の待機の関係
これらのテストを通じて、インデックス関連のロックオーバーヘッドに関する重要な洞察を明らかにし、高い同時実行性の環境におけるインデックス管理のための実践的なガイダンスを提供します。
スキーマ準備
以下の SQL コードを使用して、Aurora PostgreSQL データベースに items テーブルスキーマとそれに関連するインデックスを作成してください。
上記の SQL コードは、商品の詳細 (SKU
、price
、inventory
など) 用の 26 カラムを持つ E コマースのitems
テーブルと、よく検索されるカラムやカラムの組み合わせに対する 20 個の B-tree インデックスを作成します。
テスト 1 : 複数のインデックスを持つ非パーティションテーブルをクエリする
過剰なインデックスが PostgreSQL のロック動作にどのように影響するかの調査を始めるために、最初のテストとして items
テーブルに対してクエリを実行しましょう。単純な名前検索クエリを実行して、ほとんどのインデックスがこのクエリには不要であるにもかかわらず、PostgreSQL が 20 個すべてのインデックスにわたってロック管理をどのように処理するかを観察します。これにより、過剰なインデックスを維持することで生じる基本的なロックオーバーヘッドを理解するのに役立ちます。トランザクションを開始し、インデックスアクセスパスを使用して items
テーブルの特定のカラムにクエリを実行します。
前述の SQL クエリに対して、クエリプランナーはインデックスのみのスキャンパスを選択しました。この SQL ではitems
テーブルからname
カラムのみを選択しています。別のセッションで、ロック動作を観察します。
以下の出力で、items
テーブル、主キー、およびクエリ実行のためにプランナーが使用したインデックス (idx_items_name
) とは別に AccessShareLock
を獲得したインデックスに注目して下さい。返された行の総数は 22 で、高速パススロットは使い果たされ、6 つのロックが共有メモリロックテーブルに移動しました。このテーブルにさらにインデックスがあった場合、それらのインデックスもAccessShareLock
を必要とし、高速パススロットが使い果たされているため共有メモリロックテーブルに配置されるでしょう。このテーブルに対して高い並行ワークロードが発生すると、共有メモリロックテーブルで競合が発生するため、パフォーマンスは低下する事が想定されます。
テスト 2 : 高い同時実行性のもとで、複数のインデックスを持つ非パーティションテーブルをクエリする
これらの不要なインデックスがパフォーマンスにどのように影響するかを理解するために、高い並行性の下でパーティション化されていないitems
テーブルのストレステストを行いましょう。パーティション化されたテーブル (実験 1) での実験と同様に、pgbench を使用して複数の同時ユーザーが同時にテーブルにアクセスすることをシミュレートします。
最初の実験と同じ pgbench コマンドを使用して、セッション 1 のターミナルから以下を実行します。このテストは 5 分間実行されます。テスト実行中、CloudWatch Database Insights のデータベースの待機イベントを監視します。
transaction.sql
ファイルには、前回のテストと同じitems
テーブルに対する名前検索 SQL クエリが含まれています。
CloudWatch Database Insights の以下のスクリーンショットは、items
テーブルの過剰なインデックスによって、LWLock:LockManager
の待機時間が増加し、データベース負荷と CPU 使用率が増加する様子を示しています。
ここで観察されたLWLock:LockManager
待ちは、主に items
テーブルの過剰なインデックスによって引き起こされています。データが無い場合でも、PostgreSQL はクエリの計画と実行中に 20 個すべてのインデックスを調べ、関連するロックを取得し、カタログメタデータにアクセスする必要があるため、オーバーヘッドが発生します。高い並行性のため、多数のロックが関与し、データベースセッションは高速パスロックを使い果たし、バックエンドプロセスがメインロックマネージャーに頼らざるを得なくなり、追加の競合が発生しました。これにより、繰り返されるカタログスキャンによる CPU 使用率の増加と、ロック取得のオーバーヘッドによるデータベース負荷の上昇が生じました。不要なインデックスの数を減らすことは、クエリプランニングの複雑さを減少させるだけでなく、高速パスロッキングを維持するのに役立ち、高い並行性のワークロードの下でシステム効率を向上させるでしょう。
実験 3 : 複数結合クエリでのロック動作を観察する
この実験では、PostgreSQL が複数の関連テーブルにまたがる複雑なクエリを実行する際にどのようにロックを管理するかを調査します。実際的な E コマースデータベーススキーマを使用して、2 つの重要なテストシナリオを探ります。
- まず、単一のマルチ結合クエリを検証します
- ユーザーのカート内容、アイテム価格、注文状況、および支払い詳細を単一の読み取り専用操作で取得します
- 6 つの相互関連テーブル (
users
、carts
、cart_items
、items
、orders
、payments
) を接続します - PostgreSQL が複数のテーブルとそのインデックスにまたがるロックをどのように処理するかを実証します
このクエリは、複数のテーブル結合が累積的なロックのフットプリントにどのように影響するかを観察する機会を提供します。各テーブルには独自の主キーと外部キーのインデックスがあるため、PostgreSQL はこれらの単純な読み取りに高速パスロックを使用できる可能性があり、共有メモリロックテーブルにエントリを取得するオーバーヘッドを回避できます。
- 次に、高い並行性の下でシステムのストレステストを行います
- 複雑な結合を実行する複数のセッションがロック管理にどのように影響するかを観察します
- 複数のインデックス付きテーブルにまたがるロック取得のパフォーマンスへの影響を測定します
- 結合の複雑さが CPU 使用率とロック競合の両方にどのように影響するかを実証します
これらのテストを通じて、以下に関する重要な洞察を明らかにします。
- 複雑な相互接続されたテーブル構造におけるロック管理
- テーブル結合、インデックス、およびロックオーバーヘッドの関係
- 高い並行性環境におけるマルチ結合クエリのパフォーマンスに関する考慮事項
スキーマ準備
以下の SQL コードを使用して、6 つの相互接続されたテーブル (users
、carts
、cart_items
、items
、orders
、および payments
) とそれらに関連するインデックスおよび外部キー制約を含む E コマーススキーマを作成してください。このスキーマは、実際の E コマースデータベースをシミュレートするために、テーブルごとに複数のインデックスと適切な参照整合性制約を含む包括的な設計になっています。
テスト 1 : 複数のインデックステーブルにわたるマルチ結合クエリを実行する
PostgreSQL が複雑な結合操作中にロックをどのように処理するかの調査を開始するために、ユーザーのショッピングカート情報を取得するマルチ結合クエリを使用して最初のテストを実行しましょう。このクエリは、PostgreSQL が複数のテーブルとそれらに関連するインデックスにわたってロックをどのように管理するかを示します。セッション 1 で、トランザクションを開始して次のクエリを実行します。
セッション 1 で上記のトランザクションを開いたままにして、セッション 2 で以下のクエリを実行し、PostgreSQL がマルチ結合クエリに関わるすべてのテーブルとインデックスにわたってロックをどのように管理しているかを調べてください。
上記の出力では、PostgreSQL が合計 39 個のロックを取得したことがわかります。fastpath
列を見ると、23 行が f (false) を示しており、これらのロックは高速パスの代わりに共有メモリロックテーブルを通じた、低速パスを使用する必要があったことを示しています。これは、一見単純なネスト結合クエリでも、高速パススロットが使い果たされると著しいロック競合が発生する可能性があることを示しています。
テスト 2 : 高い同時実行性でマルチ結合クエリを実行する
上記の SQL クエリにおける複雑な結合が大規模環境でどのように実行されるかを理解するために、高い並行性の下で E コマーススキーマのストレステストを実施しましょう。前回の実験と同様に、pgbench を使用して複数の同時ユーザーが同時にマルチ結合クエリを実行することをシミュレートします。
前回の実験 (実験 1 と 2) と同じ pgbench コマンドを使用して、セッション 1 のターミナルから以下を実行します。このテストは 5 分間実行されます。
transaction.sql
ファイルには、前のテストと同じマルチ結合 SQL クエリが含まれています。テスト実行中、CloudWatch Database Insights でデータベース待機イベントを監視します。
CloudWatch Database Insights の以下のスクリーンショットは、多数のインデックスが付いたテーブル間のマルチ結合クエリが、アクティブセッションの 20 パーセントを消費するLWLock:LockManager
競合と、80 パーセントで飽和に近づく CPU 使用率という二重のパフォーマンス影響をもたらすことを示しています。
データベース負荷グラフは、複数のインデックスを持つテーブル間でのマルチ結合クエリによる著しいLWLock:LockManager
競合 (AAS の 20 パーセント) と高い CPU 使用率 (80 パーセント) を示しています。各結合は、PostgreSQL がインデックスに対してAccessShareLock
ロックを取得することを強制し、高速パスロックを使い果たし、より遅いメインロックマネージャーに頼ることになります。クエリプランニング中の繰り返しのカタログスキャンにより、CPU 使用率は飽和に近づいています。このロックマネージャーのボトルネックは、結合プランニング中に複数のインデックス付きテーブル全体でロックを維持することの複合的なオーバーヘッドに起因しています。冗長なインデックスを減らし、結合パターンを簡素化することで、ワークロードで見られるロック競合と CPU 圧力の両方が軽減されるでしょう。
PostgreSQL のロック競合を管理するための主な緩和戦略
PostgreSQL のロック競合を軽減するための以下の主要な対策を検討してください。
- パーティション化されたテーブルへの最適化されたアクセス
- パーティションプルーニングを有効にする – フルテーブルスキャンの代わりに明示的な日付範囲 (
WHERE order_ts BETWEEN X AND Y
) を使用します。PostgreSQL のドキュメントからパーティションプルーニングについて詳細を学んでください。 - 定数のない動的 SQL を避ける – プルーニングを強制するため、本投稿の最初の実験のように CTE を PL/pgSQL ブロックに置き換えます。
- パーティション数を制限する – 可能な場合はパーティションを減らし (例えば、月次パーティションの代わりに四半期パーティションを検討)、高速パスの制限内に収めます。
- パーティションプルーニングを有効にする – フルテーブルスキャンの代わりに明示的な日付範囲 (
- インデックスの合理化
- 未使用のインデックスを監査し削除する –
pg_stat_user_indexes
を使用して使用頻度の低いインデックスを特定します。 - 冗長なインデックスを統合する – 単一カラムのインデックスを複合インデックスに置き換えます (例えば、3 つの個別インデックスの代わりに(
category
,subcategory
,price
))。 - 過剰なインデックス付けを避ける – OLTP にとって重要でない限り、テーブルあたりのインデックス数を制限します (例えば、10 以下)。
- 未使用のインデックスを監査し削除する –
- スキーマ設計の調整
- 不必要なパーティション化を避ける – 100GB を超えるテーブルまたは明確なアクセスパターンがあるテーブルのみをパーティション化します。テーブルパーティション化をいつどのように実装するかについての詳細なガイダンスは、「Improve performance and manageability of large PostgreSQL tables by migrating to partitioned tables on Amazon Aurora and Amazon RDS」を参照してください。
- カバリングインデックスを使用する – テーブルアクセスを避けるために
INCLUDE
列を追加します (リレーションロックを減らします)。カバリングインデックスとインデックスのみのスキャンの実装についての詳細は、PostgreSQL のドキュメントを参照してください。 - 高並行性テーブルを正規化する – 幅広いテーブル (例えば、
items
) を分割してインデックスの拡散を減らします。
- クエリの最適化
- 結合を簡素化する – マルチ結合クエリをマテリアライズドビューまたはステージドクエリに分割します。マテリアライズドビューの実装については、PostgreSQL のドキュメントを参照してください。
- 小さな読み取りをバッチ処理にする – 小さな検索 (例えば、
IN (...)
句) を組み合わせてロック頻度を減らします。
- PostgreSQL のチューニング
- max_locks_per_transaction を調整する – パーティション化が避けられない場合 (メモリを監視)、および余分なロックが共有メモリロックハッシュテーブルに移動される場合は増やします (例えば、256 から 512 へ)。
- 高速パスの使用状況を監視する –
pg_locks
を追跡してスロットの使い果たしを特定します。
クリーンアップ
本投稿のソリューションを実装することに関連した将来の料金が発生しないようにするには、作成したリソースを削除してください。
- 実験で作成したテストスキーマとテーブルを削除します
- テスト用に専用の Aurora PostgreSQL クラスターを作成した場合は、それを削除します
- 不要になった関連スナップショットを削除します
結論
PostgreSQL の読み取り負荷の多いワークロードにおける LWLock の競合は、高速パスロッキングの制限を超えることから生じます。これは、パーティションプルーニングの未実装、冗長なインデックス、および複雑な結合によって引き起こされます。本投稿の実験では、以下のことが実証されました。
- パーティションプルーニングはロックのオーバーヘッドを削減し、ロックを高速パススロットに限定することで、34 パーセントのパフォーマンス向上 (46,000 tps から 59,000 tps へ) をもたらしました
- 使用されていない各インデックスはロック負荷を増加させ、空のテーブルでも低速パスへのフォールバックを強制します
- マルチ結合クエリは競合を増幅し、テストされたシナリオでは 60 パーセントのロックが低速パスに溢れました
パーティションを意識したクエリの優先、厳格なインデックスの管理、および結合の簡素化によって、高速パスの効率を維持し、Amazon Aurora PostgreSQL とAmazon RDS for PostgreSQL で線形な読み取りスケーラビリティを提供できます。データベースが成長するにつれ、これらの最適化はロックのボトルネックなしで AWS の弾力性を活用するための基礎となります。
Aurora でのパフォーマンスチューニングとスケーラブルな PostgreSQL ワークロードの設計についての詳細は、「Aurora PostgreSQL チューニングの基本概念」を参照してください。
翻訳はテクニカルアカウントマネージャーの西原が担当しました。原文はこちらをご覧下さい。