Closed50

[キャッチアップ] Prisma + supabase DB におけるコネクション管理

ピン留めされたアイテム
shingo.sasakishingo.sasaki

結論

  • Prisma 側がデフォルトで提供するコネクションプールは無効化する(≒同時接続上限を1にする)
    • 接続URLに connection_limit=1 を付与
  • PostgreSQL 用のコネクションプールである PgBouncer を supabase の管理画面で有効化する
    • プールモードはトランザクションモードにする
  • Prisma 側で PgBouncer を使用するように設定する
    • 接続URLに PgBouncer のポートを指定し、 pgbouncer=true を付与
  • prisma migration の場合のみ、 PgBouncer を使わずに直接 PostgresQL に繋ぐようにする

接続用URLはこんなん

postgres://postgres:[YOUR-PASSWORD]@db.xxxxxxxx.supabase.co:6543/postgres?pgbouncer=true&connection_limit=1

shingo.sasakishingo.sasaki

以下を読みながら整理してく。

https://www.prisma.io/docs/guides/performance-and-optimization/connection-management

スクラップのモチベーション

個人開発しているアプリで Vercel の serverless function 上から、supabase の postgresql に Prisma を用いてリクエストを投げているが、SSR/ISR を含んだビルド中(クエリが大量発生している間)にアクセスするとタイムアウトが発生してしまう事象について、正しく理解して解決したい。

普段はいわゆるフロントエンジニアなのでこの辺の知識がガバガバな状態からスタート。

shingo.sasakishingo.sasaki

概要

データベースが同時に処理できるコネクション数には制限がある。各コネクションにはRAMが割り当てられるため、単にコネクション数の上限を増やすだけでは限界がある。

コネクションを増やすことでさばけるリクエストは増えるものの、メモリが不足してパフォーマンスが低下し、最悪の場合はダウンしてしまうこともある。

アプリケーション側でコネクションをどのように管理するかによってもパフォーマンス影響はあるため、本ガイドではサーバーレス環境と常駐プロセスそれぞれでのアプローチを紹介する。

shingo.sasakishingo.sasaki

基本的にはサーバーレス環境のほうを必要としてるけど、一応常駐プロセスのパターンについても目を通して比較できるようになることを目指す。

shingo.sasakishingo.sasaki

現状は「最悪の場合はダウンしてしまう」を普通に起こしてしまってるので論外レベルなんだとは思う。多分リクエストの数だけコネクション貼ってる。コネクションプール対応とかそういうのが必要なんだと思う。

shingo.sasakishingo.sasaki

常駐プロセス

常駐プロセスの一例として、Node アプリケーションを Heroku や VM にホストして長時間起動し続けるプロセスで考える。

常駐プロセスにおいて重要は以下である。

  • コネクションプールサイズを適切にすること
  • PrismaClient をアプリケーション全体で1個だけ生成すること
shingo.sasakishingo.sasaki

コネクションプール自体の説明がこのガイドには乗ってないので、別なページを探したほうが良さそう。

shingo.sasakishingo.sasaki

コネクションプール

Prisma のクエリエンジンはデータベースコネクションのコネクションプールを管理する仕組みを持っている。

コネクションプールは Prisma Client で初めて DB に接続した際に開かれる。(明示的な $connect() または最初のクエリリクエスト時)

リレーショナルデータベースの場合

Prisma の RDB コネクタは、Prisma 自身が持つコネクションプールを利用する。コネクションプールにはコネクションリミットと、プールタイムアウトが設定されており、URLパラメータからこれを変更することができる。

プールの動作原理

  1. プールサイズとタイムアウト設定に基づいてプールを作成する
  2. コネクションを作成し、プールに追加しておく
  3. クエリリクエストが発生した際に、プールからコネクションを取り出して割り当てる
  4. プールにアイドル状態のコネクションがない場合、新たにコネクションを作成しプールに追加する(上限に到達するまでは)
  5. コネクションを割り当てられなかった場合、クエリは FIFO キューに積まれ、順に処理される
  6. キューに積まれたクエリが、タイムアウト時間まで完了できなかった場合はエラーを返す

プールサイズ

プールサイズのデフォルト値は、 物理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 にした場合は、タイムアウト自体を無効化出来る。

shingo.sasakishingo.sasaki

この辺りはなんとなーく知ってた。
ただ Postgresql 側が提供するコネクションプールとの違いとか使い分けはよくわかってない。このまま進めれば理解できるかな。

shingo.sasakishingo.sasaki

推奨プールサイズ (常駐プロセス)

(デフォルトプールサイズ(CPU数 * 2 + 1)) / アプリケーションインスタンス数 がオススメ。

インスタンスが一つの場合はデフォルトサイズをそのまま使えばOKで、インスタンスが複数ある場合は connection_limit parameter で明示的に指定する

