SQLAlchemy 2.0 #5 リレーション — 1:N、N:M、そして N+1 問題
テーブルひとつだけで済むアプリはありません。ユーザーは注文を複数持ち、投稿はタグを複数持ちます。ORM でこのつながりを担当するのが relationship() で、便利な分だけ ORM 最大の性能の罠である N+1 問題の発生源でもあります。今回は宣言の仕方と罠への対処をまとめて整理します。
1:N — 外部キーと relationship の役割分担 #
ユーザー 1 人が住所を複数持つ関係を宣言します。
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.addressesとaddress.userというオブジェクトのナビゲーション経路を作ります。back_populates: 2 つの経路が同じ関係の両面であることを教える設定です。片側でaddress.user = userと代入すると、反対側のuser.addressesにも自動で現れます。
使い方はコレクション操作そのものです。
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 テーブル #
投稿とタグのように両側とも複数の関係は、中間テーブルを置きます。
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 が飛びます。これがループと出会うと事故になります。
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 つあります。
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()| 戦略 | クエリの形 | 向いているところ |
|---|---|---|
selectinload | SELECT 2 回(親、子を IN(…)) | コレクション(1:N、N:M)。行の重複がなく予測しやすい |
joinedload | JOIN 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 スタイルで扱います。