N+1 問題って何?まずはざっくりイメージ
N+1 問題は、
「本当は少ない回数のクエリで済むのに、気づかないうちに大量のクエリを投げてしまっている状態」のことです。
特に多いのが、
1 回目のクエリで「親データ」を N 件取る
そのあと、各親ごとに「子データ」を 1 回ずつクエリする
というパターンです。
結果として、クエリ回数が「1(親)+N(子)=N+1」になり、データが増えるほど爆発的に遅くなります。
「動くけど、地味に DB に負荷をかけ続けるバグ」
それが N+1 問題です。
具体例で見る:ユーザーと注文の N+1 問題
前提となるテーブル構造
よくある「ユーザーと注文」の関係で考えます。
CREATE TABLE users (
id INTEGER PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
user_id INTEGER NOT NULL,
item TEXT NOT NULL,
price INTEGER NOT NULL
);
SQLusers にユーザー、orders にその人の注文が入っています。
「全ユーザーと、その人の注文一覧を画面に表示したい」というよくある要件を想像してください。
ダメな書き方:典型的な N+1
Python で素直に書くと、こうなりがちです。
import sqlite3
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute("SELECT id, name FROM users")
users = cur.fetchall()
for user_id, name in users:
cur.execute(
"SELECT item, price FROM orders WHERE user_id = ?",
(user_id,),
)
orders = cur.fetchall()
print(f"ユーザー: {name}")
for item, price in orders:
print(" -", item, price)
conn.close()
Pythonここで何が起きているかを数で見ると、
ユーザー一覧を取るクエリ:1 回
各ユーザーの注文を取るクエリ:ユーザー数 N 回
合計で N+1 回のクエリになっています。
ユーザーが 10 人なら 11 回で済みますが、
1000 人なら 1001 回、1 万人なら 10001 回です。
1 回のクエリは一瞬でも、
1 万回積み重なると「なんか遅い…」になります。
これが N+1 問題の怖いところです。
なぜ N+1 が問題になるのかをちゃんと理解する
遅くなる理由は「クエリ回数」と「往復コスト」
DB にクエリを投げるときには、毎回こういうコストがかかります。
アプリ → DB へのリクエスト送信
DB が SQL を解析・実行
結果をアプリに返す
この「往復」が 1 回なら軽いですが、
1000 回、10000 回になると、ネットワーク・DB・アプリの全部に負荷がかかります。
N+1 問題は、
「1 回で済むはずの情報取得を、N+1 回に分割してしまっている」
という構造的なミスです。
データが少ないうちは気づきにくく、
本番でデータが増えてから「なんかページが重い」と発覚しがちです。
スケールすると一気に効いてくる
例えば、ユーザー数が 100 のときは、
N+1 でも 101 クエリなので、体感的には問題にならないかもしれません。
でも、サービスが伸びてユーザーが 10 万人になったらどうでしょう。
親クエリ:1 回
子クエリ:10 万回
合計 100001 クエリです。
1 クエリ 1ms でも、理論上 100 秒かかります(実際はもっと悪化しがち)。
「小さいうちは平気だけど、成長すると致命傷になる」
それが N+1 問題の本質です。
正しい解決パターン:JOIN で 1 回にまとめる
SQL で JOIN して一気に取る
さっきの「全ユーザーとその注文」を、
N+1 ではなく 1 回のクエリで取るには、JOIN を使います。
SELECT
users.id,
users.name,
orders.item,
orders.price
FROM users
LEFT JOIN orders
ON users.id = orders.user_id;
SQLこれで、
ユーザーが 1 行
そのユーザーの注文が複数行
という形で、全部まとめて返ってきます。
クエリは 1 回だけです。
Python で書くとこうなります。
import sqlite3
conn = sqlite3.connect("app.db")
cur = conn.cursor()
cur.execute(
"""
SELECT
users.id,
users.name,
orders.item,
orders.price
FROM users
LEFT JOIN orders
ON users.id = orders.user_id
"""
)
rows = cur.fetchall()
current_user_id = None
for user_id, name, item, price in rows:
if user_id != current_user_id:
print(f"ユーザー: {name}")
current_user_id = user_id
if item is not None:
print(" -", item, price)
conn.close()
Pythonクエリは 1 回だけ。
あとは Python 側で「ユーザーごとにグルーピング」して表示しています。
ここでのポイントは、
「DB が得意な JOIN で、必要なデータを一気に取る」
「アプリ側は、その結果を整形するだけ」
という役割分担です。
ORM(SQLAlchemy)での N+1 とその回避
ありがちな N+1 パターン(ORM 版)
SQLAlchemy の ORM を使うと、
N+1 問題はさらに「気づきにくく」なります。
モデルがこうだとします。
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import declarative_base, relationship
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
name = Column(String, nullable=False)
orders = relationship("Order", back_populates="user")
class Order(Base):
__tablename__ = "orders"
id = Column(Integer, primary_key=True)
item = Column(String, nullable=False)
price = Column(Integer, nullable=False)
user_id = Column(Integer, ForeignKey("users.id"))
user = relationship("User", back_populates="orders")
Pythonそして、こんなコードを書いたとします。
session = SessionLocal()
users = session.query(User).all()
for u in users:
print("ユーザー:", u.name)
for o in u.orders:
print(" -", o.item, o.price)
session.close()
Python一見、とてもきれいなコードです。
しかし、ここで何が起きているかというと、
1 回目:SELECT * FROM users
ループの中で、ユーザーごとに SELECT * FROM orders WHERE user_id = ...
というクエリが裏で発行されます。
ユーザーが N 人なら、クエリは N+1 回です。
これが ORM 版の N+1 問題です。
eager load(事前ロード)でまとめて取る
SQLAlchemy では、joinedload などを使って、
関連するデータを「最初のクエリで一緒に取る」ことができます。
from sqlalchemy.orm import joinedload
session = SessionLocal()
users = (
session.query(User)
.options(joinedload(User.orders))
.all()
)
for u in users:
print("ユーザー:", u.name)
for o in u.orders:
print(" -", o.item, o.price)
session.close()
Pythonこの書き方だと、裏ではだいたいこんなクエリが 1 回だけ発行されます。
SELECT
users.id AS users_id,
users.name AS users_name,
orders.id AS orders_id,
orders.item AS orders_item,
orders.price AS orders_price,
orders.user_id AS orders_user_id
FROM users
LEFT OUTER JOIN orders
ON users.id = orders.user_id;
SQLつまり、JOIN で一気に取って、
ORM が「ユーザーごとに orders を割り当ててくれる」イメージです。
ORM を使うときの重要ポイントは、
「ループの中で関連オブジェクトにアクセスしたとき、裏でクエリが飛んでいないか?」
を意識することです。
怪しいときは、SQL ログ(echo=True など)を見て、
クエリ回数が N+1 になっていないか確認します。
N+1 を避けるために、常に持っておきたい視点
「このループ、裏で何回クエリが飛んでいる?」
N+1 は、見た目のコードだけでは分かりにくいことが多いです。
特に ORM を使っていると、user.orders にアクセスした瞬間にクエリが飛ぶ、ということがよくあります。
だからこそ、こういう感覚を持っておくと強いです。
この for ループの中で、DB にアクセスしていないか?
同じようなクエリを何度も繰り返していないか?
まとめて取れるものを、バラバラに取りに行っていないか?
そして、「怪しいな」と思ったら、
SQL ログを出してみる
クエリ回数を数えてみる
という「見える化」を必ずやることです。
「JOIN でまとめる」「事前ロードする」が基本の解決策
N+1 を解決する基本パターンは、シンプルに言うとこの 2 つです。
SQL なら:JOIN でまとめて取る
ORM なら:eager load(joinedload など)で関連を事前に読み込む
「親を取ってから、子を 1 件ずつ取りに行く」のではなく、
「親と子を一緒に取る」方向に発想を切り替えると、N+1 はかなり防げます。
まとめ(N+1 問題は「気づきにくいけど致命傷になりやすい」)
N+1 問題を初心者目線で整理すると、こうなります。
親データを 1 回のクエリで N 件取り、そのあと各親ごとに子データを 1 回ずつ取りに行くことで、クエリ回数が N+1 になってしまう問題。
データが少ないうちは目立たないが、件数が増えるとクエリ回数が爆発し、パフォーマンスが一気に悪化する。
SQL では JOIN を使って「親と子を一度に取る」、ORM では eager load(joinedload など)を使って「関連を事前に読み込む」のが基本的な解決策。
ループの中で DB アクセスしていないか、同じようなクエリを何度も投げていないかを常に意識し、SQL ログやクエリ回数を「見える化」する習慣がとても重要。