shingo.sasakishingo.sasaki

ちょっと混乱したけど、Prisma インスタンス側でプールを作成するなら、インスタンスがいくつあっても総プール数が均一になるようにするための対応と考えると妥当か。

複数インスタンスすべてでデフォルトプールサイズを使ったらDB側が耐えられなくなっちゃうし。

shingo.sasakishingo.sasaki

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
shingo.sasakishingo.sasaki

開発環境側の対応は現状やってる。これがないと HMR が走るたびにコネクションが新たに張られてあっという間にDBがダウンするのよね。

shingo.sasakishingo.sasaki

明示的に $disconnect() しない (常駐プロセス)

prisma.$disconnect()

でコネクションを明示的に切ることができるが、いちいち切断して次のクエリで再接続してだと効率が悪いため。

shingo.sasakishingo.sasaki

サーバーレス環境

常駐プロセスの話は終わりで、ここからはサーバーレス環境でのコネクション管理の話。

ここでいうサーバレス環境は、 AWS Lambda, Vercel, Netlify などにホストされた Node.js 関数を指す。

サーバレース環境においては以下の観点が重要。

  • サーバーレスチャレンジ(The serverless challenge) の仕組みを理解する
  • 外部のコネクションプールを利用するかに基づいたプールサイズ設定
  • PrismaClient をハンドラの外側で生成し、明示的な $disconnect をしないこと
  • 並列実行の設定を適切に行うこと
shingo.sasakishingo.sasaki

パッと見ただけで常駐プロセスよりも難しい気配がする。serverless challenge は知らないし、外部のコネクションプールってのも postgresql が持つ仕組みのことだとは思うけど全然わからんし、現状ハンドラ内で PrismaClient 生成しちゃってるし、並列実行を考えたこともないし。

shingo.sasakishingo.sasaki

サーバーレスチャレンジ

(ここでは AWS Lambda を例に考える)

サーバーレス環境においては、各Lambdaが自身の PrismaClient インスタンスを生成し、それぞれが独自のコネクションプールを作成してしまう。

プールサイズが3の場合、Lambda が1個しか動いていないならコネクション数も3に収まり問題ないが、トラフィックがスパイクして3個のLambdaが同時に動いた場合、総コネクション数は 3 * 3 の 9 に膨れてしまう。


引用元

Lambda が 200 になればコネクションは600個。こうなると DBMS 側が限界を迎えてダウンしてしまう。

これを避けるため、以下の対応を考える

  • Prisma 側のプールサイズを1にする (connection_limit=1)
  • PgBouncer のような外部のコネクションプールの利用を検討する
shingo.sasakishingo.sasaki

サーバーレス関数はインスタントな実行環境だから、Prisma側でコネクションプールを作成してもすぐに終了するから無駄になるって話かな。

Prisma 側で作成はしなくても、関数が200個あれば200コネクション作成されちゃうから、それを避けるために今度は PgBouncer を使って DB 側にプールを作ってもらう感じか。 (PgBouncer が何かはわかっていない)

shingo.sasakishingo.sasaki

推奨プールサイズ (サーバーレス環境)

サーバーレス環境における推奨プールサイズは以下に依存して決定する

  • 外部のコネクションプールを使用するのか
  • 関数が並列にクエリを投げるように設計されているか
shingo.sasakishingo.sasaki

自分のコードが並列にクエリを投げているのかわかってない。
@apollo/server で GraphQL リクエストをさばいてるけど、リゾルバ単位で並行で処理できるはずだし、多分クエリも並列で投げてるかな。

shingo.sasakishingo.sasaki

外部コネクションプールを使用しない場合

connection_limit=1 から始めてから最適化しよう。

各リクエストは寿命の短い Node.js プロセスによって裁かれるため、プールサイズが大きいとあっという間にDBコネクション制限に到達してしまうため。

shingo.sasakishingo.sasaki

一旦はこの設定の採用が必須っぽいかな。
postgresql://USER:PASSWORD@HOST:PORT/DATABASE?schema=public&connection_limit=1

shingo.sasakishingo.sasaki

でもこの場合は1関数内で並列にクエリをリクエストしたい場合も一つのコネクションを使い回さなきゃならないから、実質直列になっちゃうのかな。

そこを最適化するための外部コネクションプールか。

shingo.sasakishingo.sasaki

外部コネクションプールを使用する場合

デフォルト設定 (CPU数 * 2 + 1) で OK
外部コネクションプール側がスパイクに対応してくれる。

shingo.sasakishingo.sasaki

1関数でプールを作成しても外部コネクションプールも作成されているから、そこで対応できる。

関数内で並列に複数クエリをリクエストしても外部プール側にコネクションが空いていれば効率的にさばくことができる。

