SQLAlchemy 2.0 #5 リレーション — 1:N、N:M、そして N+1 問題

テーブルひとつだけで済むアプリはありません。ユーザーは注文を複数持ち、投稿はタグを複数持ちます。ORM でこのつながりを担当するのが relationship() で、便利な分だけ ORM 最大の性能の罠である N+1 問題の発生源でもあります。今回は宣言の仕方と罠への対処をまとめて整理します。

1:N — 外部キーと relationship の役割分担 #

ユーザー 1 人が住所を複数持つ関係を宣言します。

models.py
from sqlalchemy import ForeignKey, String
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "user_account"

    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30))

    addresses: Mapped[list["Address"]] = relationship(
        back_populates="user", cascade="all, delete-orphan"
    )


class Address(Base):
    __tablename__ = "address"

    id: Mapped[int] = mapped_column(primary_key=True)
    email_address: Mapped[str] = mapped_column(String(100))
    user_id: Mapped[int] = mapped_column(ForeignKey("user_account.id"))

    user: Mapped["User"] = relationship(back_populates="addresses")

役割がはっきり分かれています。

  • ForeignKey は DB のものです。address.user_id カラムに外部キー制約を作ります。これがなければ関係は成立しません。
  • relationship() は Python のものです。DB には何のカラムも作らず、user.addressesaddress.user というオブジェクトのナビゲーション経路を作ります。
  • back_populates: 2 つの経路が同じ関係の両面であることを教える設定です。片側で address.user = user と代入すると、反対側の user.addresses にも自動で現れます。

使い方はコレクション操作そのものです。

main.py
with SessionLocal.begin() as session:
    user = User(name="佐藤")
    user.addresses.append(Address(email_address="sato@example.com"))
    user.addresses.append(Address(email_address="sato@work.com"))
    session.add(user)
# user の INSERT → 発行された id で address 2 件の INSERT までセッションが処理

user_id を自分で埋めていない点に注目してください。flush のタイミングで、セッションが親の主キーを子の外部キーに埋め込みます。

cascade — 親が消えたら子はどうなるか #

上の宣言の cascade="all, delete-orphan" は、実務で最もよく使う組み合わせです。

  • all: 親を session.add すれば子も一緒に、親を削除すれば子も一緒に、といった主要な操作を伝播します。
  • delete-orphan: コレクションから外された子(user.addresses.remove(addr))を孤児とみなして DELETE します。

「住所はユーザーに従属する持ち物」ならこの組み合わせが正解です。逆に「投稿の作成者」のように子が独立して存在すべき関係なら、cascade に delete を入れてはいけません。所有関係なのか参照関係なのかを先に判断してから cascade を決める、という順序です。

N:M — secondary テーブル #

投稿とタグのように両側とも複数の関係は、中間テーブルを置きます。

models.py
from sqlalchemy import Column, ForeignKey, Table

post_tag = Table(
    "post_tag",
    Base.metadata,
    Column("post_id", ForeignKey("post.id"), primary_key=True),
    Column("tag_id", ForeignKey("tag.id"), primary_key=True),
)


class Post(Base):
    __tablename__ = "post"
    id: Mapped[int] = mapped_column(primary_key=True)
    title: Mapped[str] = mapped_column(String(100))
    tags: Mapped[list["Tag"]] = relationship(secondary=post_tag, back_populates="posts")


class Tag(Base):
    __tablename__ = "tag"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str] = mapped_column(String(30), unique=True)
    posts: Mapped[list["Post"]] = relationship(secondary=post_tag, back_populates="tags")

中間テーブルはモデルクラスにせず Table のまま secondary= でつなぐのが基本形です。post.tags.append(tag) と書くだけで、中間テーブルの INSERT はセッションが処理します。ただし中間テーブルに付加カラム(タグを付けた日時、付けた人)が必要になったら、Table を正式なモデルクラスに昇格させて 1:N を 2 つに分解します。これを関連オブジェクト(association object)パターンと呼びます。

N+1 問題 — ORM の性能問題の 8 割 #

デフォルト設定では、リレーション属性は遅延ロード(lazy loading)です。user.addresses に初めてアクセスした瞬間に SELECT が飛びます。これがループと出会うと事故になります。

main.py
with SessionLocal() as session:
    users = session.scalars(select(User)).all()   # クエリ 1 回
    for user in users:
        print(user.name, len(user.addresses))     # ユーザーごとにクエリ 1 回!

ユーザーが 100 人ならクエリは 1 + 100 = 101 回です。これが N+1 です。開発中はデータが少なくて気づかず、本番でデータがたまると一覧ページが急に遅くなる典型パターンです。echo=True をオンにして一覧取得のコードを回せば、同じ形の SELECT が繰り返される様子ですぐ確認できます。

解決策は、最初から一緒にロードすると宣言することです。代表的な戦略が 2 つあります。

main.py
from sqlalchemy.orm import joinedload, selectinload

# selectinload: IN 句を使う 2 本目のクエリで子をまとめてロード
stmt = select(User).options(selectinload(User.addresses))

# joinedload: LEFT JOIN 一発でロード
stmt = select(User).options(joinedload(User.addresses))
users = session.scalars(stmt).unique().all()
戦略クエリの形向いているところ
selectinloadSELECT 2 回(親、子を IN(…))コレクション(1:N、N:M)。行の重複がなく予測しやすい
joinedloadJOIN 1 回単一オブジェクト参照(N:1)。コレクションに使うと行が膨らみ unique() が必要

基本の選び方はシンプルです。コレクションは selectinload、単一参照は joinedload どちらにせよ「クエリ数がデータ件数に比例しない」ようにするのが目的です。リレーション宣言自体に lazy="selectin" を入れてデフォルト動作を変えることもできますが、常に子まで必要とは限らないので、クエリごとの options() 指定を基本にするほうが無駄がありません。

もうひとつ、第 4 回で見た detached 状態と組み合わせるとこうなります。セッションが閉じたあとに遅延ロード属性へアクセスすると DetachedInstanceError になります。 API レスポンスの直前にリレーション属性を触って出会うエラーの正体はたいていこれで、答えは取得時点で eager loading により一緒に持ってくることです。

まとめ #

  • リレーションは 2 つの部品です。ForeignKey が DB の制約を、relationship() が Python のナビゲーション経路を作り、back_populates で双方向をつなぎます。
  • cascade は所有関係(all, delete-orphan)か参照関係(伝播なし)かを先に判断してから決めます。
  • N:M は Table + secondary が基本形で、中間テーブルにカラムが付いた瞬間に関連オブジェクトパターンへ昇格させます。
  • 遅延ロード + ループ = N+1 です。コレクションは selectinload、単一参照は joinedload で、取得時点で一緒にロードします。
  • 次回はクエリ応用です。結合、集計、サブクエリ、ページネーション、大量処理を 2.0 スタイルで扱います。
X