AI生成API向けのGo + Postgresパフォーマンスチューニングプレイブック:接続プール、クエリプラン確認、賢いインデックス設計、安全なページング、JSONの高速整形。

AI 生成の API は初期テストでは速く感じることが多いです。エンドポイントを数回叩き、データセットは小さく、リクエストは一度にひとつだけ来る。ところが本番トラフィックが来ると、エンドポイントが混ざり合い、バーストが発生し、キャッシュが冷え、想定より多くの行が入っていることに気づきます。同じコードが「たまに」遅く感じられるようになりますが、実は何かが壊れているわけではありません。
遅さは通常、いくつかの現象で現れます:レイテンシのスパイク(大多数は問題ないが一部が5x〜50x長くなる)、タイムアウト(小さな割合が失敗する)、あるいは CPU の高負荷(クエリによる Postgres の CPU、または JSON、goroutine、ログ、リトライでの Go 側の CPU)。
よくあるシナリオは、柔軟な検索フィルタを持つ一覧エンドポイントで大きな JSON を返すケースです。テスト DB では数千行をスキャンして素早く終わるかもしれませんが、本番では数百万行をスキャンしてソートし、最後に LIMIT を適用していることがあります。API は動作しているものの p95 レイテンシが爆上がりし、バースト時にいくつかのリクエストがタイムアウトします。
DB の遅さとアプリの遅さを分けて考えるとシンプルになります。
"ローンチ前に十分" であることは完璧を意味しません。多くの CRUD エンドポイントでは、安定した p95(平均ではない)、バースト時に予測可能であること、想定ピークでタイムアウトが発生しないことを目指してください。目的は明快です:データとトラフィックが増えても驚きの遅延が起きないこと、そして何かが逸脱したときに明確なシグナルがあることです。
何かをチューニングする前に、API の「良い」を決めてください。ベースラインがないと、設定をいじっても改善したのか単にボトルネックを別の場所に移しただけなのか分からなくなります。
通常、次の3つの数字で多くが分かります:
p95 は「悪い日の」指標です。p95 が高くて平均が問題ない場合、少数のリクエストがやりすぎているか、ロックで詰まっているか、あるいは遅いプランを引いている可能性があります。
スロークエリを早めに可視化してください。Postgres ではプレローンチのテスト時に低めの閾値(例:100–200 ms)でスロークエリログを有効にし、フルステートメントをログに残すと SQL クライアントで再実行して調査しやすくなります。これは一時的にしておきましょう。本番で全てのスロークエリをログするとノイズが急増します。
次に、実際のリクエストに近いものをテストしてください。単一の "hello world" ルートだけでは不十分です。小さなセットで十分ですが、それがユーザーの実際の使い方に合っていることが重要です:フィルタとソートを持つ一覧、いくつかの JOIN を含む詳細ページ、検証を行う作成/更新、部分一致を使う検索スタイルなど。
もし仕様からエンドポイントを生成しているなら(たとえば Koder.ai のようなツールで)、同じ少数のリクエストを一貫した入力で繰り返し実行してください。これによりインデックスやページング、クエリ書き換えの効果を測りやすくなります。
最後に、口に出して言える目標を決めてください。例:「50 同時ユーザーで大多数のリクエストは p95 200 ms 未満、エラーは 0.5% 未満」。具体的な数値はプロダクト次第ですが、明確な目標があればエンドレスな微調整を防げます。
接続プールは開かれた DB 接続の数を制限し、それらを再利用します。プールがないと各リクエストで新しい接続を開き、Postgres はセッション管理に時間とメモリを使ってしまいます。
目的は Postgres を有用な仕事で忙しくさせ、過剰な接続間のコンテキストスイッチを避けることです。これは特にチャットっぽく多くの短いリクエストを発する AI 生成 API で最初の大きな改善になることが多いです。
Go では通常、最大オープン接続数、最大アイドル接続数、接続寿命を調整します。小さな API の安全な出発点は CPU コア数の小さな倍数(多くは合計で5〜20接続)、似た数をアイドルに保ち、30〜60分ごとに接続を再利用する、などです。
複数の API インスタンスを動かしている場合、プールは乗算されることを忘れないでください。たとえば 20 接続のプールが 10 インスタンスなら Postgres には 200 接続が来る計算になり、思わぬ接続上限にぶつかります。
プール問題は遅い SQL とは異なる振る舞いをします。
素早く切り分ける方法は、DB 呼び出しを「接続待ち時間」と「クエリ実行時間」の2つに分けて計測することです。大部分が "待ち" ならプールがボトルネックです。大部分が "クエリ内" なら SQL とインデックスに注力します。
便利な確認事項:
max_connections までの余裕を監視する。pgxpool は Postgres に最適化されたプールで明確な統計と良いデフォルトが得られます。database/sql は DB 間で共通のインターフェースを提供しますが、プール設定やドライバの挙動を明示的に調整する必要があります。
実用ルール:Postgres に集中して直接制御したければ pgxpool が使いやすいことが多いです。ライブラリが database/sql を期待しているならそちらに従い、プール設定を明確にして待ち時間を測ってください。
例:注文一覧を返すエンドポイントが単体では 20 ms なのに 100 同時ユーザーで 2 s まで跳ね上がる。ログで 1.9 s が接続待ちになっているなら、クエリチューニングでは改善せず、プールと Postgres 側の総接続数の調整が先です。
エンドポイントが遅ければ、Postgres が実際に何をしているかを確認してください。EXPLAIN を素早く読むだけで数分で解決策が見つかることが多いです。
API が送る正確な SQL に対して次を実行します:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, status, created_at
FROM orders
WHERE user_id = $1 AND status = $2
ORDER BY created_at DESC
LIMIT 50;
いくつかの行を重点的に見ます。Postgres が選んだトップノードと下部の合計時間(実行時間)を確認し、推定行数と実際の行数の差を比べてください。大きなギャップはプランの誤推定を示します。
一般的なレッドフラグ:
ORDER BY と組になったソートが時間の大半を占めている遅いプランは通常、いくつかのパターンから生じます:
(user_id, status, created_at) のような複合)WHERE lower(email) = $1)とインデックスが使われないことがある(表現インデックスで対応可能)プランが変に見えて推定が大きく外れている場合、統計が古いことが多いです。ANALYZE を実行するか autovacuum が追いつくのを待たせて、Postgres に最新の行数や値の分布を学習させてください。大量のインポート後や新しいエンドポイントが急に多くの書き込みを始めたときに特に重要です。
インデックスは、実際に API が投げる問いに合っているときにのみ役立ちます。推測だけで作ると、書き込みが遅くなり、ストレージが増え、ほとんど速度向上が得られないことがあります。
考え方としては:インデックスは特定の質問への近道です。API が別の質問をしているなら Postgres はその近道を使いません。
エンドポイントが account_id で絞って created_at DESC でソートするなら、1つの複合インデックスは別々のインデックスより優れることが多いです。必要な行を見つけ、正しい順序で返す作業を軽くできます。
指針:
例:GET /orders?status=paid で常に新しい順に表示するなら (status, created_at DESC) が良い適合です。大部分のクエリが customer も絞るなら (customer_id, status, created_at) が合う場合もありますが、本番でそのエンドポイントが実際にどう実行されているかに依存します。
トラフィックの大半が狭いスライスに集中するなら、パーシャルインデックスの方が安く速くなることがあります。たとえばアクティブなレコードだけを主に読むなら、WHERE active = true のインデックスは小さくメモリに残りやすくなります。
インデックスの有効性を確かめるには:
EXPLAIN(安全な環境では EXPLAIN ANALYZE)を実行し、クエリに対してインデックススキャンが使われているか確認する。未使用のインデックスは慎重に削除してください。使用統計(インデックスがスキャンされたかどうか)を確認し、低リスクな時間帯に1つずつドロップし、ロールバックプランを用意しましょう。未使用のインデックスは無害ではありません。書き込み時にすべての更新で余分な作業が増えます。
ページングは速い API が遅く感じられ始めるポイントです。ページングは UI の細部ではなくクエリ設計の問題として扱ってください。
LIMIT/OFFSET は見た目は簡単ですが、深いページはコストが増えます。Postgres はスキップする行を辿る必要があり(多くの場合ソートも)、1ページ目は数十行で済んでも、500ページ目は数万行をスキャンして捨てる必要が出ます。
また、リクエスト間で行が挿入・削除されると結果が不安定になり、ユーザーは重複や欠落を経験するかもしれません。
キーセットページングは "最後に見た行の次の20行をください" という問い方をします。これにより DB は小さく一貫したスライスを扱います。
単純な例(増分 id の場合):
SELECT id, created_at, title
FROM posts
WHERE id > $1
ORDER BY id
LIMIT 20;
API は next_cursor としてページの最後の id を返します。次のリクエストはその値を $1 に使います。
時系列ソートでは安定した順序とタイブレーカーが必要です。created_at だけでは同じタイムスタンプを持つ行があると不十分なので複合カーソルを使います:
WHERE (created_at, id) < ($1, $2)
ORDER BY created_at DESC, id DESC
LIMIT 20;
重複や欠落を防ぐためのルール:
ORDER BY に一意のタイブレーカー(通常は id)を必ず含める。created_at と id を一緒にエンコードする)。驚くほどよくある遅延原因はデータベースではなくレスポンスそのものです。大きな JSON は組み立てに時間がかかり、送信に時間がかかり、クライアント側のパースにも時間がかかります。最も簡単な改善は返す量を減らすことです。
まず SELECT を見直してください。エンドポイントが id, name, status のみを必要とするなら、その列だけを要求します。SELECT * はテーブルに長いテキストや JSON、監査列が増えると静かに重くなります。
もうひとつの遅延原因は N+1 パターンです:50 件のリストを取ってから関連データのために 50 回追加クエリを実行する。テストは通っても本番負荷で崩壊します。慎重な JOIN や ID バッチの 2 クエリ構成を優先してください。
ペイロードを小さく保つための方法:
include= フラグや fields= マスクを使って一覧は軽く、詳細はオプトインにする。どちらも高速にできます。最適化したい点で選んでください。
jsonb_build_object, json_agg)はラウンドトリップを減らし、一つのクエリで予測可能な形を返すときに便利です。良いルールは:Postgres にフィルタ・ソート・集計を任せ、最終的な表現は Go 側で行うことです。
迅速に API を生成する場合(たとえば Koder.ai を使うとき)は、初期から include フラグを入れておくと、後でエンドポイントが肥大化するのを防げます。これによりフィールド追加が安全になり、すべてのレスポンスが重くなるのを避けられます。
大きな検証ラボは不要です。短い、繰り返し可能な手順で多くの障害になりうる問題を発見できます。生成コードを出発点にする場合、トラフィックが増えたら障害になる問題を事前に洗い出すことが重要です。
何かを変える前に小さなベースラインを書き留めてください:
小さく始め、1 回に 1 つだけ変え、変更後に再テストします。
実際の利用に近い 10〜15 分の負荷テストを実行します。最初のユーザーが触るであろうエンドポイント(ログイン、一覧、検索、作成)を攻め、ルートを p95 と総時間でソートします。
SQL 調整の前に接続圧をチェックします。プールが大きすぎると Postgres を圧倒するし、小さすぎると長い待ちが発生します。接続取得待ち時間と接続数のスパイクを見て、まずプールと idle 制限を調整し、同じ負荷を再実行します。
最も遅いクエリに対して EXPLAIN を実行し、最大のレッドフラグを修正します。典型的なのは大テーブルのフルスキャン、巨大なソート、行数が爆発する JOIN です。最も悪いクエリを選んで「普通」にすることを目指します。
インデックスを 1 つ追加または調整し、再テストします。インデックスは WHERE と ORDER BY に合うと効果的です。一度に5つも追加しないでください。
レスポンスとページングを引き締め、再テストします。50 行の大きな JSON を返しているなら DB・ネットワーク・クライアントがすべて負担を負います。UI が必要とする列だけを返し、テーブルが成長しても遅くならないページングを選んでください。
シンプルな変更ログを残してください:何を変えたか、なぜか、p95 がどう動いたか。変化が改善をもたらさなければ戻して次に進みます。
多くの Go+Postgres のパフォーマンス問題は自己責任で発生します。良いニュースは、いくつかのチェックで本番トラフィック前に多くを防げることです。
古典的な罠はプールサイズを "できるだけ大きく" することです。大きくすればするほど遅くなることがあり、Postgres はセッション間の管理に時間を使い、アプリは波状的にタイムアウトを出します。小さく安定したプールで予測可能な並列性を保つ方が勝つことが多いです。
もう一つの間違いは "全部にインデックスを張る" ことです。読み取りの助けにはなりますが、書き込みを遅くし、クエリプランを思わぬ形で変えることがあります。追加前後で計測し、インデックス追加後はプランを再確認してください。
ページングの負債は静かに忍び寄ります。Offset ベースは早期には問題ないように見えても、時間経過で p95 が上がります。
JSON ペイロードのサイズも隠れた負擔です。圧縮は帯域を助けますが、構築・割当・パースのコストはなくなりません。フィールドを削り、深いネストを避け、画面が本当に必要とするものだけを返してください。
平均応答時間だけを見ていると、実際のユーザー痛点を見逃します。p95(場合によっては p99)がプール飽和、ロック待ち、遅いプランを最初に示します。
プレローンチでの簡単な自己チェック:
EXPLAIN を再実行する。実ユーザーが来る前に、ストレス下で API が予測可能に動く証拠を欲します。目的は完璧な数値ではなく、タイムアウトやスパイク、本当に仕事を受け付けなくなる DB を招く問題を捕まえることです。
ステージング環境(本番に近い DB サイズ、同じインデックス、同じプール設定)でチェックを行ってください:主要ルートごとの p95 を負荷下で計測し、合計時間で上位のスロークエリを収集し、最悪クエリに対して EXPLAIN (ANALYZE, BUFFERS) を行い期待するインデックスが使われているか確認し、最も忙しいルートのペイロードサイズを簡易検査します。
その後、プロダクトが壊れる様子を模したワーストケースを一回実行します:深いページを要求し、最も広いフィルタを適用し、コールドスタート(API を再起動して同じリクエストを最初に投げる)で試す。深いページングがページを進むごとに遅くなるなら、ローンチ前にカーソルベースページングに切り替えてください。
デフォルトを文書化してチームが後で一貫した選択をできるようにします:プール制限とタイムアウト、ページングルール(最大ページサイズ、offset を許可するか、カーソル形式)、クエリルール(必要な列のみ選択、SELECT * を避ける、コストの高いフィルタを制限)、ログルール(スロークエリ閾値、サンプル保持期間、エンドポイントのラベリング)。
Koder.ai で Go + Postgres サービスを生成・エクスポートする場合、デプロイ前に短いプランニングを行うとフィルタ、ページング、レスポンス形状を意図的に設計できます。インデックスやクエリ形状のチューニングを始めたら、スナップショットとロールバックで "助けになるが他を壊す" 修正を元に戻せるようにしておくと安全です。Koder.ai(koder.ai)上では、チャットでこれらのサービスを生成して改善し、準備ができたらソースをエクスポートするワークフローが想定されています。
まず DB 待機時間 と アプリ側の処理時間 を分離して計測します。
「接続を待っている時間」と「クエリ実行時間」を簡単に計測して、どちらが支配的かを見てください。
再現可能な小さなベースラインを作ってください:
例として「50 同時ユーザー時に p95 が 200 ms 未満、エラー率は 0.5% 未満」のように声に出して言える目標を決めます。変更は一度に一つずつ行い、同じリクエスト構成で再テストしてください。
プレローンチのテストでは低めの閾値(例:100–200 ms)でスロークエリログを有効にし、フルステートメントをログに残すと SQL クライアントへコピーして調査しやすくなります。
ただし本番でずっと有効にするのは避けてください:
最悪のクエリを見つけたら、サンプリングに切り替えるか閾値を上げます。
実用的なデフォルトは API インスタンスの CPU コア数の小さな倍数 です。多くの小規模 API では max open connections を 5〜20 の範囲で始め、idle も同様に保ち、接続を 30〜60 分ごとに再生するのが安全です。
失敗モード:
注意:プールはインスタンス数で掛け算されます(20 接続 × 10 インスタンス = 200 接続)。
DB 呼び出しを2つに分けて計測します:
大部分が接続待ちならプールがボトルネックです。インスタンス数やプールサイズ、タイムアウトを調整してください。クエリ実行が大部分なら EXPLAIN とインデックスに注力します。
また、rows を確実に Close して接続を素早く返していることを確認してください。
EXPLAIN (ANALYZE, BUFFERS) を API が投げている正確な SQL に対して実行し、次を確認します:
ORDER BY に伴う Sort が時間を支配している最上位ノード(Postgres が選んだ実行ノード)と下部の合計時間を見て、問題点を特定してください。
エンドポイントが実際に行うクエリに合わせてインデックスを作ること。WHERE と ORDER BY の組み合わせに合う複合インデックスが単体インデックスより有効なことが多いです。
実務上の指針:
例: で絞って で新しい順に返すなら が合うことが多いです。
トラフィックの大部分がテーブルの狭いスライスに集中するなら、パーシャルインデックスが有効です。例えば active = true の行がほとんど読み出されるなら、WHERE active = true のインデックスは小さくメモリに入りやすく、書き込み負荷も抑えられます。
使う前に EXPLAIN でそのインデックスが本当に選ばれるかを確認してください。
LIMIT/OFFSET は深いページで遅くなります。Postgres はスキップする行を辿る必要があり、ページ番号が深くなるとコストが上がるからです。また、挿入や削除によって結果が不安定になりやすく、重複や欠落が出ることがあります。
代わりに キーセット(カーソル)ページング を推奨します:
(created_at, id) のような複合カーソルをエンコードして返す。これにより各ページのコストはテーブル成長によらずほぼ一定になります。
通常ははい、一覧系のエンドポイントではペイロードを減らすべきです。送らないレスポンスが最速です。
実用的な対策:
SELECT * を避ける)。include= や fields= を用意する。user_idcreated_at DESC(user_id, created_at DESC)これだけで Go の CPU/メモリ負荷やテールレイテンシがかなり改善することが多いです。