ブログ一覧

無駄なクエリを削ったらクエリが増えた — エッジ SSR のリクエスト内キャッシュ重複排除と、表示不変を A/B で証明する計測

エッジ SSR(Cloudflare Workers + D1 のようなサーバーレス構成)でページの表示が遅いとき、まず疑うべきは CPU 時間ではなく DB への往復回数 です。実例として、ある記事ページは CPU 時間が 80ms なのに wall 時間(体感の待ち時間)は 4.7 秒でした。差の 98% は I/O 待ち、その正体は D1 への約 20 本のクエリを直列に投げていたことです。エッジではアプリのロジックがどれだけ速くても、DB との往復が積み上がると平気で数秒に届きます。

だからコードレビューで「明らかに無駄なクエリ」を見つけたら、削れば速くなる——と考えるのが自然です。ところが、実際に削ったらクエリ本数が 19 本から 22 本に増えました。本記事は、この直感に反する現象の裏にある「リクエスト内キャッシュの重複排除」という機構と、推測で書き換えず表示が変わっていないことを証明しながら最適化を進めるための計測手法をまとめます。

前提: 構成は Astro(output: "server" の SSR)+ Cloudflare Workers + D1 に、コンテンツ取得を仲介する CMS 層(コレクション API を提供するフレームワーク)を乗せたものを想定します。固有名は出しますが、「フレームワークがリクエスト単位でクエリをキャッシュする」「コレクション取得時に関連データを 1 クエリに畳み込む」といったパターン自体は、この構成に限らず起こりえます。

なぜエッジ SSR ではクエリ「本数」が支配項なのか

ローカルの DB なら 1 クエリの往復は 1ms 未満で、20 本投げても誤差です。エッジ + D1 では話が変わります。Worker と D1 は物理的に離れうるうえ、D1 のクエリはネットワーク往復を伴うため、1 往復あたりのレイテンシがそのまま本数分だけ積み上がります。前掲の「CPU 80ms / wall 4.7 秒」は、まさにこの往復レイテンシ × 直列本数が支配項になっている状態です。

ここから導かれる計測方針はシンプルです。wall 時間ではなくクエリ本数を最適化の主指標にする。wall 時間はローカル計測ではノイズ(初回コンパイル、キャッシュのウォーム状態、マシン負荷)が大きすぎて信用できません。一方でクエリ本数は、本番の往復数にほぼ 1:1 で写像される不変量です。ローカルで本数を 1 本減らせば、本番でも 1 往復ぶん確実に減ります。

反直感の現象: 無駄取得を削ったらクエリが増えた

問題の記事ページには、こういうコードがありました。

// 関連記事を limit:4 で「フル本文つき」で取得
const related = await getCollection("posts", {
  where: { category: post.category },
  limit: 4,
});
// でも表示に使うのは 3 件だけ。しかも本文は読了時間の計算にしか使っていない
const readingMinutes = estimateReadingTime(post.body);

レビューでの指摘はもっともです。「4 件取って 3 件しか出さない」「本文まで引いているのに使い道が読了時間だけ」——どう見ても過剰取得です。そこで、CMS のコレクション API をやめ、必要なカラム(タイトル・slug・日付)だけを取る生 SQL に書き換えました。取得件数も 3 に絞りました。

計測すると、クエリ本数は 19 → 22 に増えました。減るどころか 3 本増えて悪化したのです。

種明かし①: リクエスト内キャッシュの重複排除

原因は、CMS 層が同一リクエスト内で同じ条件のコレクション取得をキャッシュ(メモ化)していたことです。

このページのサイドバーには「最近の投稿」ウィジェットがあり、それが記事本文より先に、同じ posts コレクションを同じ条件で取得していました。フレームワークはその結果をリクエストスコープのキャッシュに載せます。あとから記事本文側が「無駄な」getCollection("posts", ...) を呼んでも、キャッシュキーが一致するのでクエリは飛びません。つまりこの過剰取得は、見た目は無駄でも実コストは 0 だったのです。削っても DB 往復は 1 本も減りません。

さらに悪いことに、生 SQL に書き換えたことでキャッシュの外に出てしまいました。手書きの SQL はフレームワークのキャッシュ層を通らないので、ウィジェットが温めておいたキャッシュに相乗りできず、純粋な新規クエリとして往復が増えます。

種明かし②: byline の畳み込みが JOIN 分離で剥がれた

増えた 3 本のもう一つの内訳がこれです。CMS のコレクション API は、記事を取得するときに著者情報(byline)を1 クエリに畳み込んで(JOIN で同時取得して)返します。ところが生 SQL 化すると、その畳み込みが効かず、著者情報の取得が別クエリに分離します。3 件ぶんの記事に対して著者引きが N 本に散り、往復が純増しました。

