SQLAlchemy 安易なjoinedloadを撲滅したい
はじめに
SQLAlchemyを使用していると、N+1問題を回避するためにEager Loading(joinedload や selectinload)を利用する場面が数多く存在します。
この2つを適切に使い分けられてますか?
特に1対Nのリレーションに対して joinedload を使用すると、パフォーマンスに悪影響を与える可能性があります。
本記事では、DB層だけでなくPythonアプリケーション層(メモリ・CPU)の観点からも、なぜ使い分けが必要なのかを解説します。
結論:使い分けの基本ルール
| リレーション | 推奨 | 理由 |
|---|---|---|
| One-to-Many (Collection) | selectinload |
重複データ排除、Python側の負荷軽減 |
| Many-to-One (Scalar) | joinedload |
クエリ数削減 (1回で済む) |
1. コレクション(1対N)における joinedload の欠点
ユーザー(User)と投稿(Post)の 1対N の関係を例に考えます。
ユーザー一覧を取得する際、そのユーザーの投稿も一緒に取得したい場合です。
❌ 悪い例:joinedloadを使用
stmt = select(User).options(joinedload(User.posts))
joinedload は LEFT OUTER JOIN を使用して1回のクエリでデータを取得しようとします。
SELECT user.id, user.name, post.id, post.title
FROM user
LEFT OUTER JOIN post ON user.id = post.user_id
何が問題なのか?
1. データ転送量の無駄(ネットワーク帯域の圧迫)
SQLのJOINの性質上、親(User)のデータは、子(Post)の数だけ重複して返却されます(デカルト積)。
Userが100件のPostを持っていれば、Userの同じ名前やプロフィール情報が100行分、無駄にDBからアプリケーションサーバーへ転送されます。
2. Python側での処理コスト(CPU/メモリ)
DBだけでなく、Python側でも問題を引き起こします。
DBから受け取った「重複だらけの行データ」を、SQLAlchemyは以下のように処理する必要があります。
- 返ってきた行を1行ずつ走査する。
- 「この行のUser IDは見たことあるか?」を確認(重複排除)。
- 「このPostはどのUserオブジェクトの
postsリストに追加すべきか?」を判断して割り当て。
💡 「重複したデータを一個ずつ見ていって、これってどのUserに属するかな?」 と毎回やるのは、CPUとメモリにとって非常に大きな負担になります。
データ量が増えれば増えるほど、このPython側でのオブジェクト変換(De-serialization / Unique化)のコストが、クエリの実行時間以上にパフォーマンスを劣化させる原因になります。
2. selectinload の推奨場面
1対Nに対しては、selectinload が推奨されます。
✅ 良い例:selectinloadを使用
stmt = select(User).options(selectinload(User.posts))
selectinload は、クエリを2回に分けて発行します。
Step 1: 親データの取得(1回目のクエリ)
まず、親テーブル(User)だけを取得します。
SELECT * FROM user LIMIT 10;
この時点で、取得したUserの主キー(ID)のリスト(例: [1, 2, ... 10])がメモリ上で作成されます。
Step 2: 子データの取得(2回目のクエリ)
次に、先ほどのIDリストを使って、必要な子データ(Post)を一括取得します。
SELECT * FROM post
WHERE post.user_id IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
Step 3: メモリ上での紐付け
取得したPostデータは、ID(外部キー)を元に、メモリ上のUserオブジェクトに紐付けられます(User.posts に格納)。
この方法は、ハッシュマップ的にIDでマッチングを行うため、joinedload のような「重複行の解析と排除」という重い処理が不要です。クエリが1回増えるコストよりも、データ転送量削減とPython側の処理軽減のメリットの方が遥かに大きくなります。
3. joinedload の推奨場面
では joinedload は必要ないのかというとそんなことはありません。しかるべき場面では私たちが見放しかけたこの操作も十分な活躍をしてくれます。
それは N対1(Many-to-One) のリレーション、つまり「投稿(Post)から見た作成者(User)」を取得する場合です。
# Postを取得するついでに、User情報も欲しい
stmt = select(Post).options(joinedload(Post.user))
この場合、LEFT OUTER JOIN をしても行数は増えません(Post 1つにつきUserは必ず1人だから)。
- joinedload: クエリ1回で済む。
- selectinload: クエリが2回になる。
レイテンシが高い環境などでは、クエリ回数を減らせる joinedload の方が有利に働きます。ただし、selectinload を使っても致命的な問題にはなりにくいため、迷ったら selectinload に統一するという考え方もありますが、基本的には joinedload が適しています。
まとめ
-
1対N(
User.postsなど)-
使うもの:
selectinload - 理由: 重複行によるネットワーク圧迫を防ぎ、Python側のCPU/メモリ負荷(重複排除処理)を抑えるため。
-
使うもの:
-
N対1(
Post.userなど)-
使うもの:
joinedload - 理由: JOINしても行数が増えないため、1回のクエリで効率よく取得できるため。
-
使うもの:
SQLAlchemyを使う際は、「クエリの回数」だけでなく、「アプリケーションサーバーが受け取るデータ量」と「オブジェクト構築のコスト」も意識して使い分けましょう。
Discussion