Skip to content

필사 모드: コネクションプール完全ガイド: プーリングモードがアプリケーションと結ぶ契約

日本語
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

はじめに

このブログにはすでにコネクションプールを大きくすると損をする理由 — 待ち行列をどこに立てるかがあります。プールサイズをなぜコア数の近くに取るべきか、待ち行列をどこに立てるべきかを扱うサイズ設計編です。

この記事はその次の問いを扱います。サイズを決めたなら、そのコネクションをどのような方式で再利用するのか。セッションプーリング、トランザクションプーリング、文プーリングはそれぞれ異なる契約です。トランザクションプーリングに切り替えるとコネクションの効率は劇的に良くなりますが、その代償としてアプリケーションが使えなくなる機能が生まれます。そのリストを知らないままモードを切り替えて起きる事故が現場では最も多いパターンです。SET search_pathが次のリクエストに漏れ出したり、セッションレベルのアドバイザリーロックが永遠に解放されなかったり、プリペアド文が「does not exist」エラーを出したりします。

基準はPostgreSQL 18PgBouncerであり、引用したデフォルト値はそれぞれの公式ドキュメントで確認しました。

1. 一つのコネクションが実際に何を消費するのか

PostgreSQLは接続ごとにオペレーティングシステムのプロセスを一つ立ち上げます。スレッドではなくプロセスです。この構造がコネクションプールの議論の出発点です。

コストは三つの層で発生します。

プロセス自体のメモリです。バックエンドプロセスごとにキャッシュと作業領域が付きます。カタログキャッシュと実行計画キャッシュは、そのコネクションが触れたオブジェクトの数に比例して大きくなります。パーティションの多いテーブルに複数のセッションが触れると、この部分が積み重なります。

作業メモリですwork_memのデフォルト値は4MBですが、この値はコネクション単位ではなくソートやハッシュ演算一つあたりに適用されます。一つのクエリにソートが三つとハッシュジョインが二つ含まれていれば、そのクエリ一つで五倍を使います。ハッシュ系はhash_mem_multiplier(デフォルト値2.0)までさらに掛かります。コネクション数かけるwork_memで最悪値を計算するよくある方法は、実際には過小評価です。

共有資源のサイズです。ドキュメントは明確に書いています。「PostgreSQLは特定のリソースをmax_connectionsの値に直接基づいてサイズを決める。この値を増やすと、共有メモリを含む該当リソースの割り当てが増える」。max_connectionsのデフォルト値は、ドキュメントの表現では「一般的に100個」であり、カーネル設定によってはさらに少ないこともあります。

そして最大のコストはメモリではありません。コンテキストスイッチとロック競合です。コアが16個のサーバーにアクティブなコネクション500個を接続すると、CPUは実際の作業よりもプロセスの切り替えに時間を使います。これが前の記事が「プールを大きく取ると損をする」と述べた理由です。

2. 三重の予算 — 三つの数字を一緒に決める

実戦の構成では、コネクションの数字が最低でも三か所にあります。この三つを別々に決めると、必ずずれます。

[アプリケーションインスタンスN個]
   各インスタンスのコネクションプールサイズ = A
        ↓  最大 N × A 個のクライアントコネクション
[PgBouncer]
   max_client_conn (受け入れられるクライアント数)
   default_pool_size (DB/ユーザーの組ごとに実際に開くサーバーコネクション数)
        ↓  最大 (プール数 × default_pool_size) 個のサーバーコネクション
[PostgreSQL]
   max_connections

三つの層の関係をルールとしてまとめるとこうなります。

ルール1 — PgBouncerのmax_client_connは、アプリケーションが作りうる最大コネクション数より大きくなければなりません。PgBouncerのドキュメント基準でmax_client_connのデフォルト値は100です。インスタンス20個がそれぞれプール10を使うと200必要になりますが、デフォルト値は100なので、デフォルトのままにしておくとアプリケーションはコネクション拒否を受けます。

