- Authors
- Name
- はじめに
- N+1 問題とは?
- N+1 の種類
- select_related vs prefetch_related
- ORM 別の解決法
- N+1 の検出方法
- GraphQL の N+1(DataLoader)
- まとめ:N+1 解決の意思決定ツリー

はじめに
コードレビューで最も多いフィードバックの一つ:「N+1 クエリが発生しています。」
N+1 問題は ORM を使うほぼすべてのプロジェクトで発生します。そして静かにパフォーマンスを殺します。ローカルでは気づかないのに、データが1万件を超えた途端、API レスポンスが10秒になります。
N+1 問題とは?
シナリオ
ブログシステムで記事一覧と著者を表示しようとしています。
# モデル定義(Django)
class Author(models.Model):
name = models.CharField(max_length=100)
class Post(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
# N+1 問題発生!
posts = Post.objects.all()[:100] # 1回目のクエリ:記事100件を取得
for post in posts:
print(f"{post.title} by {post.author.name}")
# ↑ 記事ごとに author を照会!100回の追加クエリ!
実際に発生する SQL
-- 1回目のクエリ(記事一覧)
SELECT id, title, author_id, created_at FROM posts LIMIT 100;
-- 2回目~101回目のクエリ(著者を個別に照会!)
SELECT id, name FROM authors WHERE id = 1;
SELECT id, name FROM authors WHERE id = 2;
SELECT id, name FROM authors WHERE id = 3;
SELECT id, name FROM authors WHERE id = 1; -- 同じ著者も再度照会!
SELECT id, name FROM authors WHERE id = 4;
...
-- 合計101回のクエリ!(1 + N = 1 + 100)
パフォーマンスへの影響
# ベンチマーク
import time
# N+1(101クエリ)
start = time.time()
posts = Post.objects.all()[:1000]
for post in posts:
_ = post.author.name
print(f"N+1: {time.time() - start:.2f}s")
# → N+1: 3.47s(1001クエリ)
# 最適化後(2クエリ)
start = time.time()
posts = Post.objects.select_related('author').all()[:1000]
for post in posts:
_ = post.author.name
print(f"Optimized: {time.time() - start:.2f}s")
# → Optimized: 0.05s(2クエリ)
# 69倍高速!!
データ数 │ N+1 クエリ数 │ 応答時間 │ 最適化後
──────────┼──────────────┼─────────────┼──────────
10 │ 11 │ 0.05s │ 0.01s
100 │ 101 │ 0.35s │ 0.02s
1,000 │ 1,001 │ 3.5s │ 0.05s
10,000 │ 10,001 │ 35s │ 0.1s
100,000 │ 100,001 │ 5分+ │ 0.3s
N+1 の種類
1. ForeignKey N+1(1:N の「1」側)
# Post → Author(Many-to-One)
# 記事から著者を照会
# N+1
for post in Post.objects.all():
print(post.author.name) # 毎回 SELECT
# select_related(SQL JOIN)
for post in Post.objects.select_related('author'):
print(post.author.name) # JOIN で一度に取得!
生成される SQL:
-- select_related → INNER JOIN
SELECT posts.*, authors.name
FROM posts
INNER JOIN authors ON posts.author_id = authors.id
LIMIT 100;
-- 1回のクエリ!
2. Reverse ForeignKey N+1(1:N の「N」側)
# Author → Posts(One-to-Many)
# 著者別の記事一覧を照会
# N+1
authors = Author.objects.all()[:50]
for author in authors:
posts = author.post_set.all() # 毎回 SELECT
print(f"{author.name}: {posts.count()} posts")
# prefetch_related(別クエリ + Python でマッピング)
authors = Author.objects.prefetch_related('post_set').all()[:50]
for author in authors:
posts = author.post_set.all() # キャッシュされた結果を使用!
print(f"{author.name}: {len(posts)} posts")
生成される SQL:
-- prefetch_related → 2回のクエリ
SELECT id, name FROM authors LIMIT 50;
SELECT id, title, author_id FROM posts WHERE author_id IN (1, 2, 3, ..., 50);
-- Python でマッピング!
3. ManyToMany N+1
class Post(models.Model):
tags = models.ManyToManyField('Tag')
# N+1
for post in Post.objects.all():
tag_names = [t.name for t in post.tags.all()] # 毎回 SELECT
# prefetch_related
for post in Post.objects.prefetch_related('tags'):
tag_names = [t.name for t in post.tags.all()] # キャッシュ!
4. ネストされた N+1(最も危険!)
# Post → Author → Company(3段階ネスト)
# N+1+1(記事 N + 著者 N + 会社 N)
for post in Post.objects.all():
print(f"{post.title} by {post.author.name} at {post.author.company.name}")
# 記事ごとに: author SELECT + company SELECT
# select_related チェイニング
for post in Post.objects.select_related('author__company'):
print(f"{post.title} by {post.author.name} at {post.author.company.name}")
-- 3重 JOIN、1回のクエリ!
SELECT posts.*, authors.*, companies.*
FROM posts
JOIN authors ON posts.author_id = authors.id
JOIN companies ON authors.company_id = companies.id;
select_related vs prefetch_related
select_related:
├── SQL JOIN で一度に取得
├── ForeignKey / OneToOneField にのみ使用
├── 1回のクエリ(JOIN)
└── データが少ないときに効率的
prefetch_related:
├── 別クエリ + Python でマッピング
├── ManyToMany / Reverse FK に使用
├── 2回のクエリ(メイン + IN)
└── データが多いか複雑なリレーションで効率的
# 両方同時に使用可能!
posts = Post.objects.select_related(
'author', # FK → JOIN
'author__company', # FK → JOIN
).prefetch_related(
'tags', # M2M → 別クエリ
'comments', # Reverse FK → 別クエリ
).all()[:100]
# たった4回のクエリですべてのリレーションをロード!
ORM 別の解決法
Django
# select_related(FK, O2O)
Post.objects.select_related('author', 'category')
# prefetch_related(M2M, Reverse FK)
Post.objects.prefetch_related('tags', 'comments')
# Prefetch オブジェクト(フィルタリング + 最適化)
from django.db.models import Prefetch
Post.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(is_approved=True).select_related('user'),
to_attr='approved_comments' # post.approved_comments でアクセス
)
)
# annotate で COUNT 最適化(サブクエリの代わりに集計)
from django.db.models import Count
Author.objects.annotate(post_count=Count('post')).filter(post_count__gte=5)
SQLAlchemy (Python)
from sqlalchemy.orm import joinedload, selectinload, subqueryload
# joinedload = select_related(JOIN)
session.query(Post).options(joinedload(Post.author)).all()
# selectinload = prefetch_related(SELECT ... IN)
session.query(Author).options(selectinload(Author.posts)).all()
# ネスト
session.query(Post).options(
joinedload(Post.author).joinedload(Author.company),
selectinload(Post.tags)
).all()
# リレーションローディング戦略(モデルで設定)
class Post(Base):
author = relationship("Author", lazy="joined") # 常に JOIN
tags = relationship("Tag", lazy="selectin") # 常に IN クエリ
comments = relationship("Comment", lazy="dynamic") # 明示的なクエリのみ
JPA / Hibernate (Java/Kotlin)
// Lazy Loading → デフォルトで N+1 が発生!
@Entity
public class Post {
@ManyToOne(fetch = FetchType.LAZY) // デフォルトが LAZY
private Author author;
}
// 解決法 1: JPQL JOIN FETCH
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
// 解決法 2: EntityGraph
@EntityGraph(attributePaths = {"author", "tags"})
List<Post> findAll();
// 解決法 3: Batch Size(Hibernate)
@BatchSize(size = 100) // IN 句で100件ずつまとめて照会
@OneToMany(mappedBy = "author")
private List<Post> posts;
// application.yml
// spring.jpa.properties.hibernate.default_batch_fetch_size: 100
Prisma (TypeScript/Node.js)
// N+1
const posts = await prisma.post.findMany()
for (const post of posts) {
const author = await prisma.author.findUnique({
where: { id: post.authorId },
})
}
// include(Eager Loading)
const posts = await prisma.post.findMany({
include: {
author: true,
tags: true,
comments: {
where: { approved: true },
include: { user: true },
},
},
})
ActiveRecord (Ruby on Rails)
# N+1
Post.all.each { |post| puts post.author.name }
# includes(自動的に最適な戦略を選択)
Post.includes(:author).each { |post| puts post.author.name }
# eager_load(常に JOIN)
Post.eager_load(:author, :tags).all
# preload(常に別クエリ)
Post.preload(:comments).all
# Bullet gem で自動検出!
# Gemfile: gem 'bullet'
N+1 の検出方法
1. Django Debug Toolbar
# settings.py
INSTALLED_APPS = ['debug_toolbar']
MIDDLEWARE = ['debug_toolbar.middleware.DebugToolbarMiddleware']
# → ブラウザで SQL クエリ数 + 重複を確認!
2. ログでクエリ数をカウント
# Django: クエリ数自動ロギングミドルウェア
from django.db import connection, reset_queries
import logging
logger = logging.getLogger(__name__)
class QueryCountMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
reset_queries()
response = self.get_response(request)
query_count = len(connection.queries)
if query_count > 10: # 閾値
logger.warning(
f"警告: {request.path}: {query_count} queries! "
f"Possible N+1 problem."
)
# 重複クエリを検出
queries = [q['sql'] for q in connection.queries]
from collections import Counter
dupes = {q: c for q, c in Counter(queries).items() if c > 1}
if dupes:
logger.warning(f"Duplicate queries: {dupes}")
return response
3. pytest で自動検出
import pytest
from django.test.utils import override_settings
@pytest.mark.django_db
def test_no_n_plus_1_on_post_list(client, django_assert_max_num_queries):
# 記事100件を作成
author = Author.objects.create(name="Test")
for i in range(100):
Post.objects.create(title=f"Post {i}", author=author)
# クエリ数制限(100件の記事で10回以下であるべき)
with django_assert_max_num_queries(10):
response = client.get('/api/posts/')
assert response.status_code == 200
4. SQLAlchemy イベントリスナー
from sqlalchemy import event
import warnings
@event.listens_for(Engine, "before_cursor_execute")
def receive_before_cursor_execute(conn, cursor, statement, *args):
if not hasattr(conn, '_query_count'):
conn._query_count = 0
conn._query_count += 1
if conn._query_count > 50:
warnings.warn(f"Over 50 queries in one request! ({conn._query_count})")
GraphQL の N+1(DataLoader)
# GraphQL は N+1 の温床!
# resolver がフィールドごとに呼び出されるため
# N+1 発生
class PostType:
def resolve_author(post, info):
return Author.objects.get(id=post.author_id) # 毎回 SELECT!
# DataLoader でバッチ処理
from promise import Promise
from promise.dataloader import DataLoader
class AuthorLoader(DataLoader):
def batch_load_fn(self, author_ids):
# 一度にすべて取得!
authors = {a.id: a for a in Author.objects.filter(id__in=author_ids)}
return Promise.resolve([authors.get(id) for id in author_ids])
# リクエストごとに新しい loader を生成
author_loader = AuthorLoader()
class PostType:
def resolve_author(post, info):
return author_loader.load(post.author_id) # バッチ処理!
まとめ:N+1 解決の意思決定ツリー
リレーションタイプは?
├── ForeignKey / OneToOne
│ └── select_related(JOIN)
│
├── ManyToMany / Reverse FK
│ └── prefetch_related(IN クエリ)
│
├── ネストされたリレーション(A → B → C)
│ └── select_related チェイニング('b__c')
│
├── 条件付きプリフェッチ
│ └── Prefetch オブジェクト + queryset フィルタ
│
└── COUNT/SUM のみ必要
└── annotate + 集計関数(プリフェッチより効率的)
クイズ — N+1 問題(クリックして確認!)
Q1. N+1 問題の N と 1 はそれぞれ何を指すか? ||1: メインエンティティ一覧を取得する最初のクエリ。N: 各エンティティの関連データを個別に照会する N 回の追加クエリ。合計 1+N 回のクエリが発生||
Q2. select_related と prefetch_related の違いは? ||select_related: SQL JOIN で一度に照会(FK/O2O 用)。prefetch_related: 別クエリ後に Python でマッピング(M2M/Reverse FK 用)||
Q3. 記事1,000件を N+1 で照会すると何回のクエリが発生するか? ||1,001回(一覧 1回 + 著者 1,000回)。最適化すると2回(select_related)または2回(prefetch_related)||
Q4. JPA で Lazy Loading がデフォルトの理由と問題点は? ||理由: 不要な関連データを事前にロードしないためメモリ節約。問題点: ループでアクセスすると N+1 発生。JOIN FETCH または EntityGraph で解決||
Q5. GraphQL で N+1 が特に深刻な理由と解決策は? ||理由: resolver がフィールドごとに独立して呼び出され、各 Post の author を個別に照会。解決: DataLoader で同一ターンのリクエストをまとめてバッチ処理(IN クエリ1回)||
Q6. ネストされた N+1(Post → Author → Company)での総クエリ数は? ||最悪の場合 1 + N + N = 2N+1。記事100件なら201回のクエリ。select_related('author__company') で1回の JOIN クエリに解決||
Q7. annotate が prefetch_related より効率的な場合は? ||関連データの全体ではなく COUNT、SUM のような集計のみ必要な場合。prefetch_related はすべてのデータを取得して Python で処理するが、annotate は DB で集計後に結果のみ返す||
Q8. N+1 を自動的に検出する方法3つは? ||1) Django Debug Toolbar(ブラウザでクエリ数確認)2) クエリカウンティングミドルウェア(閾値超過時に警告)3) pytest django_assert_max_num_queries(テストでクエリ数制限)||