メインコンテンツへスキップ

VillageSQL is a drop-in replacement for MySQL with extensions.

All examples on this page work on VillageSQL. Install Now →
長時間稼働する Node サーバーは、プロセスの起動時に 1 つのコネクションプールを開き、プロセスが終了するまですべてのリクエストでそれを再利用します。サーバーレス関数にはその保証がありません。各呼び出しは、前回の記憶を持たない新しい実行環境で実行される可能性があります。そのため、「普通のやり方」でプールを作成するコードは、リクエストのたびにまったく新しいプール、またはまったく新しい接続を作成してしまうことがあります。このガイドでは、Hono、Drizzle ORM、および mysql2 で小さなアプリケーションを構築し、それぞれのパターンで同時負荷をかけたときに MySQL の接続数がどうなるかを測定します。

このガイドで検証したこと、しなかったこと

以下の内容はすべて、実際のローカル VillageSQL サーバーと実際の Node プロセスに対して実行しました。アプリケーションは nodejs テンプレート(npm create hono@latest)で構築した Hono アプリで、このガイドの検証中は通常の長時間稼働する Node プロセスとして動作しました。Cloudflare Workers、AWS Lambda、または Vercel のエッジランタイムにはデプロイしていません。 これは、このガイドが主張する 1 つの点と、主張しない 1 つの点に関わります。
  • 実際に検証したこと: 実行のたびに新しい MySQL 接続を開くリクエストハンドラーと、モジュールの読み込み時に一度だけ作成したコネクションプールを再利用するハンドラーの比較です。それぞれのバージョンに同時 HTTP リクエストを送り、MySQL の接続数を観察することは、1 つのサーバーレス関数インスタンスが同時呼び出しを処理する場合、またはコールドスタートするインスタンスのモジュールレベルのコードが再実行される場合に起きることの忠実な再現です。
  • 検証しなかったこと: 多数の独立した実行環境が同時にコールドスタートし、それぞれがこのプロセスのコピーを実行して独自のプールを開くという乗算効果です。AWS Lambda や Cloudflare Workers で実際にトラフィックが急増すると、数十の分離されたインスタンスが同時に起動することがあり、それぞれに 10 接続のプールがあれば、このガイドの修正を適用していても合計はすぐに膨らみます。後述の「単一インスタンスの先へ」のセクションでその対処法を扱いますが、このガイドの Node プロセスは定義上 1 つのインスタンスであるため、再現できませんでした。

データベースのセットアップ

専用のデータベースと、それに限定したユーザーを作成します。

アプリケーションのスキャフォールド

このガイドでは Hono の nodejs テンプレートを使用します。これは @hono/node-server 上で動作し、開発に特定のクラウドプラットフォームを必要としません。
Drizzle、mysql2 ドライバー、および drizzle-kit をインストールします。
drizzle-orm と drizzle-kit はどちらも、latest と並んで dist-tags にプレリリースタグ(beta、rc)を公開しています。インストールする前に確認してください。
執筆時点では、drizzle-orm の latest は 0.45.2 に、drizzle-kit の latest は 0.31.10 に解決されます。どちらもリリース候補ではなく安定版リリースです。 .env に接続文字列を設定します。

スキーマの定義とマイグレーションの実行

src/db/schema.ts
drizzle.config.ts
マイグレーションを生成して適用します。
テーブルが存在することを確認します。

アンチパターン: リクエストごとの接続

この問題が最もよく現れるのは、単独で見れば妥当に見えるコードです。リクエストハンドラーの中で mysql2 の接続を開き、クエリを実行し、接続を閉じます。
src/index.bad.ts
同時実行数が少ないうちは、これで動作します。5 つの同時クライアントで 3 秒間アクセスしても、失敗したレスポンスはゼロでした。問題が現れるのは、同時呼び出しの数が、そのユーザーに対してデータベースが許可する数を超えたときだけです。

枯渇を実演する

MySQL の max_connections はサーバー全体の上限であり、共有サーバーでこれを下げて障害を強制すると、そのインスタンス上の他のすべての接続に影響します。1 つのアプリケーションに対する接続上限をより的を絞って再現する方法、そしてクラウドの MySQL プロバイダーがまさにこの理由で使う方法は、アカウント自体に対する MAX_USER_CONNECTIONS です。
アンチパターンのサーバーを実行し、serverless_app を 15 接続に制限した状態で、30 の同時リクエストを 5 秒間送り続ける(autocannon を使用)と、次の結果になりました。
バースト中に SELECT COUNT(*) FROM information_schema.processlist WHERE user = 'serverless_app' で 150ms ごとに serverless_app のアクティブな接続数をサンプリングすると、接続数が 15 接続の上限に達し、それを超えて上昇するのが観察できました(最も高いサンプルで 15、15、16、17、17、17、17、25)。3614 の非 2xx レスポンスは、その上限に達したものです。上のハンドラーはエラーを捕捉して各 503 のボディで返すため、失敗したレスポンスには次の内容が含まれていました。
これは MAX_USER_CONNECTIONS に固有のことではありません。ユーザーごとの制限がまったくないサーバーでも、代わりに max_connections に対して同じように接続数が上昇し、サーバー全体の上限に達した時点で、別の名前の同じエラー(ERROR 1040 (08004): Too many connections)が発生します。ユーザーごとの上限は、同じサーバーで実行されている他のものを妨げずにこれを再現できるようにするためのものです。

