Skip to main content

ORM 與 N+1 查詢問題

ORM(Object-Relational Mapping,物件-關聯對映)讓我們用物件操作資料庫,開發速度快,但也容易踩到效能陷阱。其中最常見、也最致命的,就是 N+1 查詢問題(N+1 Query Problem)


一、什麼是 ORM?

ORM 把資料表對應成程式物件(例如 UserProfile),用方法呼叫取代手寫 SQL。常見工具:

生態系ORM
PythonDjango ORM、SQLAlchemy
Node.js / TSPrisma、TypeORM、Sequelize
JavaHibernate / JPA
RubyActiveRecord(Rails)

便利的代價是:預設常採 Lazy Loading(懶加載)——關聯資料「用到才查」。這正是 N+1 的溫床。


二、什麼是 N+1 查詢?

原本 一次就能抓完 的資料,被拆成 1 + N 次 資料庫往返(Round-trip):

  1. 主查詢(1 次):撈出主資料列表
    例如取得 1,000 位使用者:SELECT * FROM users
  2. 子查詢(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
典型寫法迴圈存取關聯、未 includeJOIN / IN / include / select_related
資料量大時易拖垮 DB、逾時穩定、可預期
代價開發當下「看起來沒寫 SQL」要明確宣告會用到的關聯;過度預載也可能多撈資料

七、實務建議

  1. 列表 + 關聯一起回傳 → 一律 Eager Loading,不要依賴 Lazy。
  2. 只取需要的欄位/關聯,避免 include 整棵樹造成過度預載。
  3. 一對多、多對多 優先用 IN / prefetch / selectinload,小心大 JOIN 造成列數爆炸。
  4. Code review 時盯「迴圈裡是否碰到關聯屬性」。
  5. 上線前用真實資料量壓測列表 API 的查詢次數,而不只看功能正不正確。