「同時アクセスが増えたら DB が too many clients で落ちた」「Lambda を並列実行したら接続が枯渇した」——これらはすべて、PostgreSQL の接続モデルを知らずに直結したことが原因です。
PostgreSQL は接続をOSプロセスで扱います。だから接続は「安いリソース」ではなく「高い固定費」。本番、とりわけサーバーレスでは、接続プーリングが必須になります。この記事は、なぜ必要か・どのモードを選ぶか・何が壊れるか・どう直すかを、公式とベンダー資料に忠実に整理します。本番運用ガイド §5の深掘りです。
この記事のルール:PostgreSQL サーバー側の仕様は PostgreSQL 18 公式ドキュメント、PgBouncer の挙動は PgBouncer 公式ドキュメント、RDS Proxy / Supavisor はそれぞれ AWS / Supabase 公式に基づきます(出典を明記)。プールサイズの目安などコミュニティ通説はその旨を注記します。
1. なぜ接続は「高い」のか:プロセス・パー・コネクション
PostgreSQL の接続モデルを、公式は明快に述べています。
PostgreSQL は「プロセス・パー・ユーザー」のクライアント/サーバーモデルを実装している。…接続要求を検知するたびに、新しいバックエンドプロセスを生成(fork)する。
スレッドではなくプロセスです。1接続 = 1 OSプロセスで、固定的なメモリオーバーヘッドを持ち、さらに work_mem はバックエンド単位・操作単位で確保されます(総論 §4.2)。500接続あれば500プロセスがメモリを食い、CPUはコンテキストスイッチで消耗します。
max_connections の既定は 100(サーバー起動時のみ変更可)。「足りないなら上げればいい」は罠です。上げた分だけメモリ・CPUを食い潰し、ある閾値で性能が崖を落ちます。
結論:
max_connectionsを上げるのは解決策ではありません。少数の物理接続を多数のクライアントで使い回す——それが接続プーリングです。
なお max_connections のうち一部は予約されます:superuser_reserved_connections(既定 3、緊急用)、reserved_connections(既定 0、PG16+)。一般ロールが使えるのは概ね max_connections − これらの予約 です。
2. プーラーの役割:funnel(漏斗)
接続プーラーは、アプリと PostgreSQL の間に立ち、多数のクライアント接続を、少数の物理接続に集約します。
[アプリ: 数百〜数千の接続] ──▶ [プーラー: max_client_conn] ──▶ [物理接続: default_pool_size(少数)] ──▶ PostgreSQL
安い・大量でOK 入口は広く 出口は狭く絞る
PgBouncer なら、max_client_conn(クライアント側の入口、既定 100。大きくしてよい)と default_pool_size(DB/ユーザー対あたりの物理接続、既定 20。ここを絞る)で、この漏斗を設計します。
3. 3つのプールモード:session / transaction / statement
PgBouncer の核心は pool_mode——「サーバー接続をいつ他のクライアントに再利用させるか」です。公式の定義どおり。
| モード | サーバー接続が返却されるタイミング | 用途 |
|---|---|---|
| session(既定) | クライアントが切断したとき | 互換性最大・再利用最小。レガシー/セッション機能必須 |
| transaction | トランザクション終了ごと | 再利用最大。Web/サーバーレス推奨 |
| statement | クエリ終了ごと(複文トランザクション禁止) | 最アグレッシブ・autocommitのみ。ニッチ |
実務の答えは明確です。Web アプリ・サーバーレスは transaction モード。1つの物理接続を「トランザクションの間だけ」貸し出すので、小さなプールで数千クライアントを捌けます。
# pgbouncer.ini(トランザクションモードの基本形)
[databases]
appdb = host=127.0.0.1 port=5432 dbname=appdb
[pgbouncer]
pool_mode = transaction
max_client_conn = 2000 # 入口は広く
default_pool_size = 20 # 出口(物理接続)は CPU コアの小さな倍数に絞る
server_reset_query = DISCARD ALL
4. トランザクションモードの罠:セッション状態が壊れる
ここが最重要です。トランザクションモードは「同じクライアントの連続したトランザクションが別々の物理接続に乗る」ため、セッションに紐づく状態が壊れます。PgBouncer 公式が「いくつかのセッションベース機能を壊す」と明記する対象:
SET/RESET(セッション変数。SET LOCALはトランザクション内なのでOK)LISTEN/NOTIFY(接続が変わると通知を取りこぼす)- セッションアドバイザリロック(
pg_advisory_lock) - SQLレベルの
PREPARE/DEALLOCATE(サーバー側プリペアドステートメント) WITH HOLDカーソル、一時テーブル、LOAD
公式の根拠(server_reset_query の説明):
トランザクションプーリングでは、各トランザクションが別の接続に乗り、異なるセッション状態を得るため、クライアントはセッションベースの機能を使ってはならない。
しかも公式曰く、トランザクションモードでは server_reset_query(既定 DISCARD ALL)は使われません(貸出のたびにリセットされない)。だから「セッション状態に依存しない」ことがアプリ側の責任になります。
対処1:セッション機能を使わない設計に変える
| 壊れる機能 | 代替 |
|---|---|
pg_advisory_lock(セッション) | pg_advisory_xact_lock(トランザクション境界で自動解放) |
SET search_path = ...(セッション) | 接続文字列の options=-c search_path=...、または SET LOCAL |
LISTEN/NOTIFY | プーラーをバイパスした専用接続、または別のメッセージング |
SQL PREPARE | プロトコルレベルのプリペアド(下記) |
-- ❌ セッションアドバイザリロック(トランザクションモードで壊れる)
-- SELECT pg_advisory_lock(42);
-- ✅ トランザクションスコープ(コミット/ロールバックで自動解放。プーラー安全)
BEGIN;
SELECT pg_advisory_xact_lock(42);
-- ... クリティカルセクション ...
COMMIT;
対処2:プリペアドステートメントはプロトコルレベルで
PgBouncer 1.21+ は max_prepared_statements(既定 200) を非0にすると、プロトコルレベルの名前付きプリペアドステートメントをトランザクション/ステートメントモードでも追跡・書き換えして安全に扱えます。ただしSQLレベルの PREPARE/DEALLOCATE は依然壊れます。
クライアントライブラリ側の設定も必要です。
// node-postgres: トランザクションプーラー配下では「クライアント側のステートメントキャッシュ」を避け、
// パラメータ化クエリ(プロトコルレベル)に寄せる。ORM 各種はプーラー用フラグを持つ。
import { Pool } from "pg";
export const pool = new Pool({
connectionString: process.env.DATABASE_URL, // 例: PgBouncer/Supavisor のエンドポイント
max: 10, // アプリ内プールは小さく(外側のプーラーが本命)
// node-postgres はデフォルトでサーバー側プリペアドを多用しない。
// Prisma なら ?pgbouncer=true、Drizzle/postgres.js なら prepare:false 相当を検討。
});
設計の含意:トランザクションモードは「速さ」と引き換えに「ステートレスである規律」を要求します。
SET LOCAL・pg_advisory_xact_lock・パラメータ化クエリを徹底すれば、互換性問題はほぼ消えます。
5. プールサイズの決め方:クライアント数ではなくコア数
直感に反しますが、プールサイズは接続してくるクライアント数ではなく、DBサーバーのCPUコア数で決めます。CPUが同時に実行できるクエリ数には上限があり、コア数より大幅に多いプールは競合を増やすだけです。
広く使われる目安(PgBouncer/HikariCP 系の経験則。公式仕様ではなくコミュニティガイダンス):
推奨プールサイズ ≈ (コア数 × 2) + 実効スピンドル数
例:8コア・SSD(実効スピンドル≒1〜2)→ 約 18〜20 物理接続
そして鉄則:default_pool_size ×(DB・ユーザー対の数) が、サーバーの max_connections(予約分を引いた値)を超えないこと。超えると、プーラーが物理接続を開けず詰まります。
6. サーバーレスでは必須:接続ストームを防ぐ
サーバーレス(Lambda/エッジ関数)と PostgreSQL 直結はアンチパターンです。関数は水平にスケールし、同時実行ごとに新しい直結を張る。スパイクで数百〜数千の接続が一斉に立ち上がり、max_connections を突破して接続ストームを起こします。関数は短命なので接続確立コストも償却できません。
対策はトランザクションモードのプーラーを必ず挟むこと。主な選択肢:
| プーラー | 位置づけ | 出典 |
|---|---|---|
| PgBouncer | 定番。軽量・シングルスレッド。多数のアイドル接続に強い | PgBouncer 公式 |
| AWS RDS Proxy | マネージド。Lambda 向けに接続をプール・共有。IAM/Secrets 連携、フェイルオーバー対応 | AWS 公式 |
| Supabase Supavisor | マネージド。トランザクションモードはポート6543。サーバーレス推奨 | Supabase 公式 |
| PgCat / Odyssey | マルチスレッドでコア数にスケール。シャーディング/LB/フェイルオーバー内蔵 | 各OSS |
AWS RDS Proxy(公式):「アプリケーションが接続をプール・共有してスケール性を高められる」「接続の急増を捌き、プールから接続を再利用することで、毎回新規接続を開くメモリ・CPUのオーバーヘッドを避ける」。即時に貸せない接続はキュー/スロットルします。
Supabase Supavisor(公式):「サーバーレスやエッジ関数のような一時的なクライアントにはトランザクションモードを使う」。ただし「トランザクションモードはプリペアドステートメント非対応。エラーを避けるためクライアントライブラリのプリペアドを無効化する」——§4 と同じ注意です。
// サーバーレス(Lambda/Edge)の鉄則:
// 1) 直結しない → プーラーのエンドポイント(例 Supavisor:6543 / RDS Proxy)に接続
// 2) ハンドラ外でプールを生成し、ウォームインスタンス間で再利用(毎回 new しない)
// 3) プリペアドを無効化、SET LOCAL / pg_advisory_xact_lock を使う
import { Pool } from "pg";
// モジュールスコープ=コールド起動時に1度だけ。ウォーム再利用で接続確立を償却
const pool = new Pool({ connectionString: process.env.POOLER_URL, max: 2 });
export async function handler(event: { id: string }) {
const { rows } = await pool.query(
"SELECT id, status FROM orders WHERE id = $1", // パラメータ化(インジェクション対策)
[event.id],
);
return rows[0] ?? null;
// 注意: pool.end() をハンドラ末尾で呼ばない(次の呼び出しで再利用するため)
}
7. 運用:プーラーを監視・メンテする
PgBouncer は管理用の擬似DBを持ち、稼働を可視化できます(PgBouncer 公式)。
# 管理コンソール(admin_users に登録したユーザーで)
psql -p 6432 -U pgbouncer_admin pgbouncer
SHOW POOLS; -- DB/ユーザー対ごとのプール状態(待ち・使用中・アイドル)
SHOW STATS; -- トランザクション/クエリの統計
PAUSE appdb; -- メンテ用にドレイン(新規トランザクションを保留)
RESUME appdb; -- 再開
RELOAD; -- 設定の再読み込み(無停止)
SHOW POOLS の cl_waiting(待機クライアント)が常に多いなら、default_pool_size が小さすぎる(またはDBが遅い)サイン。SHUTDOWN WAIT_FOR_CLIENTS と so_reuseport を併用すれば、無停止ローリング再起動も可能です。
PostgreSQL 18 での変化:接続モデル・予約スロットの仕様は 16→18 で変更なし。接続プーリングの設計は安定した知識として通用します。
8. まとめ
- PostgreSQL は1接続=1プロセス。接続は高い固定費で、
max_connectionsを上げるのは解決策ではない。 - プーラーで漏斗を作る:入口(
max_client_conn)は広く、出口(default_pool_size)は絞る。 - Web/サーバーレスは transaction モード。ただしセッション状態が壊れる——
SET LOCAL・pg_advisory_xact_lock・パラメータ化・プリペアド無効化で回避。 - プールサイズはコア数基準(クライアント数ではない)。
pool_size × 対数 < max_connections。 - サーバーレスはプーラー必須(PgBouncer / RDS Proxy / Supavisor)。直結は接続ストームで死ぬ。
接続設計は「速さ」より先に「落ちないこと」を決めるレイヤーです。次は、その上で「戻せること」を保証する バックアップ&PITR へ。