コネクションプール
Connection Pool ・ こねくしょんぷーる
DBへの接続を確立済みのまま貸し借りして使い回し、確立のコストと同時接続数を抑える仕組み。
✦ 30秒で試す — 航海DBへの問い合わせより、接続の準備のほうが長いことがある接続を20本に増やすと、平均の応答時間は?予想してみる概要
コネクションプールは、データベースなどへの接続をあらかじめ何本か確立して「プール(貯め池)」に確保しておき、必要なときに貸し出して使い回す仕組みです。アプリケーションはリクエストのたびに新しい接続を作るのではなく、プールから1本借り、クエリを実行し、使い終わったら切断せずにプールへ返します。次のリクエストは返却されたその接続を再利用します。
レンタサイクルの駐輪場に似ています。利用者が来るたびに自転車を工場から組み立てて納車していたら間に合わないので、整備済みの自転車を何台か常備しておき、借りて・使って・返してもらう。台数の上限を決めておけば、貸し出しすぎて誰かが困ることもありません。RDBMS を使うほぼすべての本番アプリケーションで、ORM やドライバの内部に組み込まれる形で動いており、意識していなくてもお世話になっている定番の仕組みです。
なぜ生まれたか
データベースへの接続は、見た目以上に高くつきます。1本の接続を確立するには、まず TCP の3ウェイハンドシェイクで往復し、続いて TLS のハンドシェイクで暗号化の鍵を取り決め、さらにデータベースの認証(ユーザー確認・権限チェック・セッション初期化)を経て、ようやくクエリを送れる状態になります。ネットワーク越しなら数十ミリ秒かかることも珍しくありません。DB側のコストも小さくなく、PostgreSQL は接続1本ごとに専用のプロセスを起動し、MySQL は接続ごとにスレッドとバッファを割り当てます。実行したいクエリ自体が1ミリ秒で終わるとしたら、準備に本番の何十倍も払っている計算です。
Webアプリケーションはリクエストのたびに短いクエリを大量に発行するため、毎回この初期化コストを払う設計では話になりません。しかも接続の「数」にも問題があります。DB側が同時に維持できる接続数には上限があり、接続1本ごとにメモリを消費します。アプリが無秩序に接続を張ればDBはあっさり枯渇します。「作ったものを捨てずに使い回すことでコストを償却し、同時にプールサイズという蛇口で接続数の上限を制御する」— この二つの課題を一度に解くのがコネクションプールです。
詳細
貸出→使用→返却のライフサイクル
プールの動きは「借りる・使う・返す」の循環で捉えられます。アプリが接続を要求したとき、プールに待機中の接続があればそれを渡します(ここが高速)。なければ、上限に達していない限り新しく確立して渡し、上限に達していれば空きが出るまで待たせます。この待ちにもタイムアウトを設け、いつまでも待ち続けないようにするのが定石です。返却された接続は切断されず、待機状態に戻って次の貸出に備えます。プールはさらに、アイドルが長い接続の破棄や、貸出前の簡単なヘルスチェック(死んだ接続を渡さないための検査)も面倒をみます。
プールサイズをどう決めるか
いちばん悩ましいのがプールサイズです。少なすぎれば、接続の空き待ちがリクエストの待ち行列となってレイテンシに跳ね返ります。このとき DB の CPU はむしろ暇で、ボトルネックはプールの本数そのものです。では大きくすればよいかというと、そうもいきません。DB側は接続ごとにメモリとプロセス(またはスレッド)を割り当てるため、接続過多はDB自体を圧迫します。さらに、CPUコア数を大きく超えた数の問い合わせが同時に走ると、コアの奪い合いによるコンテキストスイッチ、ロックの競合、CPU キャッシュの追い出しが増え、1本あたりの処理が遅くなります。1本が遅くなれば接続を抱えている時間が延び、待ち行列がまた伸びる — 「アプリを速くしようとプールを増やした結果、DBが遅くなって全アプリが遅くなる」のは典型的な失敗です。
経験則としては「意外なほど小さくてよい」が答えです。Java の代表的なプール実装 HikariCP の文書は、PostgreSQL のコミュニティで知られた目安として「コア数 × 2 + ディスクの数」を紹介しています。接続を抱えている時間のうち、ネットワークの往復やディスクの待ちの間は CPU を使わないので、その分だけコア数より少し多い本数でコアが休みなく働く、という考え方です。この目安から始めて、待ち時間と DB の CPU 使用率を計測しながら調整するのが定跡です。注意すべきは掛け算で、アプリのインスタンスがスケールアウトすれば総接続数は「プールサイズ×台数」になります。マイクロサービスで同じDBを複数サービスが参照する構成なら、その合計がDBの上限に収まるかを常に見ておく必要があります。
リークと汚染 — 返し忘れ・戻し忘れ
プール運用の二大事故が「リーク」と「汚染」です。リークは返却忘れのことで、例外時のクローズ漏れなどで借りた接続が返らないまま溜まり、やがてプールが空になって全リクエストが待ちに入ります。DBは元気なのにアプリだけ止まる、という不可解な障害の定番原因です。多くの言語が持つ「ブロックを抜けたら自動で返す」構文(try-with-resources や using、ORM のセッション管理など)を徹底し、貸出中の本数・取得の待ち時間・貸出時間のメトリクスを監視するのが対策です。HikariCP の leakDetectionThreshold のように、一定時間返ってこない接続を借りた場所のスタックトレース付きでログに出す機能も役に立ちます。汚染は、接続がセッション状態を持つことに起因します。トランザクションを開いたまま、あるいはセッション変数を変えたまま返却すると、次の借り手がその状態を引き継いでしまいます。返却時に状態をリセットするのはプール実装の重要な仕事です。
プールの本数と、返し忘れの怖さを確かめてください。リクエストの粒は左から届き、プールの接続を1本借りて DB へ行き、結果を持って帰って接続を返します。「+ 接続」「− 接続」で本数を変えると、待ち行列と DB の CPU の中身が変わります。使用中の接続を押すと、その処理が例外で抜けて接続を返し忘れます。
左からリクエストが次々に届いています。プールの接続はいま 3 本です。待ち行列と DB の CPU を見ながら、プールの下の 「+ 接続」で1本ずつ足してください。
時間は引き伸ばして再生しています(画面の1秒 = 100 ms)。DB は 4 コア、到着はおよそ毎秒 70 件、接続の取得を待つ上限は 300 ms の単純化したモデルです。
サーバレスとの相性問題
サーバレスはコネクションプールと根本的に相性が悪い、という点も押さえておきたい論点です。プールは「長生きするプロセスが接続を抱え続ける」ことで成立しますが、サーバレス関数は短命で、しかもリクエストが増えると実行環境が一斉に数百と立ち上がります。各環境が律儀にプールを持てば、総接続数は一瞬でDBの上限を突き破ります。この問題への現実解が、アプリの外側にプールを置く「外部プール」で、PostgreSQL の PgBouncer や AWS の RDS Proxy が代表です。無数の短命なクライアントからの接続を手前で受け止め、DBへの接続は少数を維持して多重化する — プールという考え方を、プロセスの中からインフラの一層へと引き上げた形だといえます。
外部プールには特有の落とし穴があります。PgBouncer は、クライアントに DB の接続を貸す単位を選べます。接続を切るまで同じ接続を貸し続ける session モードは安全ですが、多重化の効果は小さい。効果が大きいのは、トランザクションが終わるたびに接続をプールへ戻す transaction モードですが、この場合、同じクライアントの次のトランザクションが別の DB 接続で実行される可能性があります。すると、接続(セッション)に結び付いた状態 — SET で変えたセッション変数、PREPARE したプリペアドステートメント、一時テーブル、アドバイザリーロック、LISTEN — が、次のトランザクションでは見えなくなったり、別のクライアントに漏れたりします。ドライバが裏で自動的にプリペアドステートメントを使っていて、「prepared statement does not exist」のようなエラーで初めて気づく、というのが典型です(PgBouncer は 1.21 からプロトコルレベルのプリペアドステートメントを transaction モードでも扱えるようになりましたが、SQL の PREPARE やセッション変数は依然として対象外です)。外部プールを入れるときは、アプリがセッションの状態に頼っていないかを先に確かめる必要があります。
試問
画面は、左から届くリクエストがプールの接続を1本借りて DB(CPU は4コア)へ行き、結果を持って帰って接続を返す様子です。「多いほど速いはず」と、プールの接続はすでに20本に増やしてありますが、応答は遅く、時間切れも出ています。プールの下の「+ 接続」「− 接続」で本数を1本ずつ変えられます(使用中の接続を押すと返し忘れが起きるので、今回は押さないでください)。
左からリクエストが次々に届いています。プールの接続はいま 20 本です。待ち行列・DB の CPU・応答時間を見ながら、プールの下の「+ 接続」「− 接続」で本数を変えてみてください。
時間は引き伸ばして再生しています(画面の1秒 = 100 ms)。DB は 4 コア、到着はおよそ毎秒 70 件、接続の取得を待つ上限は 300 ms の単純化したモデルです。