shingo.sasakishingo.sasaki

並列リクエストの最適化

Promise.all() {
  query1,
  query2,
  query3
  query4,
  ...
}

のような並列クエリを含んでいる場合、プールサイズが1の場合は実質直列化してしまうし、場合によってはタイムアウトが発生してしまうので、並列化をやめるか、プールサイズを増やす対応が必要になる。

shingo.sasakishingo.sasaki

apollo/server が DB に対して並列にクエリ実行するかどうかもわかってなかったけど、リゾルバチェインの仕組み見る限りは、並列関係にあるリゾルバはすべて並列実行されるみたい。

https://www.apollographql.com/docs/apollo-server/data/resolvers/#resolver-chains

ということで GraphQL API をサーバーレスで提供する限りは、1関数で並列リクエストが発生するのでプールサイズが1だと不十分になる。

shingo.sasakishingo.sasaki

つまりサーバーレスで GraphQL API を提供する場合は Prisma 側のプールサイズを1にすると直列化して、詰まったクエリがタイムアウトしてダウンすることがある。

おぉ、点と点が繋がってきたぞ。

shingo.sasakishingo.sasaki

サーバーレス環境における PrismaClient

PrismaClient のインスタンス生成はハンドラの外側で行うこと

サーバーレス環境がウォームスタンバイで動いている場合、ハンドラの外側のスコープは保持されるため、連続したリクエストに対してコネクションを再利用することができる。

import { PrismaClient, Prisma } from '@prisma/client'

const client = new PrismaClient()

export async function handler() {
  /* ... */
}
shingo.sasakishingo.sasaki

これ普通に意識してなかった。
結果的にハンドラ外でインスタンス生成してたけど、もう少しわかりやすく明示するように修正してみた。

shingo.sasakishingo.sasaki

明示的な $disconnect() をしない

これはサーバーレスでも常駐プロセスでも一緒。
特にサーバーレスだと、せっかくウォームスタンバイで再利用できるコネクションをいちいち破棄することになっちゃうので。

shingo.sasakishingo.sasaki

サーバレス環境でのその他の考慮点

コンテナ再利用

サーバーレス環境では連続したリクエストが必ず同じコンテナに割り当てられる保証はないため、コネクションがなければ確立する、あれば再利用するといったステートレスな実装にすること。

ゾンビコネクション

削除予定とマークされたコンテナが、削除されないまま存在し続けることがある。この状態が続くとき、使用できないコネクションが維持され続けてしまう可能性がある。

アイドル状態の接続をクリーンアップするアプローチがあるが、Prisma では使用することができない。

並列実行制限

サーバーレス並行実行の上限によっては、並行実行するだけでDBコネクションを消費しきってしまうことがある。

これを防ぐためにはサーバーレスの同時実行数上限を、DBの最大コネクション数をプールサイズで割った値にすればよい。

shingo.sasakishingo.sasaki

コンテナ再利用に関してはこんな感じにしてれば大丈夫かな。

export async function getPrismaClient() {
  if (!global.prisma) {
    global.prisma = new PrismaClient()
    await global.prisma.$connect()
  }
  return global.prisma
}
shingo.sasakishingo.sasaki

ゾンビコネクションはどうしようもなさそう。
消えるのを待つしか無い。ここでは AWS Lambda の話をしてるので Vercel での挙動は不明。

shingo.sasakishingo.sasaki

並列実行制限は確かにありそう。
コネクションプールが1の場合は、DB側のコネクション数分が関数同時実行の上限になるようにしたほうが良いのね。

Vercel だと上限は1000らしい。postgresql のデフォルト上限にあわせて100ぐらいに設定するのが良いんだろうか。まぁそんなスパイクする未来は無いと思うけど。
https://vercel.com/docs/concepts/limits/overview#serverless-function-concurrency

shingo.sasakishingo.sasaki

コネクションプールの最適化

コネクションタイムアウトは以下のような場合に発生する

  • 多数のユーザーが同時にアクセスした場合
  • 1回のリクエストで並行クエリが多数発行された場合

この場合タイムアウトログが出力されるため、断続的に発生することがわかったら connection_limitpool_timeout を見直そう。

プールサイズの増加

Prisma のプールサイズを増加することでより多くの並列クエリを実行できるが、DB側のコネクション制限に引っかからないように気をつけよう。

タイムアウトの増加

プールサイズを既に充分に増加しており、クエリがメモリを使い果たさずに完了できると確認しできるのであれば pool_timeout を見直そう。

あるいは pool_timeout=0 とすることで、タイムアウトを無効化することもできる。これは単発で巨大なクエリを投げ、メモリが枯渇しないことに確信が持てる場合に有用である。

shingo.sasakishingo.sasaki

