Tech Learning Daily

2026-09-10 (Thu) — 第 55 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Perf 🌿 基礎 ⏱ 約 7 分

一覧画面をちょっと表示しただけなのに、なぜ DB へのクエリが何十回も飛ぶのか?

あなたも昨日、一覧画面に関連データを足すコードを書いたはずだ。Post.all を取ってきて、ビューで post.author.name と書くだけ。ローカルのシードデータ 10 件では一瞬で表示された。ところが本番でレコードが数千件に増えた瞬間、同じコードのまま画面が急に重くなる。コードは一行も変えていないのに、なぜだろうか。

🎯 3 行まとめ
  • post.author.name のように関連レコードをループの中で参照すると、Rails は関連ごとに別の SELECT を発行する。本を 10 件取得して著者名を表示する公式ガイドの例では 1(本)+10(著者)=11 回のクエリになる。これが N+1 問題だ。
  • 原因は Active Record の遅延ロード(lazy loading)にある。関連は宣言した時点ではなく実際にアクセスされた瞬間に初めて SQL を発行する設計になっているため、レコードごとに 1 回ずつ律儀に問い合わせてしまう。
  • includes / preload / eager_load で事前にまとめて読み込めば、同じ例は 2 回、条件次第では 1 回まで減らせる。ただし挙動が異なるため使い分けが要り、strict_loading を使えば混入を機械的に検知できる。

同じコードが、件数が増えた瞬間に牙をむく

一覧画面で関連データを表示するコードは、たいてい次のような形をしている。まず一覧の元になるレコードを 1 回の SELECT で取得し、その後 each の中で関連先のレコードに触れる。Ruby on Rails の公式ガイドが挙げている例では、10 冊の本を取得してそれぞれの著者名を表示するコードは「1(本を取得する 1 回)+10(本 1 冊ごとに著者を取得する 1 回ずつ)=11」回のクエリを発行する。件数が 10 件ならまだ誤差の範囲だが、本番で対象が数千件に増えると、発行されるクエリの数もそれに比例して増えていく。ローカルの開発環境で気づきにくいのは、シードデータの件数が少ないうちはこの比例関係が誤差として埋もれてしまうからだ。

この「1 回の取得で済むはずが、件数に応じて追加のクエリが発生し続ける」現象を N+1 問題と呼ぶ。N は対象レコードの件数、+1 は最初の一覧取得の分を指す。同じ構造の問題は Active Record に限らず、関連先のデータを後から取りに行く設計を持つ ORM や、GraphQL のリゾルバなど、似た仕組み全般で起こりうる。

N+1(1本ずつ著者を問い合わせる)
┌─────────────────────┐
│ SELECT * FROM books │
└─────────────────────┘
  → 1 回
          ▼ 本の件数だけ繰り返す
┌───────────────────────┐
│ SELECT * FROM authors │
│ WHERE id = ?          │
└───────────────────────┘
  → N 回

eager_load(LEFT JOIN で 1 回に集約)
┌──────────────────────────────┐
│ SELECT books.*, authors.name │
│ FROM books                   │
│ LEFT OUTER JOIN authors      │
└──────────────────────────────┘
  → 1 回

なぜ律儀に 1 件ずつ問い合わせてしまうのか

Active Record の has_many / belongs_to といった関連は、宣言した時点では何も問い合わせない。実際にその関連に .author のようにアクセスした瞬間に初めて SELECT を発行する。これを遅延ロード(lazy loading)と呼ぶ。この設計自体は理にかなっている。もし関連を宣言しただけで毎回 JOIN していたら、画面上でその関連を実際には使わないケースでも、余計なデータを毎回フェッチすることになるからだ。

ところが遅延ロードは「ループの中で毎回呼ばれる」使われ方をすると、逆に不利に働く。関連へのアクセスはレコードごとに独立して発生するため、Active Record 側には、それが「まとめて先読みしてよい呼び出し」なのか「たまたま 1 件だけ必要になった呼び出し」なのかを区別する手段がない。結果として、律儀に 1 回ずつ SQL を発行するのが遅延ロードにとって唯一の安全な選択になる。

🍱 たとえるなら

宅配便を思い浮かべてほしい。荷物 1 個の重さそのものは大した負荷ではない。負荷の正体は荷物ではなく「トラックが 1 回出発して戻ってくるまでの往復時間」だ。荷物 10 個を 10 回に分けて 1 個ずつ配達すると、往復にかかる時間が 10 回分積み上がる。同じ 10 個を 1 回のトラック便にまとめれば、往復は 1 回で済む。DB へのクエリも同じ構造で、1 回のクエリが運ぶデータ量よりも、アプリケーションと DB サーバーを 1 往復するオーバーヘッドのほうが支配的になりやすい。N+1 問題が件数に比例して遅くなるのは、この「往復回数」が件数と同じペースで増えていくからだ。

includes / preload / eager_load の使い分け

この往復回数を減らす代表的な方法が、事前に関連をまとめて読み込む Eager Loading(先読み)だ。代表的なメソッドが 3 つあり、それぞれ挙動が異なる。eager_load は常に LEFT OUTER JOIN で 1 回のクエリにまとめる。preload は常に個別のクエリを発行して先読みするが、公式ガイドが明記する通り「先読みした関連に対して条件を指定することはできない」という制約がある。includes はこの 2 つのいいとこ取りで、関連先に where などの条件が付いているかどうかを見て、条件が無ければ preload 相当、条件があれば eager_load 相当の JOIN に自動で切り替える。

