[キャッチアップ] Prisma + supabase DB におけるコネクション管理
結論
- Prisma 側がデフォルトで提供するコネクションプールは無効化する(≒同時接続上限を1にする)
- 接続URLに
connection_limit=1
を付与
- 接続URLに
- PostgreSQL 用のコネクションプールである PgBouncer を supabase の管理画面で有効化する
- プールモードはトランザクションモードにする
- Prisma 側で PgBouncer を使用するように設定する
- 接続URLに PgBouncer のポートを指定し、
pgbouncer=true
を付与
- 接続URLに PgBouncer のポートを指定し、
- prisma migration の場合のみ、 PgBouncer を使わずに直接 PostgresQL に繋ぐようにする
接続用URLはこんなん
postgres://postgres:[YOUR-PASSWORD]@db.xxxxxxxx.supabase.co:6543/postgres?pgbouncer=true&connection_limit=1
以下を読みながら整理してく。
スクラップのモチベーション
個人開発しているアプリで Vercel の serverless function 上から、supabase の postgresql に Prisma を用いてリクエストを投げているが、SSR/ISR を含んだビルド中(クエリが大量発生している間)にアクセスするとタイムアウトが発生してしまう事象について、正しく理解して解決したい。
普段はいわゆるフロントエンジニアなのでこの辺の知識がガバガバな状態からスタート。
概要
データベースが同時に処理できるコネクション数には制限がある。各コネクションにはRAMが割り当てられるため、単にコネクション数の上限を増やすだけでは限界がある。
コネクションを増やすことでさばけるリクエストは増えるものの、メモリが不足してパフォーマンスが低下し、最悪の場合はダウンしてしまうこともある。
アプリケーション側でコネクションをどのように管理するかによってもパフォーマンス影響はあるため、本ガイドではサーバーレス環境と常駐プロセスそれぞれでのアプローチを紹介する。
基本的にはサーバーレス環境のほうを必要としてるけど、一応常駐プロセスのパターンについても目を通して比較できるようになることを目指す。
現状は「最悪の場合はダウンしてしまう」を普通に起こしてしまってるので論外レベルなんだとは思う。多分リクエストの数だけコネクション貼ってる。コネクションプール対応とかそういうのが必要なんだと思う。
常駐プロセス
常駐プロセスの一例として、Node アプリケーションを Heroku や VM にホストして長時間起動し続けるプロセスで考える。
常駐プロセスにおいて重要は以下である。
- コネクションプールサイズを適切にすること
- PrismaClient をアプリケーション全体で1個だけ生成すること
コネクションプール自体の説明がこのガイドには乗ってないので、別なページを探したほうが良さそう。
このページをさっと確認する。
(Prisma におけるコネクションプール機能と、DBMSが提供するそれが違う気がするけど、一旦前者を考えよう)
コネクションプール
Prisma のクエリエンジンはデータベースコネクションのコネクションプールを管理する仕組みを持っている。
コネクションプールは Prisma Client で初めて DB に接続した際に開かれる。(明示的な $connect() または最初のクエリリクエスト時)
リレーショナルデータベースの場合
Prisma の RDB コネクタは、Prisma 自身が持つコネクションプールを利用する。コネクションプールにはコネクションリミットと、プールタイムアウトが設定されており、URLパラメータからこれを変更することができる。
プールの動作原理
- プールサイズとタイムアウト設定に基づいてプールを作成する
- コネクションを作成し、プールに追加しておく
- クエリリクエストが発生した際に、プールからコネクションを取り出して割り当てる
- プールにアイドル状態のコネクションがない場合、新たにコネクションを作成しプールに追加する(上限に到達するまでは)
- コネクションを割り当てられなかった場合、クエリは FIFO キューに積まれ、順に処理される
- キューに積まれたクエリが、タイムアウト時間まで完了できなかった場合はエラーを返す
プールサイズ
プールサイズのデフォルト値は、 物理CPU数 * 2 + 1
で設定される。この設定が最適であるかはデプロイメントの形式に依存する。(特にサーバーレスの場合)
プールサイズはDBのURLに connection_limit
パラメータを指定することで設定できる。
postgresql://johndoe:mypassword@localhost:5432/mydb?connection_limit=5
の場合はプールサイズが5になる。
タイムアウト
デフォルトのタイムアウトは10秒。
タイムアウトもURLから pool_timeout
で設定可能
postgresql://johndoe:mypassword@localhost:5432/mydb?connection_limit=5&pool_timeout=2
これを 0
にした場合は、タイムアウト自体を無効化出来る。
この辺りはなんとなーく知ってた。
ただ Postgresql 側が提供するコネクションプールとの違いとか使い分けはよくわかってない。このまま進めれば理解できるかな。
推奨プールサイズ (常駐プロセス)
(デフォルトプールサイズ(CPU数 * 2 + 1)) / アプリケーションインスタンス数
がオススメ。
インスタンスが一つの場合はデフォルトサイズをそのまま使えばOKで、インスタンスが複数ある場合は connection_limit
parameter で明示的に指定する
ちょっと混乱したけど、Prisma インスタンス側でプールを作成するなら、インスタンスがいくつあっても総プール数が均一になるようにするための対応と考えると妥当か。
複数インスタンスすべてでデフォルトプールサイズを使ったらDB側が耐えられなくなっちゃうし。
PrismaClient をシングルトン化する (常駐プロセス)
アプリケーション全体で PrismaClient が1個しか存在しないようにする。
モジュールのトップレベルでインスタンスを生成してそれを戻すようにすればOK
let prisma = new PrismaClient()
export default prisma
ただし開発環境の場合は、HMRによって何度も同じモジュールが実行されることがあるので、グローバル変数を使ってそれを回避する。
import { PrismaClient } from '@prisma/client'
// add prisma to the NodeJS global type
interface CustomNodeJsGlobal extends NodeJS.Global {
prisma: PrismaClient
}
// Prevent multiple instances of Prisma Client in development
declare const global: CustomNodeJsGlobal
const prisma = global.prisma || new PrismaClient()
if (process.env.NODE_ENV === 'development') global.prisma = prisma
export default prisma
開発環境側の対応は現状やってる。これがないと HMR が走るたびにコネクションが新たに張られてあっという間にDBがダウンするのよね。
$disconnect()
しない (常駐プロセス)
明示的に prisma.$disconnect()
でコネクションを明示的に切ることができるが、いちいち切断して次のクエリで再接続してだと効率が悪いため。
以下によると、Nodeプロセスが終了する際に自動で
サーバーレス環境
常駐プロセスの話は終わりで、ここからはサーバーレス環境でのコネクション管理の話。
ここでいうサーバレス環境は、 AWS Lambda, Vercel, Netlify などにホストされた Node.js 関数を指す。
サーバレース環境においては以下の観点が重要。
- サーバーレスチャレンジ(The serverless challenge) の仕組みを理解する
- 外部のコネクションプールを利用するかに基づいたプールサイズ設定
- PrismaClient をハンドラの外側で生成し、明示的な $disconnect をしないこと
- 並列実行の設定を適切に行うこと
パッと見ただけで常駐プロセスよりも難しい気配がする。serverless challenge は知らないし、外部のコネクションプールってのも postgresql が持つ仕組みのことだとは思うけど全然わからんし、現状ハンドラ内で PrismaClient 生成しちゃってるし、並列実行を考えたこともないし。
この章をしっかり理解できればだいぶレベルアップできそうだ。
サーバーレスチャレンジ
(ここでは AWS Lambda を例に考える)
サーバーレス環境においては、各Lambdaが自身の PrismaClient インスタンスを生成し、それぞれが独自のコネクションプールを作成してしまう。
プールサイズが3の場合、Lambda が1個しか動いていないならコネクション数も3に収まり問題ないが、トラフィックがスパイクして3個のLambdaが同時に動いた場合、総コネクション数は 3 * 3 の 9 に膨れてしまう。
Lambda が 200 になればコネクションは600個。こうなると DBMS 側が限界を迎えてダウンしてしまう。
これを避けるため、以下の対応を考える
- Prisma 側のプールサイズを1にする (
connection_limit=1
) - PgBouncer のような外部のコネクションプールの利用を検討する
サーバーレス関数はインスタントな実行環境だから、Prisma側でコネクションプールを作成してもすぐに終了するから無駄になるって話かな。
Prisma 側で作成はしなくても、関数が200個あれば200コネクション作成されちゃうから、それを避けるために今度は PgBouncer を使って DB 側にプールを作ってもらう感じか。 (PgBouncer が何かはわかっていない)
推奨プールサイズ (サーバーレス環境)
サーバーレス環境における推奨プールサイズは以下に依存して決定する
- 外部のコネクションプールを使用するのか
- 関数が並列にクエリを投げるように設計されているか
自分のコードが並列にクエリを投げているのかわかってない。
@apollo/server で GraphQL リクエストをさばいてるけど、リゾルバ単位で並行で処理できるはずだし、多分クエリも並列で投げてるかな。
外部コネクションプールを使用しない場合
connection_limit=1
から始めてから最適化しよう。
各リクエストは寿命の短い Node.js プロセスによって裁かれるため、プールサイズが大きいとあっという間にDBコネクション制限に到達してしまうため。
一旦はこの設定の採用が必須っぽいかな。
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public&connection_limit=1
でもこの場合は1関数内で並列にクエリをリクエストしたい場合も一つのコネクションを使い回さなきゃならないから、実質直列になっちゃうのかな。
そこを最適化するための外部コネクションプールか。
外部コネクションプールを使用する場合
デフォルト設定 (CPU数 * 2 + 1) で OK
外部コネクションプール側がスパイクに対応してくれる。
1関数でプールを作成しても外部コネクションプールも作成されているから、そこで対応できる。
関数内で並列に複数クエリをリクエストしても外部プール側にコネクションが空いていれば効率的にさばくことができる。
並列リクエストの最適化
Promise.all() {
query1,
query2,
query3
query4,
...
}
のような並列クエリを含んでいる場合、プールサイズが1の場合は実質直列化してしまうし、場合によってはタイムアウトが発生してしまうので、並列化をやめるか、プールサイズを増やす対応が必要になる。
apollo/server が DB に対して並列にクエリ実行するかどうかもわかってなかったけど、リゾルバチェインの仕組み見る限りは、並列関係にあるリゾルバはすべて並列実行されるみたい。
ということで GraphQL API をサーバーレスで提供する限りは、1関数で並列リクエストが発生するのでプールサイズが1だと不十分になる。
つまりサーバーレスで GraphQL API を提供する場合は Prisma 側のプールサイズを1にすると直列化して、詰まったクエリがタイムアウトしてダウンすることがある。
おぉ、点と点が繋がってきたぞ。
サーバーレス環境における PrismaClient
PrismaClient のインスタンス生成はハンドラの外側で行うこと
サーバーレス環境がウォームスタンバイで動いている場合、ハンドラの外側のスコープは保持されるため、連続したリクエストに対してコネクションを再利用することができる。
import { PrismaClient, Prisma } from '@prisma/client'
const client = new PrismaClient()
export async function handler() {
/* ... */
}
これ普通に意識してなかった。
結果的にハンドラ外でインスタンス生成してたけど、もう少しわかりやすく明示するように修正してみた。
$disconnect()
をしない
明示的な これはサーバーレスでも常駐プロセスでも一緒。
特にサーバーレスだと、せっかくウォームスタンバイで再利用できるコネクションをいちいち破棄することになっちゃうので。
サーバレス環境でのその他の考慮点
コンテナ再利用
サーバーレス環境では連続したリクエストが必ず同じコンテナに割り当てられる保証はないため、コネクションがなければ確立する、あれば再利用するといったステートレスな実装にすること。
ゾンビコネクション
削除予定とマークされたコンテナが、削除されないまま存在し続けることがある。この状態が続くとき、使用できないコネクションが維持され続けてしまう可能性がある。
アイドル状態の接続をクリーンアップするアプローチがあるが、Prisma では使用することができない。
並列実行制限
サーバーレス並行実行の上限によっては、並行実行するだけでDBコネクションを消費しきってしまうことがある。
これを防ぐためにはサーバーレスの同時実行数上限を、DBの最大コネクション数をプールサイズで割った値にすればよい。
コンテナ再利用に関してはこんな感じにしてれば大丈夫かな。
export async function getPrismaClient() {
if (!global.prisma) {
global.prisma = new PrismaClient()
await global.prisma.$connect()
}
return global.prisma
}
ゾンビコネクションはどうしようもなさそう。
消えるのを待つしか無い。ここでは AWS Lambda の話をしてるので Vercel での挙動は不明。
並列実行制限は確かにありそう。
コネクションプールが1の場合は、DB側のコネクション数分が関数同時実行の上限になるようにしたほうが良いのね。
Vercel だと上限は1000らしい。postgresql のデフォルト上限にあわせて100ぐらいに設定するのが良いんだろうか。まぁそんなスパイクする未来は無いと思うけど。
コネクションプールの最適化
コネクションタイムアウトは以下のような場合に発生する
- 多数のユーザーが同時にアクセスした場合
- 1回のリクエストで並行クエリが多数発行された場合
この場合タイムアウトログが出力されるため、断続的に発生することがわかったら connection_limit
や pool_timeout
を見直そう。
プールサイズの増加
Prisma のプールサイズを増加することでより多くの並列クエリを実行できるが、DB側のコネクション制限に引っかからないように気をつけよう。
タイムアウトの増加
プールサイズを既に充分に増加しており、クエリがメモリを使い果たさずに完了できると確認しできるのであれば pool_timeout
を見直そう。
あるいは pool_timeout=0
とすることで、タイムアウトを無効化することもできる。これは単発で巨大なクエリを投げ、メモリが枯渇しないことに確信が持てる場合に有用である。
タイムアウトについては Vercel の serverless function 側が (無料プランでは) 10秒でタイムアウトするので、そのみちそれ未満にする必要はある。
と書いてて思ったけど、現状発生してるタイムアウトって Prisma じゃなくて serverless function 側だったかもしれねぇ。
外部コネクションプール
本ガイドでは Prisma が作成するコネクションプールの話がメインだったが、データベース側にもコネクションをプールする仕組みがある場合がある。
Data Proxy
Prisma Data Platform が提供するマネージドのコネクションプール
PgBouncer
PostgreSQL 用のコネクションプール。
PostgreSQL は一定の同時接続数しか対応できなく、特にサーバーレス環境ではすぐに枯渇してしまうため、PgBouncer を用いたコネクションプールで Prisma Client とデータベースの間のコネクションを取り持つことで、データベースが常時処理しなければならないプロセスを減らすことが出来る。
AWS RDS Proxy
AWS RDS が提供するやつだが、Prisma とあわせて使う場合は特に意味がない(らしい)
今回は supabase 経由で PostgreSQL を使用するため、この中だと当然 PgBouncer 一択になる。
Prisma での PgBouncer との接続については別途ドキュメントがあるのでそれに目を通す。
Prisma Client と PgBouncer
URL 設定
Prisma Client で PgBouncer を使用する場合は、URL に ?pgbouncer=true
を付与する。
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?pgbouncer=true
ちなみに postgresql のポート番号は通常 5432 だが、PgBouncer は他のポートで動いている場合があるので注意。
PgBouncer モード
Prisma Client の場合、PgBouncer はトランザクションモードで動かす必要がある。
Prisma Migrate
Prisma Migrate はシングルコネクションを使用することを前提としているため、pgBouncer を使わずに postgreSQL に直接つなぎに行こうね
supabasea database の場合、PgBouncer はデフォルトで 6543 ポートで動いており、トランザクションモードに設定されている模様。
モードについてはこちらに書いてある。
- セッションモード
- クライアントが接続すると、切断まで同じコネクションを割り当てる
- トランザクションモード
- トランザクションごとにコネクションを割り当てる
- ステートメントモード
- トランザクション内に複数のステートメントがある場合、それごとにコネクションを割り当てる
Prisma で1リクエスト内で複数のクエリを発行した場合、トランザクションモードだとそれぞれにコネクションが割り当てられるのか。
セッションモードだと結局1クエリずつ直列処理になってパフォーマンスが悪くなるからかな。1セッションに対してならそうだけど、多数のセッションが同時に発生した場合はトランザクションごとだとそれはそれで逼迫しそうだけど大丈夫かな?
と書いてて思ったけど、これもクエリのタイムアウトを懸念したところでファンクションのタイムアウトが先に来るから関係ないか。
マイグレーション時は PgBouncer を使わないようにするの、うっかり忘れちゃいそうだな。手作業ならエラー出たら書き換えれば済む話だけど、CI/CD 周りでURL差し替えるのちょっと面倒そう?
CI/CD 周り
いや本番環境の環境変数だけ接続URLを PgBouncer 用にすれば済む話か。マイグレーション自体はその前に済ませてるんだから。
supabase と Prisma
最後に、supabase 側にも PgBouncer と Prisma のインテグレーションに関するドキュメントがあるのでそこに目を通す。
こっちは日本語訳があるのでメモ程度で済ませる。
サーバーレス環境(AWS Lambda、Vercel、Netlify FunctionsにホストされているNode.js関数など)で作業している場合は、コネクションプーリングを設定する必要があります
断言なんだ。まぁすべてデフォルト設定で考えると Prisma 側はプールを生成するから、1リクエストで多数のコネクションを張りに行ってすぐに枯渇するからそれはそう…?
Supabaseは、PgBouncerを使用して接続管理をサポートしており、デフォルトで有効になっています。
あぁ、別に PostgreSQL が PgBouncer の仕組みを持ってるわけじゃなくて、PgBouncer っていう別のツールがあって、 supabase はそれと PostgreSQL を繋いでくれる機能があるよってことか。
マイグレーションを実行する際には、プールされていない接続URL(ステップ4で使用したようなもの)を使用する必要があります。
こっちにも書いてあった。
同時接続数を最小限にするため、connection_limitを1に設定することも推奨されます。
Prisma 側ではコネクションプールを作らずに、PgBouncer に寄せるのね。
このページに本来知りたいこと全部書いてあったわ。
まぁ周辺の知識をキャッチアップできて、最後に答え合わせできたと考えると良い形かな。