タイムアウトについては Vercel の serverless function 側が (無料プランでは) 10秒でタイムアウトするので、そのみちそれ未満にする必要はある。

と書いてて思ったけど、現状発生してるタイムアウトって Prisma じゃなくて serverless function 側だったかもしれねぇ。

shingo.sasakishingo.sasaki

外部コネクションプール

本ガイドでは Prisma が作成するコネクションプールの話がメインだったが、データベース側にもコネクションをプールする仕組みがある場合がある。

Data Proxy

Prisma Data Platform が提供するマネージドのコネクションプール

PgBouncer

PostgreSQL 用のコネクションプール。

PostgreSQL は一定の同時接続数しか対応できなく、特にサーバーレス環境ではすぐに枯渇してしまうため、PgBouncer を用いたコネクションプールで Prisma Client とデータベースの間のコネクションを取り持つことで、データベースが常時処理しなければならないプロセスを減らすことが出来る。

AWS RDS Proxy

AWS RDS が提供するやつだが、Prisma とあわせて使う場合は特に意味がない(らしい)

shingo.sasakishingo.sasaki

今回は supabase 経由で PostgreSQL を使用するため、この中だと当然 PgBouncer 一択になる。

Prisma での PgBouncer との接続については別途ドキュメントがあるのでそれに目を通す。

shingo.sasakishingo.sasaki

Prisma Client と PgBouncer

https://www.prisma.io/docs/guides/performance-and-optimization/connection-management/configure-pg-bouncer

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 に直接つなぎに行こうね

shingo.sasakishingo.sasaki

supabasea database の場合、PgBouncer はデフォルトで 6543 ポートで動いており、トランザクションモードに設定されている模様。

shingo.sasakishingo.sasaki

モードについてはこちらに書いてある。
https://www.pgbouncer.org/features.html

  • セッションモード
    • クライアントが接続すると、切断まで同じコネクションを割り当てる
  • トランザクションモード
    • トランザクションごとにコネクションを割り当てる
  • ステートメントモード
    • トランザクション内に複数のステートメントがある場合、それごとにコネクションを割り当てる
shingo.sasakishingo.sasaki

Prisma で1リクエスト内で複数のクエリを発行した場合、トランザクションモードだとそれぞれにコネクションが割り当てられるのか。

セッションモードだと結局1クエリずつ直列処理になってパフォーマンスが悪くなるからかな。1セッションに対してならそうだけど、多数のセッションが同時に発生した場合はトランザクションごとだとそれはそれで逼迫しそうだけど大丈夫かな?

と書いてて思ったけど、これもクエリのタイムアウトを懸念したところでファンクションのタイムアウトが先に来るから関係ないか。

shingo.sasakishingo.sasaki

マイグレーション時は PgBouncer を使わないようにするの、うっかり忘れちゃいそうだな。手作業ならエラー出たら書き換えれば済む話だけど、CI/CD 周りでURL差し替えるのちょっと面倒そう?

shingo.sasakishingo.sasaki

CI/CD 周り

いや本番環境の環境変数だけ接続URLを PgBouncer 用にすれば済む話か。マイグレーション自体はその前に済ませてるんだから。

shingo.sasakishingo.sasaki

supabase と Prisma

最後に、supabase 側にも PgBouncer と Prisma のインテグレーションに関するドキュメントがあるのでそこに目を通す。
こっちは日本語訳があるのでメモ程度で済ませる。

https://www.supabase.jp/docs/guides/integrations/prisma#supabaseでのコネクションプーリング

サーバーレス環境(AWS Lambda、Vercel、Netlify FunctionsにホストされているNode.js関数など)で作業している場合は、コネクションプーリングを設定する必要があります

断言なんだ。まぁすべてデフォルト設定で考えると Prisma 側はプールを生成するから、1リクエストで多数のコネクションを張りに行ってすぐに枯渇するからそれはそう…?

Supabaseは、PgBouncerを使用して接続管理をサポートしており、デフォルトで有効になっています。

あぁ、別に PostgreSQL が PgBouncer の仕組みを持ってるわけじゃなくて、PgBouncer っていう別のツールがあって、 supabase はそれと PostgreSQL を繋いでくれる機能があるよってことか。

マイグレーションを実行する際には、プールされていない接続URL(ステップ4で使用したようなもの)を使用する必要があります。

こっちにも書いてあった。

同時接続数を最小限にするため、connection_limitを1に設定することも推奨されます。

Prisma 側ではコネクションプールを作らずに、PgBouncer に寄せるのね。

shingo.sasakishingo.sasaki

このページに本来知りたいこと全部書いてあったわ。
まぁ周辺の知識をキャッチアップできて、最後に答え合わせできたと考えると良い形かな。

このスクラップは2022/12/19にクローズされました