Claude Media
Claude CodeでN+1クエリを見つけて直す — クエリ数をテストで固定する

Claude CodeでN+1クエリを見つけて直す — クエリ数をテストで固定する

ORMのN+1クエリを、クエリログの集計で見つけてincludesやselectinloadで直し、クエリ数を上限つきで検証するテストと、Stop hookで再発を止める設定まで残す手順です。

N+1クエリは、直したあとにクエリ数を固定しないと、次の機能追加で静かに戻ってきます。Claude Codeに任せるなら、「遅い箇所を探して」と頼むだけでは足りません。ログの数え方、直し方の選び方、再発を止めるテストの3点を、手順として渡す必要があります。

この記事では、RailsのActive RecordとPythonのSQLAlchemyを例に、次の流れを組みます。

  1. クエリログから同じ形のクエリの繰り返しを数える
  2. includes・preload・selectinloadなどで読み込みをまとめる
  3. 「クエリ数が上限以下」と検証するテストを残す
  4. Stop hookでテストが赤のまま終わらせない

N+1クエリとは何か — 1回の一覧取得が11回のSQLになる例

N+1クエリとは、N件のレコードを1回のクエリで取ったあと、各レコードの関連データを取るために追加でN回クエリが走る状態です。Railsガイドは、本10冊とその著者の姓を表示する次のコードで説明しています。

books = Book.limit(10)
 
books.each do |book|
  puts book.author.last_name
end

本を探す1回と、本ごとに著者を読む10回で、合計11回のクエリになります。ガイドも「一見よさそうなコード」と書いており、コードを眺めても気づきにくいのが厄介な点です。

SQLAlchemyでも同じ構造です。公式ドキュメントは、N個のオブジェクトを読み込んで遅延読み込みの属性にアクセスすると、N+1回のSELECTが出ると説明しています。

読むだけでは見つからないので、数えて見つけるのが基本方針になります。

ログから同じ形のクエリを数える

N+1の目印は、条件の値だけが違う同じ形のSELECTが何十回も並ぶことです。値を ? に置き換えて数えれば、読む量を減らせます。

grep -E 'SELECT' log/test.log \
  | sed -E "s/[0-9]+/?/g; s/'[^']*'/?/g" \
  | sort | uniq -c | sort -rn | head -5

このワンライナーは、Railsのテストログ(log/test.log)にSELECTが出ている前提の例です。上位に「WHERE authors.id = ?」のような行が数十回の件数で並べば、N+1を疑う根拠になります。ログの出力形式はアプリの設定で変わるので、先頭の grep と sed はプロジェクトに合わせて調整してください。

Claude Codeには、この集計と、集計結果から呼び出し元のコードをたどる作業を任せます。たとえば次のように頼みます。

注文一覧のAPIを1回呼んだときのSELECTを、値を?に置き換えて集計して。
同じ形が5回以上出たクエリについて、どの関連(association)を
遅延読み込みしているかをコードから特定して。まだ修正はしないで。

「まだ修正はしないで」を付けるのがコツです。先に原因の候補と件数を出させておくと、あとで「直ったか」を数字で比べられます。直す前の件数は、PRの説明にもそのまま使えます。

落とし穴

テストデータが1件だと、N+1は数字に出ない

N+1は件数に比例して増えます。ファクトリが親レコードを1件しか作らないテストでは、遅延読み込みでも合計2回で、上限チェックを素通りします。クエリ数を検証するテストには、親を3件以上作るデータを用意してください。1件ずつ増えて見えるのは、3件以上からです。

直し方の選び方 — includes・preload・eager_load

Railsには関連を先読みするメソッドが3つあります。ガイドは、迷ったら高水準の includes を使うよう勧めています。includes は、条件に応じて preload か eager_load のどちらかの方式を選びます。

メソッド発行されるクエリ先の例での回数
includes発行されるクエリ条件なしなら関連ごとに別クエリ(IN で一括取得)先の例での回数2回
preload発行されるクエリ関連ごとに別クエリ。関連への条件は指定できない先の例での回数2回
eager_load発行されるクエリLEFT OUTER JOIN で1回にまとめる先の例での回数1回

先の10冊の例は、Book.includes(:author).limit(10) に直すと、本の取得と WHERE authors.id IN (1,2,...,10) の2回になります。11回が2回に減るので、これが「直した」の目安です。

Claudeに直させるときは、メソッドを指定せずに任せず、次の2点を明示すると手戻りが減ります。

  • 関連に where の条件を足す箇所があるか(あれば includes はJOIN側に切り替わる)
  • 一覧の表示に実際に使う関連だけを先読みする(使わない関連まで足さない)

