ブログ一覧

D1 の無駄なクエリを削ったらクエリ数が増えた原因

更新: Web開発者向け

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

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

前提: 構成は Astro(output: "server" の SSR)+ Cloudflare Workers + D1 に、コンテンツ取得を仲介する Astro 製の CMS「EmDash」(コレクション 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 件しか出さない」「本文まで引いているのに使い道が読了時間だけ」という指摘はもっともで、どう見ても過剰取得です。そこで、EmDash のコレクション API(getEmDashCollection)をやめ、必要なカラム(タイトル・slug・日付)だけを取る生 SQL に書き換えました。取得件数も 3 に絞りました。

計測すると、クエリ本数は 19 → 22 と 3 本増えました。

原因①: リクエスト内キャッシュの重複排除

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

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

さらに、手書きの SQL はフレームワークのキャッシュ層を通らないので、生 SQL に書き換えたクエリはウィジェットが先に載せたキャッシュを使えず、新規クエリとして往復が増えます。

原因②: 生 SQL では byline の取得が別クエリに分かれた

増えた 3 本のもう一つの内訳は著者情報(byline)の取得です。EmDash のコレクション API は、記事を取得するときに byline を JOIN で同時に取得して 1 クエリで返しますが、生 SQL に書き換えると著者情報の取得が別クエリに分かれ、3 件ぶんの記事に対して著者の取得が N 本に増えて往復が純増しました。

「必要なカラムだけ取る」は単体では正しい最適化に見えますが、フレームワークが N+1 を 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 とバイト一致で計測する

推測で書き換えないために、計測の結果で出荷するかを決める手順を組みました。

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

表示を変えずに出荷できた最適化

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

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

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

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

まとめ

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

この記事をシェア