「必要なカラムだけ取る」は単体では正しい最適化に見えます。しかしフレームワークが裏でやっていた N+1 の畳み込みを手放す代償のほうが大きければ、全体では悪化します。部分最適が全体を悪化させる典型でした。

同じアンチパターンでも、コストは 0 にも N にもなる

面白いのは、別サイトでは見た目がそっくりのコードが本物のボトルネックだったことです。

// タグごとにループでコレクションを取得(見た目は上と同じ「ループ内クエリ」)
for (const tag of post.tags) {
  const posts = await getCollection("posts", {
    where: { tag: tag.slug },   // where 条件がタグごとに異なる
    limit: 5,
  });
  // ...
}

こちらはリクエスト内キャッシュが効きません。where 条件がタグごとにユニークで、キャッシュキーが毎回変わるからです。結果、タグ数ぶんのクエリが本当にファンアウトします。同じ「ループの中でコレクション取得」でも、①のケースは実コスト 0、こちらは実コスト N。差を分けているのはキャッシュキーが一致するかどうかだけです。

この一点が、コードの見た目だけで最適化を判断してはいけない理由です。「無駄そう」「重そう」は当てになりません。キャッシュキーの一致を実測で確かめない限り、どちらに転ぶかはコードからは読めないのです。

じゃあ、どう計測するのか

推測で書き換えないための手順を、拒否権を持つ計測として組みました。

  • 同一起動中のサーバーでウォーム状態の A/B を取る。 サーバーを立ち上げ、対象ページを数回叩いてキャッシュとコンパイルを温めてから、変更前・変更後を計測します。コールド計測(起動直後の初回リクエスト)は初回コンパイルやキャッシュミスで汚染され、最適化の効果とノイズが分離できません。
  • クエリ本数はフレームワークのクエリログか [`Server-Timing`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Server-Timing) ヘッダで取る。 本数は本番の往復数に写像される不変量なので、ローカルで測ってそのまま本番の改善量として扱えます。
  • 「表示を変えていない」ことはレンダリング後の HTML のバイト一致で証明する。 最適化の前後で同じ URL を叩き、HTML のバイト単位の一致を確認します。1 バイトでも違えば、それは「速くしたつもりで表示を変えた」ということです。バイト一致していれば、表示は不変だと機械的に言い切れます。
  • 悪化したら出荷しない。 計測が拒否権を持ちます。「速くなるはず」という設計上の期待より、ウォーム A/B の実測を優先します。①の生 SQL 化は、この関門で 19 → 22 の悪化が見えたのでそのまま捨てました

実際に効いた最適化

同じ計測ルールで、逆に効いた変更がこちらです。

タグごとループの畳み込み(③のケース)。 タグ数が 3 以上のときだけ、ループを 1 本の IN クエリにまとめる条件付き最適化を入れました。結果、db.count6 → 3。全 32 記事で HTML のバイト一致と並び順の一致を検証し、表示不変を確認したうえで出荷しました。

関連記事の過剰取得の是正。 別ページの関連記事取得は本物の過剰取得(キャッシュに相乗りしていない独立クエリ)だったので、limit を 50 → 9 に絞りました。「9」の根拠は、候補集合に自記事が常に 1 件含まれるため、表示したい 8 件に対して 8 + 1 が最小安全値になるからです。結果、発行される SQL の LIMIT52 → 12、転送量は 185KB → 163KB、そして HTML はバイト一致。表示を一切変えずに往復と転送を削れました。

①(削ると悪化するので捨てる)と、③やこの関連記事是正(削ると改善するので出す)の違いは、コードの見た目からは判別できませんでした。同じ計測を通したから、出すものと捨てるものが分かれたのです。

まとめ

  • エッジ SSR(Cloudflare Workers + D1)では、レイテンシの支配項は CPU ではなく DB への往復回数。CPU 80ms に対して wall 4.7 秒、その 98% は直列クエリの I/O 待ちだった。
  • 「無駄に見えるクエリ」を削ったらクエリ本数が 19 → 22 に悪化した。原因はリクエスト内キャッシュの重複排除——サイドバーのウィジェットが先に温めた同一クエリに相乗りしていたため、過剰取得の実コストは 0 だった。生 SQL 化はキャッシュと byline の畳み込みの両方を手放して純増させた。
  • 同じアンチパターンに見えても、キャッシュキーが一致すればコストは 0、しなければ N。コードの見た目では判別できない。
  • 推測で書き換えない。同一起動中のサーバーでウォーム A/B を取り、クエリ本数(本番往復数に写像される不変量)を主指標にし、HTML のバイト一致で表示不変を証明し、悪化したら出荷しない。計測に拒否権を持たせる。
  • この計測で、タグループ畳み込み(db.count 6 → 3)と関連記事の過剰取得是正(LIMIT 52 → 12、転送 185KB → 163KB)は表示不変のまま出荷でき、悪化する変更は捨てられた。

参考リンク