ローカルで開発した「ユーザ一覧画面」、本番で30秒かかる。理由は N+1問題:ユーザ100人を取るのに `SELECT user`(1回)+ `SELECT addresses WHERE user_id=?`(100回)= 101回のSQL が飛んでる。
あの時こうすれば良かった、と思う症状
・ローカルで快速・本番で激重 / ・APM のトレースが赤い線で埋まる / ・「100人ユーザ」と「1万人ユーザ」で表示時間が線形ではなく爆発 / ・GraphQL のネスト解決で謎の遅延
起きる仕組み
ORM が「親レコードのループ中に、子のリレーションをアクセスする」と、各 iteration で が飛びます。書き手は意識せず、`user.address.city` のような自然な書き方で遅延発火。
Rails の典型的 N+1(コードは綺麗に見えるのに)
Ruby
# 100ユーザの住所都市を表示する@users = User.where(active: true).limit(100)
@users.each do |u| puts u.address.city # ← user 100 人 × address 取得 100 回end# 結果:SELECT users + 100回の SELECT addresses解消:includes / preload / eager_load
Ruby
# 1回の追加 SQL で全 address を先取り@users = User.where(active: true).limit(100).includes(:address)
@users.each do |u| puts u.address.cityend# 結果:SELECT users + 1回の SELECT addresses WHERE user_id IN (...)調査手順
- ローカルログで `BEGIN` 〜 `COMMIT` 内のクエリ数:1リクエスト10個超は黄信号、100個超は赤信号
- bullet (Rails) / nplusone (Django) の検出 gem:開発環境で N+1 を自動検出して例外
- APM (Datadog / New Relic) のトレース:本番で実際の SQL 発行頻度を可視化
- APM の slow trace:100ms 以上のリクエストのうち、SQL 数 vs 時間で比例しているもの
- GraphQL の場合:DataLoader が入ってないリゾルバの個別アクセス数
解消パターン(フレームワーク別)
| フレームワーク | N+1解消の標準手段 |
|---|---|
| Rails (ActiveRecord) | `.includes(:assoc)` / `.preload` / `.eager_load` |
| Django (ORM) | `prefetch_related()` / `select_related()` |
| Laravel (Eloquent) | `with()` / `load()` |
| TypeORM / Prisma | `relations` オプション / `include` |
| GraphQL | DataLoader(バッチ + キャッシュ) / `@nestjs/graphql` の `Loader` |
| SQLAlchemy | `joinedload()` / `selectinload()` |
| .NET EF Core | `.Include()` |
中長期対策
- に N+1 検知:bullet / nplusone を CI 環境で fail させる
- APM で監視:本番で SQL 数の異常をアラート化
- コードレビューの観点に追加:「ループの中でリレーション辿ってないか」
- メトリクス:`SQL queries per request` を SLI として p95 を見る
- GraphQL は DataLoader 必須:「リゾルバ書いたら DataLoader」のセット文化
ふくふくの進め方
N+1 監査は1〜2週間:① APM のトレースから「クエリ数 > 50」のリクエストを抽出、② 該当箇所をリスト化、③ includes / DataLoader 化の PR を一気に。Rails / Django / Laravel / GraphQL いずれも対応経験あります。
この記事の感想を教えてください
あなたの 1 クリックで、本当にこの記事は更新されます。「もっと詳しく」「続編希望」が一定数集まった記事は、 ふくふくが 実際に内容を拡充したり続編記事を公開 します。 送信したリアクションはお使いのブラウザに記録され、再カウントされません。