# 1(本)+10(著者)=11 回になる書き方
Book.all.each { |b| puts b.author.name }

# includes で先読みすると 2 回に減る
Book.includes(:author).each { |b| puts b.author.name }

どれを選ぶかは「JOIN した結果に対してさらに絞り込みや並び替えをしたいか」で決まる。関連先の条件で絞り込みたいなら eager_loadjoins を使い、単に N+1 を消したいだけなら includes に任せるのが基本になる。ここで見落としやすいのが、includes は書きさえすれば常に安全というわけではない点だ。関連先のカラムに対して where を書いた瞬間、Rails は内部で JOIN に切り替えるため、意図せず巨大な結合クエリが発行されることがある。

N+1 は Rails だけの問題ではない、検知はどうするか

気づかないうちに N+1 が紛れ込むのを防ぐ仕組みとして、Rails には strict_loading がある。User.strict_loading.first.posts のように、遅延ロードでアクセスされた瞬間に ActiveRecord::StrictLoadingViolationError を送出する。既定の :all モードは遅延ロードそのものを全面的に禁止するが、:n_plus_one_only モードを指定すると「N+1 を引き起こす呼び出しだけ」を検知対象に絞れる。

同じ問題は GraphQL のリゾルバでも起きる。一覧を返すリゾルバと、各要素の関連フィールドを解決するリゾルバが独立して呼ばれる設計上、何もしなければ要素数だけ問い合わせが発生する。ここでよく使われるのが Facebook が公開した DataLoader という汎用ユーティリティで、個々の .load(key) 呼び出しをリクエスト内でいったん貯めておき、実行タイミングをずらして batchLoadFn(keys) にまとめて渡すことでバッチ化する。includes のような ORM 側の機能とは逆に「呼び出す側を後からまとめる」アプローチだが、狙いは同じく往復回数を減らすことにある。

💼 実務でどう出会うか

一覧画面や API のレスポンスに関連データを含めた直後は開発環境で気づかず、本番デプロイ後にレスポンスタイムだけが悪化する形で発覚することが多い。Rails では bullet gem を開発・テスト環境に入れておくと、N+1 や不要な eager loading が発生した箇所をログや通知として教えてくれるため、コードレビューに乗る前に機械的に見つけやすくなる。

⌨️ 手を動かす(5 分)

Rails を用意しなくても、N+1 が「クエリの回数」として実際に増えることは Python 標準の sqlite3 だけで確認できる。1 件ずつ著者を問い合わせるパターンと、JOIN でまとめて取得するパターンのクエリ回数を数えて比較する。

python3 - <<'PY'
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE authors(id INTEGER PRIMARY KEY, name TEXT)")
conn.execute("CREATE TABLE books(id INTEGER PRIMARY KEY, title TEXT, author_id INTEGER)")
for i in range(1, 6):
    conn.execute("INSERT INTO authors VALUES (?, ?)", (i, f"著者{i}"))
    conn.execute("INSERT INTO books VALUES (?, ?, ?)", (i, f"本{i}", i))
conn.commit()

count = 0
def q(sql, args=()):
    global count
    count += 1
    return conn.execute(sql, args).fetchall()

count = 0
books = q("SELECT * FROM books")
for b in books:
    q("SELECT * FROM authors WHERE id = ?", (b[2],))
print("N+1:", count, "回のクエリ")

count = 0
q("SELECT books.*, authors.name FROM books JOIN authors ON authors.id = books.author_id")
print("JOIN:", count, "回のクエリ")
PY

N+1: 6 回のクエリ(本 5 件を取得する 1 回 + 著者を 1 件ずつ問い合わせる 5 回)に対して、JOIN: 1 回のクエリになる。件数を range(1, 6) の上限を増やして試すと、N+1 側だけがその件数分だけ回数を増やしていくのが確認できる。

🙅 よくある誤解
  • includes を書いておけば常に安全だ — 関連先のカラムに where 条件を書くと Rails は内部で JOIN に切り替えるため、想定外の結合クエリが発行されることがある。効果があるかはログで確認する必要がある。
  • N+1 は件数が少なければ気にしなくていい — クエリのコストの多くはデータ量そのものより往復(ラウンドトリップ)のオーバーヘッドで決まる。件数が増えるほど往復回数も同じペースで増えるため、レイテンシへの影響も比例して大きくなる。
  • N+1 は Active Record 特有のバグだ — 関連先のデータを後から取りに行く設計を持つ ORM や GraphQL のリゾルバなど、同じ構造を持つ仕組み全般で起こりうる一般的なパターンだ。
📖 用語ミニ辞典
N+1 問題
一覧の 1 件取得に加え、件数分だけ関連データの取得クエリが追加で発生してしまう現象。
遅延ロード(lazy loading)
関連を宣言した時点では問い合わせず、実際にアクセスされた瞬間に初めて SQL を発行する設計。
Eager Loading
関連を使う前にまとめて先読みしておき、レコードごとの追加クエリを防ぐ手法の総称。
preload
常に個別クエリで関連を先読みする Active Record のメソッド。先読みした関連に条件は指定できない。
strict_loading
遅延ロードが起きた瞬間にエラーを送出させ、N+1 の混入を機械的に検知する Active Record の機能。
DataLoader
個々の取得リクエストをまとめてバッチ化し、GraphQL のリゾルバなどで N+1 を防ぐ Facebook 発の汎用ユーティリティ。
🔗 もっと深く