SQLAlchemyでは、selectinload が先の includes に近い役割です。公式ドキュメントは、2回目のSELECTが addresses.user_id IN (5, 7) の形になる例を載せています。

from sqlalchemy import select
from sqlalchemy.orm import selectinload
 
stmt = select(User).options(selectinload(User.addresses))

テストにクエリ数の上限を入れる

直しただけでは、再発を止められません。次に進める人が includes を外しても、画面は同じに見えるからです。クエリ数の上限をassertするテストを、修正と同じコミットに入れます。

Railsでは、Active Supportの計装(instrumentation)が sql.active_record というイベントを出します。ペイロードにはSQL文(:sql)などが入っているので、テスト中だけ購読して数えられます。

# spec/support/query_counter.rb(例)
def count_select_queries
  count = 0
  counter = ->(*, payload) { count += 1 if payload[:sql].start_with?("SELECT") }
  ActiveSupport::Notifications.subscribed(counter, "sql.active_record") { yield }
  count
end
# spec/requests/orders_spec.rb(例)
it "注文一覧はクエリ数が上限以下" do
  create_list(:order, 3, :with_items)
 
  count = count_select_queries { get "/orders" }
 
  expect(count).to be <= 4
end

上限の「4」は例で、実際のアプリではもっと大きくなります。置き方のコツは、現在の件数をそのまま上限にせず、直したあとの実測値に少しだけ余裕を持たせることです。さらに、親を3件と30件に増やしても上限が変わらないことを確かめれば、件数に比例しない(N+1ではない)ことの証明になります。

SQLAlchemyの場合は、エンジンのイベントで同じことができます。

# tests/query_counter.py(例)
from contextlib import contextmanager
from sqlalchemy import event
 
@contextmanager
def count_queries(engine):
    statements = []
 
    def before(conn, cursor, statement, *args):
        statements.append(statement)
 
    event.listen(engine, "before_cursor_execute", before)
    try:
        yield statements
    finally:
        event.remove(engine, "before_cursor_execute", before)
def test_users_list_query_count(engine, client, make_users):
    make_users(3)
    with count_queries(engine) as statements:
        client.get("/users")
    assert len(statements) <= 3

どちらのテストも、先に「修正前のコードで赤になること」を確かめてから直させてください。Claude Codeに「テストを書いて、修正前のコミットで失敗することを確認し、それから修正して」と順番を指定すると、上限が緩すぎて常に通るテストを避けられます。

遅延読み込みそのものを禁止する

上限を数えるテストは、書いた画面にしか効きません。ORMには、遅延読み込みが起きた瞬間にエラーにする仕組みがあり、こちらは網羅的です。

  • Rails: strict_loading を有効にすると、遅延読み込みを試みた時点で ActiveRecord::StrictLoadingViolationError が上がる。全モデルに適用するなら config.active_record.strict_loading_by_default = true。エラーにせずログに出すだけなら action_on_strict_loading_violation を :log にする
  • SQLAlchemy: raiseload() が遅延読み込みをエラーに置き換える。raiseload("*") で全リレーションが対象になり、joinedload(Order.items) と組み合わせれば「items だけ先読みして残りは禁止」と書ける

テスト環境だけで有効にしておくと、N+1を新しく書いた時点でテストが落ちます。既存のコードが大量に落ちる場合は、まず :log で件数を見てから厳しくしていく進め方になります。

落とし穴

includesの条件付きで、先読みの方式が変わる

includes に where(books: { out_of_print: true }) のような条件を足すと、2回の別クエリではなく LEFT OUTER JOIN の1クエリになります。クエリ数の上限テストは通っても、SQLの形も実行時間も変わっています。SQL断片の文字列で条件を書くときは references も必要です。さらに includes の付いたリレーションに pluck を使うと、不要でも先読みが走ります。避けるには unscope(:includes) を挟みます。

Stop hookでテストが赤のまま終わらせない

テストを置いても、Claudeが作業を終えるときに実行されなければ意味がありません。Claude Codeには、Claudeが応答を終えるタイミングで動くStop hookがあり、exit code 2で終わると、止まらずに会話を続けさせられます。そのとき標準エラー出力が、Claudeへの理由として渡されます。

手順