ルール2 — サーバーコネクションの合計はmax_connectionsより確実に小さくなければなりません。PgBouncerのdefault_pool_sizeのデフォルト値は20で、この値はデータベースとユーザーの組ごとに適用されます。データベース3個にユーザー4人なら、最悪の場合12個のプール×20=240個のサーバーコネクションが開く可能性があります。max_db_connections(デフォルト値0、無制限)でデータベース単位の上限をかけておくのが安全です。

ルール3 — max_connectionsには余裕を残しますsuperuser_reserved_connectionsのデフォルト値は3で、reserved_connectionsのデフォルト値は0です。障害の状況で管理者が接続できる席を残しておくのが、この予約分の目的です。モニタリングエージェント、バックアップツール、マイグレーションランナーの分も別途計算しておいてください。

3. 三つのプーリングモード

PgBouncerのドキュメントが定義する三つのモードです。引用した文章がそのまま契約です。

  • session — 「クライアントが接続を切った後、サーバーがプールに返却される。デフォルト」。つまりクライアント一つがコネクション一つを丸ごと占有します。アプリケーションの立場からはPgBouncerが存在しないのと同じように動作するので、何も壊れません。その代わりコネクション節約の効果はほとんどありません。
  • transaction — 「トランザクションが終わった後、サーバーがプールに返却される」。実戦で最も多く使われるモードです。アイドル時間が長いウェブアプリケーションで、コネクション数を一桁分減らしてくれます。
  • statement — 「クエリが終わった後、サーバーがプールに返却される。このモードでは複数の文にまたがるトランザクションが禁止される」。複数文トランザクションがそもそも不可能なので、用途はかなり限られます。

デフォルト値がsessionであるという事実を見落とすケースが多くあります。PgBouncerを前段に立てているのにコネクション数がそのままなら、モードを確認してみてください。

モードを選ぶ基準は単純です。トランザクションの外でセッション状態に依存するコードが一つもなければtransactionを使います。一つでもあれば、そのコードを直すかsessionにとどまらなければなりません。次の節がそのリストです。

4. トランザクションプーリングで壊れるもの

PgBouncerのドキュメントの説明はこうです。トランザクションプーリングモードでは、「クライアントはセッションベースの機能を使用してはならない。各トランザクションが互いに異なるコネクションで終わるため、セッション状態が変わってしまうからだ」。

具体的に壊れるものです。

SET / RESET。トランザクションの外で実行したSET search_pathSET timezoneSET statement_timeoutは、そのサーバーコネクションに残ります。次のトランザクションは別のコネクションに割り当てられることがあるため設定が消えたように見え、逆にそのコネクションを次に受け取った別のクライアントには設定が漏れ出します。後者のほうがはるかに危険です。マルチテナントアプリケーションでsearch_pathによってスキーマを切り替える設計は、トランザクションプーリングとは決して併用できません。トランザクションの中でSET LOCALを使えば安全です。トランザクションの終了とともに元に戻るためです。

-- 安全: トランザクション境界の内側でのみ有効
BEGIN;
SET LOCAL statement_timeout = '5s';
SELECT ...;
COMMIT;

LISTEN / NOTIFYLISTENはセッションに登録される状態です。トランザクションが終わるとコネクションが返却されるため、通知を受け取る方法がありません。通知ベースの構造が必要なら、そのコネクションだけを別のsessionモードのプールに分離しなければなりません。

セッションレベルのアドバイザリーロック。最も危険な項目です。PostgreSQLのドキュメントによれば、セッションレベルのアドバイザリーロックは「明示的に解放されるかセッションが終了するまで保持され、トランザクションのロールバックにも生き残る」とされています。トランザクションプーリングでは、ロックを取得したコネクションとロックを解放するコネクションが異なることがあり、そうなるとロックは永遠に解放されません。必ずトランザクションレベルの関数(pg_advisory_xact_lockpg_try_advisory_xact_lock)だけを使ってください。これらの関数はトランザクション終了時に自動的に解放されます。

WITH HOLDカーソル。トランザクション終了後も生き残るカーソルですが、コネクションが返却されるためアクセスできません。

一時テーブル。セッションに属するため、次のトランザクションでは消えたように見えます。一つのトランザクションの中で作り、使い、捨てるパターン(ON COMMIT DROP)だけが安全です。

5. プリペアド文の問題

