コネクションプールが枯渇すると、なぜアプリ全体が固まるのか?
本番でレスポンスが軒並み遅くなり、ログに ActiveRecord::ConnectionTimeoutError や Connection is not available, request timed out が並んだ経験はないだろうか。CPU にもメモリにも余裕があるのに、なぜこの一点だけでアプリ全体が詰まるのか。答えはコネクションプールという、CPU やメモリとは別枠の資源制約にある。
- DB への接続確立は TCP ハンドシェイクと認証を伴う重い処理なので、リクエストごとに張り直さずあらかじめ確立した接続を使い回すのがコネクションプールの役割
- プールが枯渇すると新規リクエストは空き接続待ちの列に並び、待ち時間が
connectionTimeout(HikariCP)やcheckout_timeout(Rails)を超えると例外になる。だが DB サーバー自体の上限を超えた場合は待たされすらせず即座に接続が拒否される - エラーログで気づいた時点ではすでに手遅れで、プールが公開する使用中・待機中・待ち行列のメトリクスを見て枯渇の兆候を先に検知する必要がある
なぜ接続を使い回す必要があるのか
DB への接続は、TCP の 3 ウェイハンドシェイクに加えてユーザー認証やセッションの初期化を経て確立される。PostgreSQL はさらにプロセス単位の接続モデル(クライアントが接続するたびにサーバー側で新しい OS プロセスをフォークして専任させる方式)を採っており、接続の確立と切断そのものが OS レベルでも軽くない処理になる。毎リクエストのたびにこれを繰り返すと、クエリの実行時間よりも接続確立のオーバーヘッドの方が支配的になりかねない。
そこでコネクションプール(あらかじめ複数の DB 接続を確立してプールに保持し、リクエストのたびに使い回す仕組み)を使う。アプリケーションはリクエストが来るたびに新規接続を張るのではなく、プールから空いている接続を借り(チェックアウト)、処理が終わったら返却する。接続確立のコストを毎回払わずに済むため、レイテンシとサーバー負荷の両方を削減できる。
レンタサイクルのポートを思い浮かべてほしい。自転車を買うのは高くつくが、ポートに常駐する台数を使い回せば誰もが低コストで移動できる。ただしポートの台数(プールサイズ)には上限があり、全台が貸し出し中のときに来た利用者は空きが出るまで待つしかない。待っても一定時間内に自転車が返ってこなければ、その利用者は諦めて立ち去る。これがコネクションタイムアウトに相当する。さらにポート自体の設置スペース(DB サーバーが受け入れられる総接続数)にも上限があり、こちらを超えるとポートは新しい台数を置くこと自体を拒否する。
プールは 2 段構えになっている
実際のシステムでは、プールは「アプリ側のプール」と「DB サーバー自体の上限」という 2 段構えになっている。Java であれば HikariCP の maximumPoolSize、Rails であれば database.yml の pool が、アプリケーション内で保持する接続数の上限を決める。HikariCP の maximumPoolSize のデフォルトは 10、Rails の pool のデフォルトは 5 だ。
# config/database.yml production: adapter: postgresql pool: 20 # デフォルトは 5。アプリの並行スレッド数に合わせて増やす checkout_timeout: 5 # デフォルト 5 秒。これを超えると ConnectionTimeoutError
pool を増やすとアプリ 1 プロセスあたりの同時 DB アクセス数は増えるが、これは無条件に安全な変更ではない。Pod やワーカープロセスが複数動くデプロイでは、実際に DB へ到達する接続数は「プールサイズ × インスタンス数」になる。この合計が DB 側の上限を超えると、個々のアプリからは正常に見えても DB は新規接続を拒否し始める。
DB サーバー側にも受け入れ上限がある
PostgreSQL は max_connections(デフォルト 100)で同時接続数の上限を定めており、そのうち superuser_reserved_connections(デフォルト 3)は管理用に予約され一般ユーザーからは使えない。max_connections を単純に増やせば受け入れ数は増えるが、接続 1 本が OS プロセス 1 つに対応するプロセス単位の接続モデルである以上、接続数が増えるほどメモリ消費や OS のコンテキストスイッチ、内部ロックの競合も比例して増える。max_connections は「アプリを楽にする値」ではなく「DB サーバーが物理的に耐えられる上限」として設計する必要がある。
プールが枯渇したとき何が起きるかは、待たされるだけだと思っていないだろうか。実際にはレイヤーによって挙動が違う。アプリ側のプールは空き接続が出るまで待ち行列を作り、待っても空かなければタイムアウト例外を投げる。一方 DB サーバー自体の max_connections を超えた接続要求に対して、PostgreSQL は順番を待たせることをせず、その場で FATAL: sorry, too many clients already を返して接続を拒否する。「プールが枯渇したら少し待てば繋がる」という前提は、アプリ側のプールの中でしか成り立たない。
app pool (N connections)
┌─────────────────────┐
│ in-use x3 idle x1 │
│ waiting queue: 2 │
└──────────┬──────────┘
│ over timeout
▼ → exception
DB max_connections
┌─────────────────────┐
│ over limit → reject │
│ (no waiting) │
└─────────────────────┘
枯渇はどう検知するのか
タイムアウト例外がログに出た時点では、すでにユーザーのリクエストが失敗している。先に気づくには、プール自体が公開しているメトリクスを見る必要がある。HikariCP は JMX 経由で ActiveConnections(使用中)・IdleConnections(空き)・PendingConnections(チェックアウト待ちのスレッド数)を公開しており、PendingConnections が 0 より大きい状態が続くのは、プールサイズが実際の需要に対して不足している明確なシグナルになる。Rails では ActiveRecord::Base.connection_pool.stat を呼ぶと、size(プール上限)・busy(使用中)・waiting(待機中スレッド数)を含むハッシュが返る。
クエリが本来より遅くなる原因は、遅い 1 本のクエリが接続を長く握り続けることでもある。集計クエリやロック待ちで 1 本の接続が数秒単位で解放されないと、他のリクエストはその間チェックアウトできず待ち行列に積み上がる。プールサイズを増やす前に、まず個々の接続がどれだけ長く握られているかを疑う価値がある。
トラフィックが急増したタイミングで DB の CPU 使用率には余裕があるのにレスポンスタイムだけが跳ね上がる現象は、コネクションプールの枯渇が原因であることが多い。オートスケールで Pod 数を急に増やした直後に DB 側が too many clients already を返し始めるのも典型例で、これはアプリ側のプールではなく DB サーバー側の max_connections に到達しているサインだ。この場合はアプリのプールサイズをいじるのではなく、Pod あたりのプールサイズを下げるか、PgBouncer のような外部プーラーを挟んで多数のアプリ接続を少数の DB 接続に集約する対処になる。
max_connections を極端に絞った PostgreSQL に対して同時接続を張り、枯渇時に「待たされる」のではなく「即座に拒否される」挙動を実際のエラーメッセージで確認する。Docker Desktop があれば動く。
docker run --rm -d --name pgpool-demo \ -e POSTGRES_PASSWORD=pass -p 5433:5432 \ postgres:16 -c max_connections=5 sleep 3 for i in $(seq 1 6); do docker exec pgpool-demo psql -U postgres -c "SELECT pg_sleep(15)" & done wait docker stop pgpool-demo
6 本の接続を同時に張ろうとするうち、5 本は pg_sleep(15) で 15 秒間接続を保持したまま応答待ちになるが、残り 1 本は待たされることなく即座に FATAL: sorry, too many clients already で終了するはずだ。これが DB サーバー側の上限に達したときの拒否の挙動で、アプリ側プールのタイムアウト待ちとは別物であることが確認できる。
- コネクションプールは DB アクセスを高速化する魔法のキャッシュだ — 実際に高速化しているのは接続の確立コストであって、クエリの実行速度そのものを上げるものではない。遅いクエリはプールを使っても遅いままだ。
- プールサイズはとりあえず大きくしておけば安全 — DB サーバーが受け入れられる総接続数には上限があり、アプリのインスタンス数 × プールサイズがそれを超えると、個々のプール設定に関わらず DB 側で接続拒否が起き始める。
- 接続が枯渇しても、少し待てばそのうち空く — アプリ側のプールでは待ち時間が
checkout_timeout等を超えると例外になり、DB サーバー自体の上限を超えている場合はそもそも待たされず接続そのものが拒否される。
- コネクションプール
- あらかじめ複数の DB 接続を確立して保持し、リクエストのたびに使い回す仕組み。接続確立のコストを削減する。
- チェックアウト
- プールから空いている接続を 1 本借りる操作。処理後は返却してプールに戻す。
- コネクションタイムアウト
- プールに空きがない状態でチェックアウトを待つ上限時間。超えると例外(Rails では
ActiveRecord::ConnectionTimeoutError)になる。 - max_connections
- PostgreSQL サーバーが同時に受け入れる接続数の上限。デフォルトは 100。
- プーリングモード
- PgBouncer 等の外部プーラーで、クライアント接続を DB サーバー接続に対してどう共有するかの方式。session・transaction・statement の 3 種類がある。
- HikariCP - A solid, high-performance, JDBC connection pool —
maximumPoolSizeやconnectionTimeoutなど主要設定項目のデフォルト値を一次情報で確認できる。 - Connections and Authentication - PostgreSQL Documentation —
max_connections・superuser_reserved_connectionsの公式定義。 - ActiveRecord::ConnectionAdapters::ConnectionPool - Rails API —
pool・checkout_timeoutのデフォルト値とConnectionTimeoutErrorの発生条件。 - PgBouncer Configuration —
pool_mode(session/transaction/statement)とdefault_pool_sizeの公式ドキュメント。