Postgres における接続スケーラビリティの限界の分析
※本ブログは、Microsoft Tech Community Blog ‘’Analyzing the Limits of Connection Scalability in Postgres – Microsoft Tech Community‘’の抄訳です。
高負荷のPostgresデータベースを管理する人や、その状況を予想する人にとってのPostgresの共通の課題の1つは、Postgresが非常に大量の接続をうまく処理していないということです。
数千の接続を問題なく確立することは可能ですが、現実的で回避が難しい問題がいくつかあります。
昨年マイクロソフトに入社して、オープンソースの Postgres に取り組んでいる Azure Database for PostgreSQL のチームに参加して以来、Postgres の接続スケーラビリティに関するいくつかの問題を分析し対処するのに多くの時間を費やしてきました。
<p”>この記事では、Postgres の大量の接続の取り扱いを改善することが重要だと思う理由を説明します。続いて、Postgres の接続スケーラビリティに対するさまざまな制限面の分析を行います。
今後の投稿では、Postgres での接続処理とスナップショットのスケーラビリティを向上させるために行った作業の結果を示し、特定された問題と Postgres 14 での対処方法について詳しく説明します。
- なぜPostgresの接続スケーラビリティが重要なのか
- 接続スケーラビリティの問題を調査する
- メモリの使用量
- 定常的な接続のオーバーヘッド
- キャッシュの肥大化
- クエリのメモリ使用量
- スナップショットのスケーラビリティ
- 接続モデルとコンテキストスイッチ
- 結論: Postgresのスナップショットのスケーラビリティの改善から始める
なぜPostgreSQLの接続スケーラビリティが重要なのか
接続スケーラビリティに関する問題は、Postgres に慣れていないことや、壊れたアプリケーション、または同じ流れの中にある他の問題によって引き起こされる場合があります。既に述べたように、アプリケーションによっては、何の問題にも遭遇することなく、数千の接続を確立することができます。
Postgres の大量の接続の処理を改善する要求に対するよくある反論は、何も対処しなくて良い、という主張です。大量の接続を処理する要件/必要性がそもそも間違っており、壊れたアプリケーションや類似したものによって引き起こされるというものです。多くの場合、CPU コアの数が限られているサーバーへの参照が伴います。
大量の接続を避けるのが最善の方法である場合もありますが、Postgresでそれらが必要な理由は明白だと私は考えています。主なものは次のとおりです:
- 集約された状態と負荷の急増は大量の接続を必要とする: 一般にデータベースはアプリケーションと状態を共有します(非永続キャッシュ サービスは除きます)。新しいデータベース接続の確立コスト (TLS、レイテンシー、Postgres のコストといった順) を考えると明白なのですが、アプリケーションは、受信要求に伴う小さなスパイクを処理するのに十分な大きさの Postgres 接続のプールを維持する必要があります。多くの場合、1 つに集約されたデータベースを使用する、(ウェブ)アプリケーション コードを実行している多数のサーバーがあります。
この問題には、PgBouncer や最近の Odyssey のような Postgres 接続プールを使用して対処することができます。データベース サーバーへの接続数を実際に減らすには、このようなプールをトランザクション (またはステートメント) プーリング モードで使用する必要があります。ただし、これを行うと、準備済みステートメント、一時テーブル…のような多くのデータベースの便利な機能を除外することになります。
- レイテンシーと結果の処理時間はアイドル接続を生み出します: ネットワークのレイテンシーとアプリケーションの処理時間は、アプリケーションが可能な限り高速にデータベース要求を発行している場合でも、ほとんどの場合、個々のデータベース接続がアイドル状態になります。
一般的な OLTP データベースワークロード、特にウェブアプリケーションは、読み取りに大きく偏っています。また、OLTP ワークロードでは、SQL クエリの大半は、アプリケーションとデータベース間のネットワークレイテンシーを十分に下回る時間で処理が可能なぐらい単純です。
さらに、アプリケーションは、送信したデータベース クエリの結果を処理する必要があります。多くの場合、相当な作業(テンプレート処理、キャッシュサーバーとの通信など)が必要です。
このことを理解するために、ここでは pgbench(Postgres の一部である簡単なベンチマークプログラム)を使用した簡単な実験をします。メモリ常駐型の読み取り専用の pgbench ワークロード (私のワークステーション1、20/40 CPU コア/スレッドで実行) では、遅延無しの pgbench とシミュレートされた遅延を伴う pgbench の間でクライアント数が増加していく際のスループットを比較しています。シミュレーションとして、1msのネットワーク遅延と1msの処理遅延を使用しました。遅延無しの pgbench では約48クライアントがピークとなり、遅延を伴う pgbench では約3000接続を実行します。ホスト上の TCP 接続と物理的に近い2台のホスト間の10GBe との比較でも、ピークは約 48 接続から 500 接続に近づきます。
- より多くの接続を許可するようにスケールアウトするとコストが増加する可能性がある: アプリケーションのワークロードを多数の Postgres インスタンスに分散できる場合でも、待機時間と接続制限の影響でデータベース サーバーの使用率が低くなる場合が多く、必要な接続数を処理するデータベース サーバーの数を増やす理由になり得ます。これは、運用コストを大幅に増加させる可能性があります。
接続スケーラビリティの問題を調査する
このプロジェクトを始める私の目標は、Postgresが大量の接続を処理する能力を向上させることでした。そのためには、つまり解決すべき問題を選ぶには、まずどの問題が最も重要なのかを理解する必要がありました。そうではなく、実際のワークロードを改善せずに、部分的な最適化で終わるのは簡単なことでしたが。
そこで、私の最初のソフトウェアエンジニアリングタスクは、Postgresの接続スケーラビリティの制限のさまざまな側面を調査することでした:
Postgres の接続スケーラビリティの制限についての詳細な調査の完了までに、スナップショットのスケーラビリティを最初に解決する必要があると結論付けた理由を理解してください。
メモリの使用量
大量の接続によるメモリの使用量に関する問題には、主に3つの側面があります:
- 定常的な接続のオーバーヘッド、確立した接続が使っているメモリの総量
- キャッシュの肥大化、大量のデータベースオブジェクトによるメモリ使用量の増大
- クエリのメモリ使用量、クエリ実行そのものによって使われるメモリ
定常的な接続のオーバーヘッド
Postgresは、多くの人が知っているように、プロセスベースの接続モデルを使用します。新しい接続が確立されると、Postgres のスーパーバイザ プロセスは、その接続を処理するための専用プロセスを作成します。スレッドの使用に対する「本格的なプロセス」の使用には、分離/堅牢性の向上などの利点がありますが、いくつかの欠点もあります。
よくある不満の 1 つは、各接続がメモリを使用しすぎるということです。すなわち、追加の接続によるメモリ使用量の増加を測定することは驚くほど困難であるため、少なくとも部分的には、一般的な観察しかできない、ということです。
Postgres 接続のメモリ オーバーヘッドの測定に関する最近の記事では、メモリのオーバーヘッドを正確に測定するのは驚くほど難しいことを示しています。また、多くのワークロードにおいて、かつ適切な構成を使用しても – 最も重要なことに huge_pages を使用しても – 接続毎のメモリ オーバーヘッドは 2 MiB未満に過ぎないことです。
結論: 接続によるメモリのオーバーヘッドは許容可能
各接続に数 MiB のオーバーヘッドしかない場合、確立された接続が数千に及ぶ可能性は十分にあります。メモリ使用量を減らすのは明らかに良いことですが、メモリは接続のスケーラビリティに関する主要な問題ではありません。
キャッシュの肥大化
メモリに関連する接続スケーラビリティの問題のもう 1 つの重要な側面は、時間の経過とともに、長期間存在するリソースが原因で接続のメモリ使用量が増加することです。これは、スキーマ ベースのマルチテナント機能と組み合わせた長時間の接続を使用するワークロードで特に問題になります。
アプリケーションが何らかの形の接続 <-> テナント連携を実装しない限り、時間の経過と共に、各接続は、すべてのテナントのすべてのリレーションにアクセスします。これは、現在(バージョン13の時点で)、Postgresがめったにアクセスされないコンテンツのメタデータキャッシュを取り除かないため、Postgresの内部カタログメタデータキャッシュが妥当なサイズを超えて大きくなる可能性があります。
問題の図解
キャッシュの肥大化の問題を示すために、私はいくつかの列と単一のプライマリシリアル列インデックス2を持つ100kテーブルを備えたシンプルなテストベッドを作成しました。作成にはちょっと時間がかかります。
最近追加されたpg_backend_memory_contextsビューでは、さまざまなキャッシュの集計メモリ使用量を見ることはそれほど難しくはありません(ただし、さまざまな種類のキャッシュが独自のメモリコンテキストに分割されているのを見ることをお勧めします)。3を参照してください。
新規の Postgres の接続では、メモリはあまり使われません:
name | parent | size_bytes | size_human | num_contexts |
CacheMemoryContext | TopMemoryContext | 524288 | 512 kB | 1 |
index info | CacheMemoryContext | 149504 | 146 kB | 80 |
relation rules | CacheMemoryContext | 8192 | 8192 bytes | 1 |
しかし作成したばかりの全ての Postgres のテーブルにアクセスすると4、全く違った様相を見せます:
name | parent | size_bytes | size_human | num_contexts |
CacheMemoryContext | TopMemoryContext | 621805848 | 593 MB | 1 |
index info | CacheMemoryContext | 102560768 | 98 MB | 100084 |
relation rules | CacheMemoryContext | 8192 | 8192 bytes | 1 |
インデックスのメタデータ キャッシュは独自のメモリ コンテキストで作成され、”インデックス情報” コンテキストのnum_contextsは、100k テーブル (および一部のシステム内部テーブル) にアクセスしたことを示しています。
結論: キャッシュの肥大化は現時点では大きな問題ではない
キャッシュの肥大化の問題の一般的な解決策は、一定の時間が経過した後にアプリケーション接続プールから「古い」接続を削除することです。多くの接続プールライブラリ/Webフレームワークはその機能をサポートしています。
実行可能な回避策があり、キャッシュの肥大化はオブジェクトが多いデータベースの問題に過ぎないため、キャッシュの肥大化は現時点では大きな問題ではありません(ただし、明らかに改善に値します)。
クエリのメモリ使用量
3 つ目の側面は、クエリで使用されるメモリを制限するのが難しい点です。work_mem設定では、クエリ全体で使用されるメモリは制御されませんが、クエリの個々の部分 (ソート、ハッシュ集計、ハッシュ結合など) のみを制御します。つまり、クエリが何度か work_mem に達するメモリを要求して終わることがある、ことを示しています5。
つまり、多くの接続を必要とするワークロードの work_mem は慎重に設定する必要があります。分析ワークロードに実質的に必要な大きい work_mem 設定では、膨大な数の同時接続を合理的に使用することは不可能で、メモリ枯渇に関連する問題(エラーや OOM キラーなど)には決して当たらないという期待が生じます。
さいわい、多くの接続を必要とするほとんどのワークロードは、高いwork_mem設定を必要とせず、ユーザー、データベース、接続、およびトランザクションのレベルで設定できます。
スナップショットのスケーラビリティ
大きな値が問題を引き起こす可能性があるので、Postgresの max_connections を大きな値に設定しないことを強く推奨する推奨事項がたくさんあります。実際、私は何度も自分自身で主張してきました。
しかし、それは真実の半分に過ぎません。
max_connectionsを非常に大きな値に設定するだけで、最良の場合 (最悪?)で、それ自体が非常に小さな性能劣化につながり、メモリを無駄にします。例えばワークステーション1では、max_connections=100と極端なmax_connections=100000(この場合は同じ pgbench の48クライアント)の値の間で、読み取り専用 pgbench のパフォーマンスの違いは測定できません。しかし、Postgresに必要なメモリの増加は、このような極端な設定で測定可能になります。 shared_buffers=16GB max_connections=100 では 16804 MiB を消費し、max_connections=100000 では 21463 MiB の共有メモリを消費します。これは、間接的に性能劣化を引き起こす可能性があるほど大きな差です(ただし、そのメモリのほとんどは使用されませんが、いずれの構成でもOSは割り当てません)。
実際の問題は、ほぼすべての接続がアイドル状態であっても、現在のPostgresは確立された接続の数が多いことに応じて、うまくスケールしないということです。
このことを説明するために、私は2つの別々のpgbench6を実行しました。そのうちの 1 つは、完全にアイドル状態の接続を確立するだけです (クライアント側のスリープを発生させる \sleep 1s を含むテスト ファイルを使用)。そして通常の pgbench 読み取り専用ワークロードを実行するもう 1 つです。
これは、通常、アイドル接続のセットが時間の経過とともに変化し問題がかなり悪化するため、この問題の最悪のバージョンを再現する方法とは程遠いものです。しかし、このバージョンなら再現がはるかに簡単です。
これは、追加接続のコストを十二分に分離できるため、テストするには非常に便利なシナリオです。特にアクティブな接続数が少ない場合、システムの CPU 使用率はかなり低くなります。アイドル接続の数が増えると速度が低下する場合は、アイドル接続の数に明確に相関します。
代わりに、アクティブな接続数が多いスループットを測定した場合、コンテキストスイッチの増加や CPU サイクルの不足が性能劣化の原因であるかどうかを特定するのは難しくなります。
これらの結果7は、アイドル接続数が増加すると、アクティブな接続の達成可能なスループットが大幅に低下することを明確に示しています。
実際には、”アイドル” 接続は完全にアイドル状態ではなく、より低いレートでクエリを送信します。クライアントがたまにしかクエリを送信しないことをシミュレートするには以下を使いました:
\sleep 100ms
SELECT 1;
結果8は、少しだけ現実的なシナリオにより、アクティブな接続がさらに遅くなることを示しています。
原因
これらの結果を組み合わせることで、CPU/メモリが豊富な場合でも、多くの接続を処理する重大な問題があることを非常に明確に示しています。同時アイドル接続が原因で 1 つのアクティブな接続が 2 倍以上に速度低下するという事実は、非常に明確な問題を指しています。
CPU プロファイルは、Postgres に責任の一端があることを素早く特定します:
明確なことですが、ボトルネックは完全にGetSnapshotData() 関数にあります。この関数は、トランザクション分離を読み取り処理に提供するために必要な作業の大部分を実行します。GetSnapshotData() はいわゆる「スナップショット」を作成し、スナップショットは同時実行トランザクションの影響のいずれがトランザクションに対して見えて、いずれが見えないかを記述します。これらのスナップショットは非常に頻繁に作成されます (トランザクションごとに少なくとも 1 回、非常に一般的に多くの場合)。
実装を知らなくても、そのようなタスクがより高価になるほど、より多くの接続/トランザクションを処理する必要があることは、直感的な意味を持ちます(少なくとも私はそう思いますが、私はそれが何をするのかも知っています)。
Brandurによる2つのブログ記事は、これを取り巻く仕組みと問題をより詳細に説明しています。
- How Postgres Makes Transactions Atomic
- How to Manage Connections Efficiently in Postgres, or Any Database
結論: スナップショットのスケーラビリティは大きな制限
接続数が多いと、アイドル状態の場合でも、他の接続の効率が明らかに低下します (上で説明したように、非常に一般的です)。同時接続数を減らし、クエリを発行する回数を減らすことを除くと、スナップショットのスケーラビリティの問題に対する実際の回避策はありません。
接続モデルとコンテキストスイッチ
前述のように、Postgres は1接続ごとに 1 つのプロセスモデルを使用します。これは多くの場合に適していますが、10~100数千もの接続に対処する際には制限要因です。
バックエンドプロセスがクエリを受信するたびに、カーネルはそのプロセスにコンテキストスイッチを実行する必要があります。それは安くはありません。しかし、さらに重要なのは、クエリの結果が計算されると、バックエンドは一定の時間アイドル状態になります。つまり、高負荷のサーバーでは別のプロセス/バックエンド/接続をスケジュールする必要があります(プロセス間のコンテキストスイッチは、プロセスとカーネルの同じプロセスを行うよりもコストがかかります。例えば、syscallの一部)。
1接続ごとに1スレッドというモデルへ切り替えても、この問題に対して意味のある対応にはならないことに注意してください:コンテキストスイッチのいくつかはより安くなりますが、それでもコンテキストスイッチは主要な制限です。スレッドに切り替える検討をする理由はありますが、接続スケーラビリティそのものは重要ではありません(アーキテクチャの変更を追加せず、スレッドを使用する方が簡単なものもあります)。
膨大な数の接続を処理するには、異なるタイプの接続モデルが必要です。接続ごとのプロセス/スレッドモデルを使用する代わりに、固定/制限された数のプロセス/スレッドがすべての接続を処理する必要があります。一度に多くの接続で受信クエリを待機し、OS の CPU スケジューラによって中断されることなく、多くのクエリを処理することで、効率を大幅に向上させることができます。
これは私の素晴らしい洞察力ではありません。このようなアーキテクチャは広く使用されており、広く議論されています。例えば、1999年に作られたC10k問題を参照してください。
コンテキストスイッチ以外にも、多くのパフォーマンス上の利点を得ることができます。たとえば、コア数の多いマシンでは、特定のプロセス/スレッドやメモリの領域を特定のCPUコアにバインドすることで共有メモリの局所性を高めることによって、パフォーマンスを大きな向上を得ることができます。
しかし、このような異なる種類の接続モデルをサポートするようにPostgresを変更することは大変な作業です。この方法では、プロセスと接続の間の多くの依存関係を慎重に分離するだけでなく、異なるクエリ間のユーザーランドスケジューリング、非同期 IO のサポート、クエリ実行モデルの違い (クエリごとに個別のスタックが必要にならないように) などを行う必要があります。
結論: Postgresのスナップショットのスケーラビリティの改善から始める
私の意見では、メモリの使用の問題は、他の問題が議論されているほど深刻ではありません。理由の一部は、接続のメモリオーバーヘッドは初めに目にするほど大きくはなく、Postgresのキャッシュがあまりに多くのメモリを消費する問題は合理的に回避出来るから、です。
私たちは、Postgresのメモリ使用量に関する改善を行うことができ、かつすべきで、簡単に得られる成果がいくつかあります。しかし、私は、現在のように、メモリ使用量を改善することは少なくとも基本的なレベルではなく、接続スケーラビリティに関するイメージを変えるとは思いません。
これに対し、スナップショットのスケーラビリティの問題を回避する優れた方法はありません。上述したように、確立された接続の数を大幅に減らすことは、多くの場合、実現不可能です。実際には、他の回避策はありません。
さらに、スナップショットのスケーラビリティの問題は非常にローカライズされ、それに取り組むのは非常に現実的です。基本的なパラダイムシフトは必要ありません。
最後に、接続モデルを完全に切り替えることによって、何万もの接続を処理する必要があるという側面があります。概説したように、それは巨大なプロジェクト/基本的なパラダイムシフトです。明らかに、それは取り組むべきではないことを意味するものではありません。
したがって、スナップショットのスケーラビリティの問題に最初に対処することは価値があると思われ、それ自体で大きな利点が期待されます。
しかし、スナップショットのスケーラビリティに最初に取り組む根本的な理由もあります: 例えば、メモリ使用量の問題に同時に対処する一方で、接続モデルを切り替えるqsはスナップショットの問題にまったく対処しません。接続に専用のプロセスがなくなったとしても、接続間の分離を提供する必要があるのは明らかです。
うまくいけば、今、あなたは私が最初にPostgresスナップショットのスケーラビリティに焦点を当てることを選んだ理由を理解しています。私の次のブログ記事でそれについて詳しく説明します。
- 2x xeon gold 5215, 192GiB of RAM, kernel 5.8.5, debian Sid ↩︎
- psql で 100k テーブルを作成する:
\gexec
COMMIT
COMMIT
…
- クエリキャッシュのメモリ使用量:
SELECT * FROM pg_backend_memory_contexts),
caches AS (
SELECT *
FROM contexts
WHERE name = ‘CacheMemoryContext’
SELECT contexts.*
FROM caches
JOIN contexts ON (contexts.parent = caches.name)
)
SELECT
name, parent,
sum(total_bytes) size_bytes,
pg_size_pretty(sum(total_bytes)) size_human,
count(*) AS num_contexts
FROM caches
GROUP BY name, parent
ORDER BY SUM(total_bytes) DESC;
- foo* という名前の全てのテーブルにアクセスするクエリ:
DECLARE
cnt int := 0;
v record;
BEGIN
FOR v IN SELECT * FROM pg_class WHERE relkind = ‘r’ and relname LIKE ‘foo%’ LOOP
EXECUTE format(‘SELECT count(*) FROM %s’, v.oid::regclass::text);
cnt = cnt + 1;
IF cnt % 100 = 0 THEN
COMMIT;
END IF;
END LOOP;
RAISE NOTICE ‘tables %1’, cnt;
END;$$;
- さらに悪いことに、カーソルの使用など、複数のクエリが同時に進行中である可能性があります。ただし、多くのカーソルを同時に使用することは一般的ではありません。
- これは、すべての接続が確立されるまで待機するように変更されたpgbenchを使用します。pgbench の変更がなければ、特にこの記事で説明されている修正の前に、クライアントのサブセットが接続できない場合があります。詳細については、このメーリングリストポストを参照してください。
PG Version | Idle Connections | Active Connections | TPS |
12 | 0 | 1 | 33457 |
12 | 100 | 1 | 33705 |
12 | 1000 | 1 | 30558 |
12 | 2500 | 1 | 26075 |
12 | 5000 | 1 | 23284 |
12 | 10000 | 1 | 14496 |
12 | 0 | 48 | 1032435 |
12 | 100 | 48 | 960847 |
12 | 1000 | 48 | 902109 |
12 | 2500 | 48 | 759723 |
12 | 5000 | 48 | 702680 |
12 | 10000 | 48 | 521558 |
PG Version | Less active Connections | Active Connections | TPS |
12 | 0 | 1 | 33773 |
12 | 100 | 1 | 29074 |
12 | 1000 | 1 | 25327 |
12 | 2500 | 1 | 19752 |
12 | 5000 | 1 | 9807 |
12 | 10000 | 1 | 6049 |
12 | 0 | 48 | 1040616 |
12 | 100 | 48 | 953755 |
12 | 1000 | 48 | 759366 |
12 | 2500 | 48 | 733000 |
12 | 5000 | 48 | 636057 |
12 | 10000 | 48 | 416819 |