最も頻繁にぶつかる項目なので、独立した節で扱います。

ほとんどのドライバはパラメータバインディングのためにプリペアド文を使います。プリペアド文はサーバーコネクションに名前で登録されるため、トランザクションプーリングで別のコネクションに割り当てられるとprepared statement "S_1" does not existエラーが出ます。

PgBouncerはmax_prepared_statementsでこの問題を緩和します。ドキュメント基準でデフォルト値は200であり、トランザクションおよび文プーリングモードでプロトコルレベルでプリペアド文を追跡してくれます。PgBouncerが各サーバーコネクションに必要な準備を代わりに実行する仕組みです。

それでも安全な組み合わせを確認する手順はこうです。

  1. PgBouncerのバージョンがプリペアド文の追跡をサポートしているか確認します。この機能は特定のバージョンで導入されたので、使用中のバージョンのドキュメントで確認してください
  2. max_prepared_statementsが0でないか確認します。
  3. ドライバ側の設定を確認します。サーバーサイドの準備をオフにするオプションを持つドライバは多くあります。オフにすれば確実に安全ですが、実行計画再利用の利点を失います。

ここにつながる性能問題がもう一つあります。プリペアド文を使うとPostgreSQLが汎用計画(generic plan)を選ぶ可能性があり、値の分布が偏った列ではこれが災いになることがあります。この挙動はplan_cache_modeが制御し、許容される値はauto(デフォルト)、force_custom_planforce_generic_planです。プリペアド文を有効にすると決めたなら、このパラメータの存在も一緒に知っておく必要があります。

6. タイムアウトの地形図 — どの層に何を設定するか

一つのリクエストが通る経路ごとにタイムアウトがあり、これらが互いに矛盾すると診断が不可能になります。

アプリケーションプール層。プールからコネクションを得るまで待つ時間の上限です。この値がないと、データベースが遅くなったときにアプリケーションのスレッドが全部待機状態で積み重なります。

PgBouncer層query_wait_timeoutのデフォルト値は120秒です。クライアントがプールからサーバーコネクションの割り当てを受けるまで待つ時間です。server_idle_timeoutのデフォルト値は600秒で、長くアイドルしているサーバーコネクションを閉じます。

PostgreSQL層。三つのタイムアウトがあり、三つともデフォルト値は0、つまり無効です。statement_timeoutは指定時間を超える文を中断し、lock_timeoutはロック待機が指定時間を超えると中断し、idle_in_transaction_session_timeoutはトランザクションを開いたまま遊んでいるセッションを終了させます。PostgreSQL 17からはトランザクション全体の時間を制限するtransaction_timeoutもあり、デフォルト値はやはり0です。

配置順序の原則は、外側が内側より長くなければならないということです。アプリケーションのタイムアウトがstatement_timeoutより短いと、アプリケーションは諦めているのにサーバーではクエリが動き続けて資源を使います。逆に配置すればサーバーが先に切ってくれるので、資源が回収されます。

-- サービスアカウントとバッチアカウントに異なる予算を与える
ALTER ROLE app_web SET statement_timeout = '10s';
ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s';
ALTER ROLE app_web SET lock_timeout = '3s';

ALTER ROLE app_batch SET statement_timeout = '30min';
ALTER ROLE app_batch SET idle_in_transaction_session_timeout = '5min';

7. 観測 — どこに列ができているか

遅くなったときに最初に問うべき質問は「どの層に待ち行列があるか」です。層ごとに見る指標が異なります。

アプリケーションプール。プールの待ち時間とアクティブなコネクション数。待ち時間が伸びているのにデータベースは暇なら、プールが小さすぎるということです。

PgBouncer。管理コンソールに接続してSHOW POOLSSHOW STATSを見ます。cl_waiting(サーバーの割り当てを待つクライアント数)が持続的に0より大きければ、default_pool_sizeが不足しているかサーバーが遅いということです。

PostgreSQLpg_stat_activityで状態と待機イベントを見ます。