Stop hookでクエリ数テストを回す手順

  1. 1

    スクリプトを置く

    .claude/hooks/query-count-check.sh を作り、クエリ数のテストだけを実行します。全テストではなく対象を絞ると、毎回の待ち時間を抑えられます。

  2. 2

    settings.jsonに登録する

    プロジェクト共有なら .claude/settings.json に書きます。コマンドのパスは ${CLAUDE_PROJECT_DIR} で指定します。

  3. 3

    暴走を防ぐ

    stop_hook_active が true のときは、すでにhookの結果で続行中です。同じ失敗で無限に止め続けないよう、この値を見て抜けます。

#!/bin/bash
# .claude/hooks/query-count-check.sh(例)
INPUT=$(cat)
 
# すでにStop hookで続行中なら、再度ブロックしない
if [ "$(echo "$INPUT" | jq -r '.stop_hook_active')" = "true" ]; then
  exit 0
fi
 
if ! OUT=$(bundle exec rspec spec/query_counts 2>&1); then
  echo "クエリ数のテストが失敗しています。N+1が増えていないか確認して直してください。" >&2
  echo "$OUT" | tail -30 >&2
  exit 2
fi
exit 0
{
  "hooks": {
    "Stop": [
      {
        "hooks": [
          {
            "type": "command",
            "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/query-count-check.sh",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

timeout を指定しないとコマンドhookは600秒まで待つため、テストの実行時間に合わせて短く指定しておくと安心です。Claude Codeには、Stop hookが続けて8回ターンを継続させたら、次のブロックを無視して終わらせる上限もあります。直らないテストで延々と回り続けることはありませんが、それは裏を返せば、8回で直せなければ赤いまま止まるということです。再発火の挙動はClaude Codeの/goal Stop hookが止まらない原因と回避策でも扱っています。

PostToolUseで編集のたびに回す方法もありますが、PostToolUseのexit 2は、ツールがすでに実行済みなので止められず、標準エラー出力をClaudeに見せるだけです。クエリ数のテストは1回に数秒以上かかるため、編集ごとではなく、作業の終わりに1回回すStopのほうが向いています。

作業の流れと、使い分けの目安

最後に、手順を1枚にまとめます。

段階Claude Codeへの指示確認する数字
計測Claude Codeへの指示修正はせず、SELECTを形ごとに集計させる確認する数字同形クエリの件数
テストClaude Codeへの指示修正前に落ちるクエリ数テストを書かせる確認する数字親3件での実測値
修正Claude Codeへの指示使う関連だけを includes・selectinload で先読みさせる確認する数字11回から2回など
固定Claude Codeへの指示strict_loading・raiseload、Stop hookを入れる確認する数字テストが緑のまま

個別のクエリが遅いと分かっているなら、方針は別です。実行計画を見て索引を足すのが先で、データベース側の診断をClaudeに任せる設定はCockroachDBプラグインの記事にあります。N+1は1回ごとは速いクエリが大量に積み上がる問題なので、実行計画ではなく回数を見るのが筋です。

pytestでの検証の組み立て方はCeleryのタスクをpytestで検証する記事にあり、モックで内側の呼び出しを差し替える考え方はクエリ数のテストにも流用できます。

CLAUDE.mdに書いておく規約

毎回の指示で手順を渡すのは大変なので、繰り返し使う部分はCLAUDE.mdに置きます。次はRailsの例で、プロジェクトに合わせて書き換える前提の断片です。

## クエリ数の規約
- 一覧系のエンドポイントを変更したら、spec/query_counts に上限テストを追加する
- テストデータの親レコードは3件以上にする
- 関連を使う箇所では includes または preload を明示し、遅延読み込みを残さない
- 修正前に落ちるテストを先に書き、落ちることを確認してから直す
- 上限を緩めるときは、理由をコミットメッセージに書く

最後の1行が地味に効きます。テストが落ちたとき、Claudeが最も手軽に直す方法は上限の数字を上げることです。理由の記述を求めておけば、数字だけ変える変更がレビューで目につきます。

まとめ

N+1の修正は、includes を足す作業そのものより、直ったことを数字で示し、戻ったら落ちる状態にするところに価値があります。ログの集計で件数を出し、親3件以上のデータで上限をassertし、Stop hookで赤のまま終わらせない。この3つを最初の指示に含めておけば、次にN+1が入ったときは、人間のレビューより先にテストが気づきます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/hooks
  2. guides.rubyonrails.org/active_record_querying.html
  3. guides.rubyonrails.org/active_support_instrumentation.html
  4. docs.sqlalchemy.org/en/20/orm/queryguide/relationships.html
この記事を共有:XはてブLinkedIn