コネクションプール — 増やしたら、もっと詰まった
DB の接続が足りないと言われてプールを増やしたら、さらに詰まった。接続の総数は「1台あたり × 台数」で、サーバーレスでは台数が勝手に増える。
この記事の進み方
What — 接続を借りて、返す
DB への接続を作るのは、思っているより高くつきます。TCP の接続、TLS のハンドシェイク、認証、そして DB 側での準備。PostgreSQL は 接続1本ごとに専用のプロセスを起動します。
リクエストのたびにこれを繰り返すと、クエリそのものより接続の準備に時間を使うことになります。そこで、接続を作っておいて使い回すのがコネクションプールです。
空きプールプールの中で、次に借りられるのを待っている
貸し出し中アプリリクエストが借りて、クエリを投げている
トランザクション中アプリBEGIN から COMMIT まで、この接続を占有している
借りるのを待つ次のリクエスト空きが無いので、他の誰かが返すのを待っている
空き—リクエストが接続を借りる→貸し出し中 — 接続を作り直すのではなく、作ってある接続を渡す。数ミリ秒から数十ミリ秒の準備を省ける。接続は「借りて、使って、返す」。返さない処理が1つあれば、その接続は他の誰にも使えない。プールが空になれば、DB が暇でも待たされる。
- コネクションプール
- DB への接続をあらかじめ作っておき、リクエスト間で使い回す仕組み。
- max_connections
- DB が同時に受け付ける接続の上限。超えると新しい接続は拒否される。
- 取得待ちタイムアウト
- プールに空きが無いとき、接続を借りるまで待つ上限時間。超えるとアプリ側でエラーになる。
- 外部プーラー
- アプリと DB の間に置く、接続をまとめる専用の中継。RDS Proxy や PgBouncer など。
プールは1台ごと。DB から見えるのは合計
プールの設定はアプリの1台(1プロセス、1実行環境)ごとです。DB が受け止めるのは、その合計です。
DB への接続数 = 1台あたりのプールの最大 × 台数
サーバーを4台並べて各20本なら80本です。ここまでは設定を書いた人にも見えています。
問題は台数が自分の知らないところで増えるときです。オートスケールで台数が増える。デプロイ中に新旧が同時に立つ。そしてサーバーレスでは、同時実行数がそのまま台数になります。
Lambda の同時実行数が最大 500 まで伸びます。各実行環境はプールの最大を 10 本に設定しています。DB の max_connections は 400 です。DB に向かう接続は理論上最大で何本になりますか?
Why — なぜ増やすと悪化するのか
「接続が足りない」と言われると、増やしたくなります。ところが、接続は増やすほど DB を遅くする面があります。
DB は、接続の数だけ速くなるわけではない
DB が実際に仕事をするのは CPU とディスクです。8コアの DB に 400 本の接続から同時にクエリが来ても、同時に進められるのはせいぜい数本から十数本です。残りは順番を待ちます。
待つ場所が DB の中か、アプリのプールの手前か、の違いです。そしてDB の中で待つほうが高くつきます。
- 接続1本ごとにメモリを使う(PostgreSQL なら1本ごとにプロセス)
- 大量のクエリが同時に走ると、ロックの取り合いと切り替えのコストが増える
- 待っているクエリが増えるほど、1本あたりの処理も遅くなる
プールを小さくして、アプリ側で順番を待たせたほうが、全体として速く終わることがよくあります。経験則として「コア数の2倍にディスク数を足した程度」という目安が知られていますが、これは出発点で、答えではありません。
接続を「使う時間」ではなく「握る時間」が効く
プールの本数を決めるのは、同時に何件のリクエストがあるかではありません。同時に何本の接続が握られているかです。
クエリ自体が 5ms で終わっても、その前後で接続を握ったまま別のことをしていれば、その間ずっと1本を塞ぎます。
クエリだけなら: [借りる][SELECT 5ms][返す] → 5ms 握る
外部 API を挟むと: [借りる][BEGIN][INSERT][決済 API 3秒][UPDATE][COMMIT][返す] → 3秒 握る
同じ10本のプールでも、前者なら毎秒2,000件、後者なら毎秒3件しか捌けません。DB の CPU は暇なのに、API は遅い。このときログに出るのは「接続の取得待ちでタイムアウト」で、DB の負荷グラフを見ても原因は見つかりません。
DB の CPU 使用率は 15% で余裕があります。一方で API の応答が数秒に伸び、アプリのログには「接続の取得待ちでタイムアウト」が出ています。最も疑うべきものはどれですか?
サーバーレスでは、プールがほとんど意味を持たない
Lambda の実行環境は一度に1件のリクエストしか処理しません。その中にプールを作っても、同時に使われる接続は1本です。
それでいて、実行環境は使われていない間も接続を握ったまま凍結されます。しばらく呼ばれなければ回収されますが、そのときに接続を閉じる機会は無く、DB 側では接続がタイムアウトするまで残ります。
そして同時実行数が増えれば、実行環境が増え、接続が増えます。アプリ側でプールを作っても、DB から見た接続数は抑えられません。
ここで効くのが外部プーラーです。アプリと DB の間に置き、アプリからの大量の接続を、DB への少数の接続にまとめます。
毎回作って、毎回閉じる
connect() → query() → close()- 接続の準備
- **毎回かかる**
- DB から見た接続数
- 同時リクエスト数と同じ
- 向いている場面
- ごく低頻度のバッチ
- 握る時間
- 最短
- 注意
- —
- 同時に使われる本数
- —
- 凍結中
- —
- –接続の準備(TCP・TLS・認証・プロセス起動)がクエリより重くなりがち
- –同時リクエストが増えれば、そのまま DB の上限に届く
- –PostgreSQL は接続ごとにプロセスを起動するので、特に重い
DB から見た接続数を誰が抑えるか、で選ぶ。台数が勝手に増える環境では、アプリの中のプールでは抑えられない。
演習 — まず自分で判断する
解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。
キャンペーンに備えて、Lambda から DB への接続をどう組みますか?
- API は Lambda、DB は PostgreSQL。DB の max_connections は 400
- 普段の同時実行数は 30 前後。キャンペーン時は 600 を超える見込み
- クエリの大半は 20ms 以内に終わる
- DB のインスタンスを大きくする予算は、キャンペーン期間だけなら取れる
- キャンペーンは来週。大きな作り直しはできない
- · DB から見た接続数を、何が決めているか
- · 実行環境1つが同時に使う接続は何本か
- · 同時実行数が見込みを超えたとき、どこで止まるか
「DB を大きくすれば解決するのでは」と言うプロダクトマネージャーに、なぜそれだけでは足りないのかを説明してください
- 相手は技術的な詳細より、費用と再発防止に関心がある
- キャンペーン期間だけ DB を大きくする予算は承認済み
- 来週までにできることと、その後にやるべきことを分けて伝えたい
読み終わりましたか?
読了にすると、これを前提とする記事がロードマップで開放されます。