深夜、ダッシュボードの p95 レイテンシが跳ね上がっているのに気づきました。940ms。前日までは 180ms 台で安定していた一覧APIです。
障害ではありません。エラー率はゼロ、レスポンスの中身も正しい。ただ遅い。本番環境の数字だけが、約 5.2 倍に膨らんでいました。
原因を辿ると、その日の午後にエージェントへ委ねた小さな改修に行き当たりました。一覧に「各アイテムの最新コメント」を足すだけの変更です。差分は 12 行。レビューで私は「読みやすい」とだけ思い、そのまま通していました。
12 行のうちの 1 行が、ループの中でリレーションを引いていました。
正しいコードは、速いコードとは限らない
N+1 クエリの厄介さは、コードとして間違っていないことです。
型は通ります。テストも通ります。返る JSON も期待通りです。レビュアーが目にするのは for ループの中の await getComments(item.id) という、それ自体は何の問題もない一行だけ。呼び出しの回数がリクエストの中身に依存して増えるという事実は、差分のどこにも書かれていません。
エージェントに実装を任せるようになってから、この非対称性が一段と効いてくると感じています。人間なら「ここはループの中だから注意」と身体が反応する場面でも、生成されたコードは常に落ち着いて正しく見えます。私自身、差分の行数が少ないほど油断していました。個人開発で私はこの油断を何度も繰り返しています。
だからレビューの精度を上げる方向で解こうとするのは、筋が悪いと考えるようになりました。人間の注意力に依存しない指標が要ります。
観測できないものは、守れません。
クエリ回数を「テストで読める数値」にする
幸い、主要な ORM はどれもクエリ発行のフックを持っています。ここを使えば、テストの中でクエリ回数を数えられます。
まず Prisma。クライアントを query イベント付きで生成し、カウンタに積むだけです。
// test/support/query-counter.ts
import { PrismaClient } from "@prisma/client";
export const prisma = new PrismaClient({
log: [{ emit: "event", level: "query" }],
});
let count = 0;
const seen: string[] = [];
prisma.$on("query", (e) => {
count += 1;
seen.push(e.query);
});
export function resetQueryCount(): void {
count = 0;
seen.length = 0;
}
export function getQueryCount(): number {
return count;
}
export function getQueries(): readonly string[] {
return seen;
}
Drizzle では logger を差し替えます。
// test/support/drizzle-counter.ts
import { drizzle } from "drizzle-orm/node-postgres";
import type { Logger } from "drizzle-orm/logger";
class CountingLogger implements Logger {
count = 0;
logQuery(query: string): void {
this.count += 1;
}
}
export const logger = new CountingLogger();
export const db = drizzle(pool, { logger });
Python / SQLAlchemy なら before_cursor_execute フックです。
# tests/support/query_counter.py
from contextlib import contextmanager
from sqlalchemy import event
@contextmanager
def count_queries(engine):
counter = {"n": 0}
def _on_execute(conn, cursor, statement, params, context, executemany):
counter["n"] += 1
event.listen(engine, "before_cursor_execute", _on_execute)
try:
yield counter
finally:
event.remove(engine, "before_cursor_execute", _on_execute)
ここまでは単なる計測です。問題は、この数値をどう判定に使うかでした。
「20回以下」という閾値は、なぜ効かないのか
最初に書いたゲートは素朴なものでした。
expect(getQueryCount()).toBeLessThanOrEqual(20);
これは半年もたずに形骸化しました。理由は二つあります。
ひとつは、機能が増えれば正当にクエリも増えるからです。誰かが閾値を 20 から 25 に上げ、また 30 に上げます。上げる判断は毎回もっともらしく、上げた履歴を見返す人はいません。
もうひとつは、N+1 かどうかは絶対数では決まらないからです。テストのフィクスチャがアイテム 3 件なら、N+1 のコードでも 5 クエリで収まります。閾値 20 は静かに通過します。本番でアイテムが 200 件になった瞬間だけ牙をむきます。
見るべきは回数ではなく、入力件数に対する増え方でした。この場合は絶対値の上限を捨て、増え方そのものを不変条件に据えることを推奨します。
| 判定方法 | N=3 のテストで検出できるか | 機能追加への耐性 |
| 絶対数の上限(20回以下) | できない | 低い(閾値が緩み続ける) |
| クエリ文字列の正規表現検査 | 部分的 | 低い(クエリ形が変わると壊れる) |
| 入力件数に対する傾き | できる | 高い(件数非依存が不変条件) |
定数時間で引けている実装は、アイテムが 1 件でも 20 件でもクエリ回数が変わりません。傾きはゼロです。N+1 の実装は、アイテム 1 件増えるごとにクエリが 1 本増えます。傾きは 1 に近づきます。
不変条件として言い換えるなら、「このエンドポイントのクエリ回数は入力件数に依存しない」。これはレビュアーの気分に左右されない、機械が検証できる命題です。
傾きで判定するアサーション
同じシナリオを複数の件数で走らせ、最小二乗法で傾きを出します。
// test/support/assert-constant-queries.ts
import { resetQueryCount, getQueryCount } from "./query-counter";
type Scenario = (n: number) => Promise<void>;
function slope(xs: number[], ys: number[]): number {
const n = xs.length;
const mx = xs.reduce((a, b) => a + b, 0) / n;
const my = ys.reduce((a, b) => a + b, 0) / n;
let num = 0;
let den = 0;
for (let i = 0; i < n; i++) {
num += (xs[i] - mx) * (ys[i] - my);
den += (xs[i] - mx) ** 2;
}
return den === 0 ? 0 : num / den;
}
export async function assertQueryCountIsConstant(
scenario: Scenario,
opts: { sizes?: number[]; maxSlope?: number } = {},
): Promise<void> {
const sizes = opts.sizes ?? [1, 5, 20];
const maxSlope = opts.maxSlope ?? 0.2;
const counts: number[] = [];
for (const n of sizes) {
resetQueryCount();
await scenario(n);
counts.push(getQueryCount());
}
const s = slope(sizes, counts);
if (s > maxSlope) {
throw new Error(
`クエリ回数が入力件数に比例しています(傾き ${s.toFixed(2)})。` +
sizes.map((n, i) => `N=${n}:${counts[i]}回`).join(" / ") +
` — N+1 の疑いがあります`,
);
}
}
使う側はこう書きます。
// test/api/items.list.test.ts
import { assertQueryCountIsConstant } from "../support/assert-constant-queries";
import { seedItems, listItemsWithLatestComment } from "./helpers";
it("一覧APIのクエリ回数はアイテム件数に依存しない", async () => {
await assertQueryCountIsConstant(async (n) => {
await seedItems(n);
await listItemsWithLatestComment();
});
});
maxSlope を 0 ではなく 0.2 にしているのは、実務上の妥協です。ページャの count クエリなど、件数によって条件分岐で 1 本増減する経路が現実には存在します。傾き 0.2 なら、20 件で 4 本増える程度までを許容します。真の N+1 は傾きが 0.9 を超えるので、この幅で取りこぼしたことは今のところありません。
sizes に 20 を含めるかどうかは、シードのコストとの相談です。私は [1, 5, 20] から始め、シードが重いテーブルでは [1, 4, 10] に落としています。3 点あれば傾きは十分に安定します。テーブルのシードが 1 秒を超えるなら、測定点を 3 点に抑えることをお勧めします。
先ほどの障害を起こしたコードにこのアサーションを当てると、N=1:4回 / N=5:8回 / N=20:23回、傾き 1.00 で即座に落ちました。修正後は 4回 / 4回 / 5回、傾き 0.05 です。
CI に置き、エージェントに読ませる
ゲートは CI で走らせて初めて意味を持ちます。テストの一部として実行しつつ、失敗時にどう直すかを出力に含めておくのが肝心でした。
# .github/workflows/perf-gate.yml
name: query-count-gate
on: [pull_request]
jobs:
gate:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: postgres
options: >-
--health-cmd pg_isready --health-interval 10s --health-retries 5
ports: ["5432:5432"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx prisma migrate deploy
- run: npx vitest run test/api --reporter=verbose
そして、エージェントが同じ罠を繰り返さないよう、リポジトリの AGENTS.md に不変条件そのものを書きました。
## データアクセスの規約
- リレーションをループ内で解決しない。`include` / `with` / `selectinload` で一括取得する
- 一覧系エンドポイントを変更したら `assertQueryCountIsConstant` のテストを必ず追加する
- クエリ回数の傾きが 0.2 を超えるコードはマージできない(CI で自動的に落ちる)
エージェントに「N+1 を書かないでください」と依頼しても、抽象的すぎて効きません。「傾き 0.2 を超えると CI が落ちる」という具体的な制約に翻訳して初めて、生成されるコードが変わりました。これは Antigravity のエージェントに限らず、コードを書く相手が人でも同じだと思います。
制約は、検証可能な形にして初めて制約になります。
2週間の運用で分かったこと
数字を並べておきます。個人開発の小さなバックエンド(テーブル 18 個、API 34 本)での実測です。
- 一覧APIの p95: 940ms → 190ms(N+1 の修正後)
- CI 実行時間: +11秒(傾き測定のため同一シナリオを 3 回走らせるコスト)
- 2週間で CI が捕まえた N+1: 3件(うち 2 件はエージェントの生成コード、1 件は私の手書き)
- 誤検知: 1件(
sizes に 1 を含めたため、キャッシュのウォームアップ 1 本が傾きに乗った)
- 導入前後の p95 改善率: 約 80%
誤検知の 1 件は、sizes を [2, 5, 20] に変えて回避しました。ここは実装上の落とし穴で、注意点として書き残しておきます。初回実行だけ走る初期化クエリがある場合、N=1 を測定点に含めると傾きが持ち上がります。ウォームアップの実行を測定前に一度挟むのが本筋ですが、測定点を 2 から始めるだけでも実害はなくなります。
限界も書いておきます。このゲートが見るのはクエリの本数だけです。1 本のクエリがインデックスを外して全表走査していても、傾きはゼロのまま通ります。遅い 1 本は EXPLAIN のコストを別途アサートするか、本番運用の slow query ログで拾って解決するしかありません。
また、キャッシュが効いている環境ではテストが通ってしまいます。クエリカウンタを仕込むテストでは、キャッシュ層を必ず無効化してください。ここを忘れると、ゲートは「常に緑」という最も危険な状態になります。
次の一歩
いま手元のリポジトリで一番アクセスの多い一覧エンドポイントを 1 本選び、assertQueryCountIsConstant のテストを 1 つだけ書いてみてください。
[1, 5, 20] で走らせて、クエリ回数を印字する。それだけで、そのエンドポイントが N+1 かどうかは 3 秒で分かります。私が 940ms の夜に欲しかったのは、まさにその 3 秒でした。