-- 状態別のコネクション分布: idle in transactionが多ければアプリケーション側の問題
SELECT state, count(*), max(now() - state_change) AS longest
FROM pg_stat_activity
WHERE backend_type = 'client backend'
GROUP BY state
ORDER BY count(*) DESC;
-- 何を待っているか
SELECT wait_event_type, wait_event, count(*)
FROM pg_stat_activity
WHERE wait_event IS NOT NULL AND backend_type = 'client backend'
GROUP BY 1, 2
ORDER BY 3 DESC;

stateが取りうる値はドキュメントに定義されています。activeidleidle in transactionidle in transaction (aborted)fastpath function callstartingdisabledです。idle in transactionが多いなら、コネクションプールの問題ではなくアプリケーションがトランザクションを開いたまま他のことをしているという意味です。プールを大きくしても解決せず、むしろ悪化します。

wait_event_typeの値はLockLWLockBufferPinIOIPCClientTimeoutActivityExtensionです。Clientが多ければサーバーではなくクライアントを待っている最中なので、データベースはボトルネックではありません。

8. ワークロードを分けるプールの分離

一つのプールにすべてのワークロードを混ぜると、最も遅いクエリが全体を止めます。実戦ではプールを性格ごとに分けます。

OLTPプール — 短く頻繁なリクエスト。トランザクションプーリング、小さいdefault_pool_size、短いstatement_timeout

バッチ/レポートプール — 長く稀なリクエスト。別のデータベースユーザーに分離してstatement_timeoutを長く与え、プールサイズは小さく保ちます。レポートはリードレプリカに送るほうが良いです。

セッション状態が必要なプール — LISTEN/NOTIFYやセッションアドバイザリーロックが必要な少数のコネクション。sessionモードのプールとして別に置きます。

マイグレーション/管理プール — DDLを実行する経路。トランザクションプーリングではCREATE INDEX CONCURRENTLYのようなコマンドはトランザクションブロックの外で実行されなければならないため、別扱いが必要です。

分離の実質的な利点は分離性です。レポートクエリが暴走してもOLTPプールのサーバーコネクションはそのまま残ります。同じ理由でサーキットブレーカーもプール単位でかけるのが正しいやり方です。

一つ付け加えます。PgBouncer自体は単一プロセスで動作するため、PgBouncerがCPUを1コア使い切ると、それがボトルネックになります。スループットの大きい環境ではPgBouncerを複数インスタンスで立てるか、アプリケーションノードごとにサイドカーとして配置する構成を検討してください。マルチプロセス対応の有無と設定は、使用中のバージョンのドキュメントで確認する必要があります。

クイズ: 実力を確認してみましょう

クイズ1: トランザクションプーリングに切り替えた後、マルチテナントアプリケーションで時々別のテナントのデータが見えます。なぜでしょうか。

正解: SET search_pathをトランザクションの外で実行しているためです。

解説: トランザクションプーリングでは、トランザクションが終わるとサーバーコネクションがプールに返却されます。トランザクションの外で実行したSETはそのサーバーコネクションに残り、そのコネクションを次に受け取った別のクライアントがその設定を引き継ぎます。スキーマベースのテナント分離では、これはそのままデータ漏洩です。対応は二つです。SET LOCALを使ってトランザクション境界の内側だけで有効にするか、スキーマ切り替えの代わりにtenant_id列と行レベルセキュリティに設計を変えることです。PgBouncerのドキュメント自体が、トランザクションプーリングでは「クライアントはセッションベースの機能を使用してはならない」と明記しています。

クイズ2: 夜間バッチがpg_advisory_lockで重複実行を防いでいますが、トランザクションプーリング導入後、2回目の実行からロックを永遠に獲得できません。

正解: セッションレベルのアドバイザリーロックが解放されずに残ったためです。

解説: PostgreSQLのドキュメントによれば、セッションレベルのアドバイザリーロックは明示的に解放されるかセッションが終わるまで保持され、トランザクションのロールバックにも生き残ります。トランザクションプーリングでは、pg_advisory_lock()を呼び出したトランザクションが終わった瞬間にコネクションが返却され、pg_advisory_unlock()は別のコネクションで実行される可能性があります。そうなるとロックは元のコネクションに残ったまま誰も解放できません。PgBouncerがそのサーバーコネクションを閉じるまで続きます。解決策はトランザクションレベルの関数に切り替えることです。