修正: プールをハンドラーの外で一度だけ作成する

修正とは、プーリングをやめることではありません。各リクエストを処理する関数の中ではなく、モジュールの読み込み時に、プールがちょうど一度だけ作成されるようにすることです。
src/db/index.ts
src/index.ts
db/index.ts は、モジュールが最初にインポートされたときに、トップレベルの mysql.createPool() 呼び出しをちょうど一度だけ実行します。長時間稼働する Node サーバーでは、それはプロセスの起動時の一度です。プロバイダーがコールドスタートなしに後続のリクエストのために実行環境を再利用するサーバーレスランタイムでは、インポートされたモジュールはまだメモリ上にあるため、最初の呼び出しで作成したプールが再作成されずに再利用されます。モジュールを最初から読み込み直す本当のコールドスタートだけが、新しいプールを開きます。 serverless_app を 15 接続に制限したまま、このバージョンに対して同じ 30 同時・5 秒間のバーストを実行します。
非 2xx レスポンスはまったくありません。このバースト中に同じユーザーごとの接続数をサンプリングすると、取得したすべてのサンプルで、プール自身の connectionLimit であるちょうど 10 のまま安定していました。到着する同時リクエストの数に関係なく、プールは設定された保持数を超える接続を MySQL に要求しませんでした。同じ 5 秒間の合計スループットも約 3 倍になりました(25,000 リクエストに対して 77,000 リクエスト)。この差の一部は、プール版がリクエストごとの完全な TCP ハンドシェイクと認証の往復を省略したことによるものです。もう一部は、アンチパターン版の 25,000 リクエストの多くが、接続上限に達した後は完了したクエリではなく高速な 503 の失敗であったことによります。これは実際の仕事をせずにリクエスト数を水増しします。

単一インスタンスの先へ

上の修正は、このガイドが再現できた問題、つまり 1 つの実行中インスタンス内での同時リクエストを解決します。実際のサーバーレスプラットフォームでしか現れない 2 つ目の問題は解決しません。トラフィックの急増のもとで多数の独立したインスタンスが同時にコールドスタートし、それぞれがこのコードのコピーを実行して独自のプールを開く問題です。プールは必要になるまで接続を開かず、同時リクエストが実際に必要とするときにだけ開くため、10 個のアイドル状態のインスタンスが存在するだけで 100 接続を開くことはありません。しかし、各インスタンスがごく普通の控えめなトラフィックを処理し始めると、connectionLimit が 10 のインスタンス 10 個で、ワークロードが異常に見えるよりずっと前に合計 100 接続に達することがあります。このガイドの Node プロセスは、多数ではなく 1 つのプロセスであるため、これを再現できませんでした。 2 つのアプローチがこれに対処します。どちらも上の修正の代わりにはならず、追加で行うものです。
  • インスタンスごとの connectionLimit をずっと小さくする。ほとんどのサーバーレスプラットフォームでは、同時に実行される各インスタンスが一度に処理するリクエストは少数であるため、10 ではなく 1 または 2 にすることがよくあります。
  • MySQL の前にコネクションプーリングプロキシを置く。ProxySQL などを使い、多数の独立したサーバーレスインスタンスが、ずっと少ない数の実際の MySQL 接続を共有するようにします。ProxySQL がここにどう当てはまるかについては、MySQL のコネクションプーリングを参照してください。

よくある質問

max_connections を増やすだけではだめですか?

上限は上がりますが、なくなりはしません。また、すべての接続はサーバー上で実際のメモリコストを伴います(接続ごとのコストの内訳については、MySQL のコネクションプーリングを参照してください)。数百の同時インスタンスまでスケールできるサーバーレスワークロードは、いずれどんな固定の上限にも達します。修正とは、各インスタンスが必要以上の接続を開かないようにすることであり、それらのインスタンスが使い果たせる数を増やすことではありません。

これは Hono だけでなく Express にも当てはまりますか?

はい。この障害モードは、リクエストハンドラーに対してプールまたは接続がどこで作成されるかに関するものであり、どの Web フレームワークがリクエストをルーティングするかには関係ありません。ルートハンドラーの中で mysql.createConnection() を呼び出す Express アプリにはまったく同じ問題があり、モジュールスコープで一度だけ mysql.createPool() を呼び出すことがまったく同じ修正になります。

mysql2 の組み込みプールで十分ですか、それとも ProxySQL のようなものが必要ですか?

単一の長時間稼働するサーバーであれば、mysql2 のプールだけで十分です。それがMySQL のコネクションプーリングで扱っている内容です。サーバーレスプラットフォームでは、このガイドのモジュールレベルのプールパターンによって、1 つのインスタンスが connectionLimit を超える接続を開くことは防げますが、同時に実行される別々のインスタンス間で調整することはできません。インスタンス間で調整するのが、ProxySQL のようなプロキシです。

修正版の接続数は、なぜテストの同時実行数に合わせて 30 まで上昇せず、ちょうど 10 にとどまったのですか?

プールが waitForConnections: true と connectionLimit 10 を持つためです。10 接続が貸し出されると、11 番目の同時リクエストは新しい接続を開くのではなく、接続がプールに返されるのを待ちます。プールが際限なく拡大する代わりに、リクエストが短時間キューに入ります。

トラブルシューティング

関連項目