ORM 與 N+1 查詢問題
ORM(Object-Relational Mapping,物件-關聯對映)讓我們用物件操作資料庫,開發速度快,但也容易踩到效能陷阱。其中最常見、也最致命的,就是 N+1 查詢問題(N+1 Query Problem)。
一、什麼是 ORM?
ORM 把資料表對應成程式物件(例如 User、Profile),用方法呼叫取代手寫 SQL。常見工具:
| 生態系 | ORM |
|---|---|
| Python | Django ORM、SQLAlchemy |
| Node.js / TS | Prisma、TypeORM、Sequelize |
| Java | Hibernate / JPA |
| Ruby | ActiveRecord(Rails) |
便利的代價是:預設常採 Lazy Loading(懶加載)——關聯資料「用到才查」。這正是 N+1 的溫床。
二、什麼是 N+1 查詢?
原本 一次就能抓完 的資料,被拆成 1 + N 次 資料庫往返(Round-trip):
- 主查詢(1 次):撈出主資料列表
例如取得 1,000 位使用者:SELECT * FROM users - 子查詢(N 次):在迴圈或遍歷時,為每位使用者再查關聯資料
例如個人資料:SELECT * FROM profiles WHERE user_id = ?(執行 1,000 次)
總共 1 + 1,000 = 1,001 次 請求。資料量大時,資料庫連線與 I/O 會被打爆,API 變慢甚至卡死。
典型反例(概念)
# 1 次:取得所有 users
users = User.objects.all()
# N 次:每跑一圈就多打一槍 DB
for user in users:
print(user.profile.bio) # Lazy Loading 觸發額外 SELECT
等價的「壞」SQL 模式:
-- 1 次
SELECT * FROM users;
-- 接著 N 次(每位 user 一次)
SELECT * FROM profiles WHERE user_id = 1;
SELECT * FROM profiles WHERE user_id = 2;
-- ...
SELECT * FROM profiles WHERE user_id = 1000;
三、為什麼會發生?Lazy Loading
| 載入策略 | 行為 | 風險 |
|---|---|---|
| Lazy Loading(懶加載) | 關聯欄位被存取時才發查詢 | 列表 + 迴圈 → 容易 N+1 |
| Eager Loading(預先載入) | 第一次查詢就一併載入關聯 | 查詢次數可控(通常 1~2 次) |
ORM 預設偏 Lazy,是為了「沒用到就不查」。一旦在列表頁、報表、序列化(JSON)裡遍歷關聯,就會在不知不覺中觸發 N 次查詢。
四、解法:Eager Loading(預先載入)
核心原則:在第一次查詢時,就把之後會用到的關聯一次載入,把 1,001 次縮成 1~2 次。
4.1 SQL:用 JOIN 一次撈齊
SELECT
users.id,
users.name,
profiles.bio
FROM users
INNER JOIN profiles ON profiles.user_id = users.id;
4.2 SQL:先抓主鍵,再用 IN 批次載入(常見於 ORM 內部)
-- 第 1 次:主列表
SELECT * FROM users;
-- 第 2 次:一次撈完所有關聯(不再 N 次)
SELECT * FROM profiles
WHERE user_id IN (1, 2, 3, /* ... */, 1000);
4.3 各 ORM 的預載寫法
Prisma(include)
const users = await prisma.user.findMany({
include: { profile: true },
});
Django ORM
# 一對一 / 多對一(FK):JOIN
users = User.objects.select_related("profile")
# 一對多 / 多對多:額外 IN 查詢
users = User.objects.prefetch_related("orders")
SQLAlchemy(Python)
from sqlalchemy.orm import selectinload, joinedload
# JOIN 一次載入
stmt = select(User).options(joinedload(User.profile))
# 或用 SELECT IN(適合一對多,避免笛卡爾積膨脹)
stmt = select(User).options(selectinload(User.orders))
Rails ActiveRecord
# includes:視情況走 preload 或 eager_load
User.includes(:profile)
# 強制 LEFT OUTER JOIN
User.eager_load(:profile)
TypeORM
const users = await userRepository.find({
relations: { profile: true },
});
五、怎麼發現自己踩到 N+1?
- 開發環境開啟 SQL log / query log,看同一條
WHERE id = ?是否重複出現數百次。 - 用 APM、慢查詢監控觀察「單次 API 的 DB round-trip 數」。
- Django:
django-debug-toolbar;Rails:bullet gem;Prisma:開 啟 query logging。
經驗法則:列表 API 若關聯被序列化進 response,預設就假設會 N+1,先寫成 Eager Loading 再驗證。
六、對照整理
| 項目 | N+1(Lazy) | Eager Loading |
|---|---|---|
| 查詢次數 | 1 + N | 通常 1~2 |
| 典型寫法 | 迴圈存取關聯、未 include | JOIN / IN / include / select_related |
| 資料量大時 | 易拖垮 DB、逾時 | 穩定、可預期 |
| 代價 | 開發當下「看起來沒寫 SQL」 | 要明確宣告會用到的關聯;過度預載也可能多撈資料 |
七、實務建議
- 列表 + 關聯一起回傳 → 一律 Eager Loading,不要依賴 Lazy。
- 只取需要的欄位/關聯,避免
include整棵樹造成過度預載。 - 一對多、多對多 優先用
IN/prefetch/selectinload,小心大JOIN造成列數爆炸。 - Code review 時盯「迴圈裡是否碰到關聯屬性」。
- 上線前用真實資料量壓測列表 API 的查詢次數,而不只看功能正不正確。