# PostgreSQL 接続プーリング実践（PgBouncer / RDS Proxy / Supavisor・トランザクションモードの罠・サーバーレス対応）

> PostgreSQL の接続枯渇を防ぐ接続プーリングの実践ガイド。プロセス・パー・コネクションという仕組み、max_connectionsを上げてはいけない理由、PgBouncerのsession/transaction/statementモードの違い、トランザクションモードで壊れる機能（プリペアドステートメント・LISTEN/NOTIFY・セッションアドバイザリロック）と対処、サーバーレス(Lambda/Edge)での必須構成、RDS Proxy・Supabase Supavisorまでを実コードで解説します。

- 公開日: 2026-06-25
- 著者: 友田 陽大
- タグ: PostgreSQL, パフォーマンス, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/postgresql-connection-pooling-pgbouncer-serverless-guide

## 要点

- PostgreSQL は接続1つにつきOSプロセスを1つforkする。各バックエンドはメモリを食い、work_memも接続単位で乗算される。だから max_connections を上げるのは解決策ではない
- PgBouncer の3モード：session（既定・互換性最大）、transaction（再利用最大・Web/サーバーレス推奨）、statement（最アグレッシブ・autocommitのみ）
- トランザクションモードはセッション状態を壊す：SET/RESET・LISTEN/NOTIFY・セッションアドバイザリロック・SQLレベルのPREPARE・WITH HOLDカーソル。対策はセッション機能を使わない＋pg_advisory_xact_lock＋プロトコルレベルのプリペアド対応
- プールサイズは『クライアント数』ではなく『CPUコア数の小さな倍数』。max_client_conn は大きく、default_pool_size（既定20）は小さく絞るのが funnel の設計
- サーバーレス(Lambda/Edge)は直結すると接続ストームで落ちる。トランザクションモードのプーラー(PgBouncer/RDS Proxy/Supavisor)が事実上必須。Supavisorのトランザクションモードはポート6543でプリペアドステートメント非対応

---

「同時アクセスが増えたら DB が `too many clients` で落ちた」「Lambda を並列実行したら接続が枯渇した」——これらはすべて、PostgreSQL の**接続モデル**を知らずに直結したことが原因です。

PostgreSQL は接続を**OSプロセス**で扱います。だから接続は「安いリソース」ではなく「**高い固定費**」。本番、とりわけサーバーレスでは、**接続プーリング**が必須になります。この記事は、なぜ必要か・どのモードを選ぶか・何が壊れるか・どう直すかを、公式とベンダー資料に忠実に整理します。[本番運用ガイド §5](/blog/postgresql-production-operations-guide)の深掘りです。

> **この記事のルール**：PostgreSQL サーバー側の仕様は **PostgreSQL 18 公式ドキュメント**、PgBouncer の挙動は **PgBouncer 公式ドキュメント**、RDS Proxy / Supavisor はそれぞれ **AWS / Supabase 公式**に基づきます（出典を明記）。プールサイズの目安などコミュニティ通説はその旨を注記します。

---

## 1. なぜ接続は「高い」のか：プロセス・パー・コネクション

PostgreSQL の接続モデルを、公式は明快に述べています。

> PostgreSQL は「プロセス・パー・ユーザー」のクライアント/サーバーモデルを実装している。…接続要求を検知するたびに、**新しいバックエンドプロセスを生成（fork）する**。

スレッドではなく**プロセス**です。1接続 ＝ 1 OSプロセスで、固定的なメモリオーバーヘッドを持ち、さらに `work_mem` は**バックエンド単位・操作単位で確保**されます（[総論 §4.2](/blog/postgresql-performance-tuning-production-guide)）。500接続あれば500プロセスがメモリを食い、CPUはコンテキストスイッチで消耗します。

`max_connections` の既定は **100**（サーバー起動時のみ変更可）。「足りないなら上げればいい」は**罠**です。上げた分だけメモリ・CPUを食い潰し、ある閾値で性能が崖を落ちます。

> **結論**：`max_connections` を上げるのは解決策ではありません。**少数の物理接続を多数のクライアントで使い回す**——それが接続プーリングです。

なお `max_connections` のうち一部は予約されます：`superuser_reserved_connections`（既定 **3**、緊急用）、`reserved_connections`（既定 **0**、PG16+）。一般ロールが使えるのは概ね `max_connections − これらの予約` です。

---

## 2. プーラーの役割：funnel（漏斗）

接続プーラーは、アプリと PostgreSQL の間に立ち、**多数のクライアント接続を、少数の物理接続に集約**します。

```text
[アプリ: 数百〜数千の接続] ──▶ [プーラー: 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つの物理接続を「トランザクションの間だけ」貸し出すので、小さなプールで数千クライアントを捌けます。

```ini
# 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` | プロトコルレベルのプリペアド（下記） |

```sql
-- ❌ セッションアドバイザリロック（トランザクションモードで壊れる）
-- 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` は依然壊れます**。

クライアントライブラリ側の設定も必要です。

```ts
// 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 系の経験則。**公式仕様ではなくコミュニティガイダンス**）：

```text
推奨プールサイズ ≈ (コア数 × 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 と同じ注意です。

```ts
// サーバーレス（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 公式）。

```bash
# 管理コンソール（admin_users に登録したユーザーで）
psql -p 6432 -U pgbouncer_admin pgbouncer
```

```text
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](/blog/postgresql-backup-pitr-pg-dump-wal-archiving-guide) へ。

---

### 参考（一次情報）

- [PostgreSQL 18: 19.3. Connection Settings](https://www.postgresql.org/docs/18/runtime-config-connection.html)
- [PostgreSQL 18: 19.3.1. How Connections Are Established](https://www.postgresql.org/docs/18/connect-estab.html)
- [PgBouncer: Features（pool modes）](https://www.pgbouncer.org/features.html)
- [PgBouncer: Configuration](https://www.pgbouncer.org/config.html)
- [AWS: Amazon RDS Proxy](https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/rds-proxy.html)
- [Supabase: Connecting to Postgres（Supavisor）](https://supabase.com/docs/guides/database/connecting-to-postgres)