BEGIN;
SELECT pg_try_advisory_xact_lock(hashtext('nightly-batch'));
-- 作業を実行
COMMIT;  -- ここでロックが自動的に解放される
クイズ3: PgBouncerを前段に立てたのに、PostgreSQLのコネクション数がまったく減りませんでした。

正解: pool_modeがデフォルト値のsessionのまま残っている可能性が高いです。

解説: PgBouncerのドキュメント基準でpool_modeのデフォルト値はsessionであり、このモードでは「クライアントが接続を切った後にだけサーバーがプールに返却」されます。コネクションを長く保持するアプリケーションプールの前段に立てると、節約効果は事実上ゼロです。確認は管理コンソールでSHOW CONFIGによって現在のpool_modeを見ることであり、変更する前に4節のリスト(SET、LISTEN/NOTIFY、セッションアドバイザリーロック、WITH HOLDカーソル、一時テーブル、プリペアド文)をアプリケーションが使っているかどうかを先に点検しなければなりません。

クイズ4: トラフィックが増えてアプリケーションインスタンスを10個から40個に増やしたところ、コネクション拒否が発生しました。どこを見るべきでしょうか。

正解: 三つの層の数字を一緒に見る必要があります。特にPgBouncerのmax_client_connです。

解説: PgBouncerのドキュメント基準でmax_client_connのデフォルト値は100です。インスタンス40個がそれぞれプール10を使うと最大400個のクライアントコネクションが必要になるため、デフォルト値では不足します。同時にサーバー側も確認しなければなりません。default_pool_sizeのデフォルト値は20で、データベースとユーザーの組ごとに適用されるため、プールが複数あればサーバーコネクションの合計がmax_connections(デフォルト値は一般的に100)を超える可能性があります。正しい調整は、max_client_connは大きく(クライアントを受け入れる敷居)、default_pool_sizeは小さく(サーバーを守る敷居)取ることです。待ち行列はPgBouncerに立て、データベースには立てないという原則です。

クイズ5: pg_stat_activityを見るとidle in transaction状態のコネクションが60個あります。プールを大きくすれば解決するでしょうか。

正解: いいえ。悪化します。

解説: idle in transactionはトランザクションを開いたままクライアントの次の命令を待っている状態です。つまりデータベースは暇なのに、アプリケーションが他のことをしているという意味です。原因は大抵、トランザクションの中で外部APIを呼び出しているか、ORMがリクエスト開始時点でトランザクションを開いて応答の直前まで維持する設計です。この状態のコネクションは場所を占めるだけでなく、VACUUMが死んだ行を回収できないようにも妨げます。プールを大きくすると、こうしたコネクションが増えるだけです。対応はトランザクション境界を狭めるコード修正で、防衛線はidle_in_transaction_session_timeout設定です。この値のデフォルトは0で無効なので、デフォルト設定では何の保護もありません。

おわりに

コネクションプールは性能のための道具である前に契約です。セッションプーリングは「何も変えない」という契約であり、トランザクションプーリングは「セッション状態を諦める代償にコネクションを節約する」という契約です。契約条項を読まずにサインすれば、必ず代償を払うことになります。

導入の順序をまとめるとこうです。まずアプリケーションがセッション状態に依存している箇所を全部見つけ出します(SETLISTEN、セッションアドバイザリーロック、一時テーブル、プリペアド文)。それらをトランザクションの中に移すか、別のプールに分離します。次にトランザクションプーリングに切り替え、三つの層の数字を一緒に決め、タイムアウトを外側から内側へ短くなるように配置します。最後にcl_waitingidle in transactionをダッシュボードに載せておきます。

コネクションの挙動を自分で実験してみたいなら、PostgreSQLプレイグラウンドを活用してください。

参考資料

続けて読む

현재 단락 (1/114)

このブログにはすでに[コネクションプールを大きくすると損をする理由 — 待ち行列をどこに立てるか](/blog/database/2026-07-26-connection-pool-sizing)が...

작성 글자: 0원문 글자: 11,653작성 단락: 0/114