Python | DB・SQL:N+1 問題

Python
スポンサーリンク

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
);
SQL

users にユーザー、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 ログやクエリ回数を「見える化」する習慣がとても重要。

タイトルとURLをコピーしました