Django N+1 查詢問題與 QuerySet 優化實戰 | Django 教學
N+1 查詢問題(N+1 Query Problem) 是 Django ORM 中最常見也最具破壞力的效能陷阱。它的表現形式是:執行 1 次查詢取得物件列表後,在迴圈中對每個物件的關聯欄位存取又觸發了 N 次額外查詢,導致原本只需 2-3 次的查詢暴增至數百甚至數千次。本篇將從 N+1 問題的成因與 SQL 對比開始,深入講解 select_related() 的 JOIN 策略、prefetch_related() 的獨立查詢合併機制、Prefetch 物件 的自訂查詢,以及 only()、defer()、values()、values_list()、iterator() 等 QuerySet 優化技巧,最後透過實戰對比展示優化前後的查詢數量與回應時間差異。
N+1 查詢問題詳解
先建立一個範例場景:一個部落格系統,有 Author(作者)、Book(書籍)和 Tag(標籤)三個 Model:
# models.py
class Author(models.Model):
name = models.CharField(max_length=100)
email = models.EmailField()
class Tag(models.Model):
name = models.CharField(max_length=50)
class Book(models.Model):
title = models.CharField(max_length=200)
author = models.ForeignKey(Author, on_delete=models.CASCADE, related_name='books')
tags = models.ManyToManyField(Tag, related_name='books')
content = models.TextField()
created_at = models.DateTimeField(auto_now_add=True)
N+1 問題的產生
# 問題程式碼:產生 N+1 查詢
books = Book.objects.all() # 第 1 次查詢:取得所有書籍
for book in books:
print(book.author.name) # 每次迴圈觸發 1 次查詢 → N 次查詢
假設資料庫中有 100 本書,這段程式碼會產生 101 次 SQL 查詢:
-- 第 1 次查詢:取得所有書籍
SELECT * FROM book;
-- 第 2 ~ 101 次查詢:每本書各查一次作者
SELECT * FROM author WHERE id = 1;
SELECT * FROM author WHERE id = 2;
SELECT * FROM author WHERE id = 3;
-- ... 重複 100 次
如果同時存取多對多關聯,問題會更嚴重:
# 雙重 N+1:1 + N + N = 1 + 100 + 100 = 201 次查詢!
books = Book.objects.all()
for book in books:
print(book.author.name) # N 次查詢
print(book.tags.all()) # 又 N 次查詢
select_related():JOIN 解決 ForeignKey N+1
select_related() 透過 SQL JOIN 在單一查詢中同時取得主物件和關聯物件,適用於 ForeignKey 和 OneToOneField 關係。
基本用法
# 使用 select_related:只產生 1 次 SQL 查詢
books = Book.objects.select_related('author')
for book in books:
print(book.author.name) # 不觸發額外查詢,資料已透過 JOIN 載入
-- 只有 1 次 SQL 查詢(INNER JOIN)
SELECT book.*, author.*
FROM book
INNER JOIN author ON book.author_id = author.id;
多層巢狀 JOIN
select_related() 支援透過雙底線 __ 存取多層關聯:
# 假設 Author 有 ForeignKey 指向 Publisher
class Author(models.Model):
name = models.CharField(max_length=100)
publisher = models.ForeignKey(Publisher, on_delete=models.CASCADE)
# 多層 JOIN:Book → Author → Publisher
books = Book.objects.select_related('author__publisher')
-- 產生的 SQL
SELECT book.*, author.*, publisher.*
FROM book
INNER JOIN author ON book.author_id = author.id
INNER JOIN publisher ON author.publisher_id = publisher.id;
適用場景
| 關係類型 | 是否適用 select_related | 原因 |
|---|---|---|
| ForeignKey(正向) | 適用 | 單一物件,JOIN 效率高 |
| OneToOneField | 適用 | 單一物件,JOIN 效率高 |
| ManyToManyField | 不適用 | 多對多會產生重複資料 |
| 反向 ForeignKey | 不適用 | 一對多,JOIN 會產生重複 |
prefetch_related():獨立查詢解決 ManyToMany N+1
prefetch_related() 的策略不同於 select_related():它先執行額外的獨立查詢取得所有關聯資料,再用 Python 在記憶體中合併,適用於 ManyToManyField 和 反向 ForeignKey 關聯。
基本用法
# 使用 prefetch_related:產生 2 次 SQL 查詢(而非 1 + N 次)
books = Book.objects.prefetch_related('tags')
for book in books:
print(book.tags.all()) # 不觸發額外查詢,資料已 prefetch
-- 第 1 次查詢:取得所有書籍
SELECT * FROM book;
-- 第 2 次查詢:取得所有相關標籤(透過中介表)
SELECT tag.*, book_tags.book_id
FROM tag
INNER JOIN book_tags ON tag.id = book_tags.tag_id
WHERE book_tags.book_id IN (1, 2, 3, ...);
反向 ForeignKey 的 prefetch
# 取得所有作者和他們的書籍(反向關聯)
authors = Author.objects.prefetch_related('books')
for author in authors:
print(author.books.all()) # 不觸發額外查詢
-- 第 1 次查詢:取得所有作者
SELECT * FROM author;
-- 第 2 次查詢:取得所有相關書籍
SELECT * FROM book WHERE author_id IN (1, 2, 3, ...);
混合使用 select_related 與 prefetch_related
# 最佳實踐:根據關聯類型選擇合適的方法
books = Book.objects.select_related(
'author', # ForeignKey → 用 select_related (JOIN)
).prefetch_related(
'tags', # ManyToMany → 用 prefetch_related (獨立查詢)
)
for book in books:
print(book.author.name) # 無額外 SQL(已 JOIN)
print(book.tags.all()) # 無額外 SQL(已 prefetch)
# 總共只有 2 次 SQL 查詢(原本需要 1 + N + N 次)
Prefetch 物件:自訂 prefetch 查詢
Prefetch 物件讓你可以對 prefetch 的查詢加上篩選條件、排序、甚至串接 select_related()。
queryset 參數
from django.db.models import Prefetch
# 只 prefetch 已核准的評論,並預載入評論的使用者
books = Book.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(
approved=True
).select_related('user').order_by('-created_at'),
)
)
to_attr 參數
to_attr 可以將 prefetch 結果存到自訂屬性,回傳 Python list(而非 QuerySet),且不會覆蓋原本的 Manager:
books = Book.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.filter(approved=True),
to_attr='approved_comments', # 存到自訂屬性
),
Prefetch(
'comments',
queryset=Comment.objects.filter(approved=False),
to_attr='pending_comments', # 同一關聯的不同條件
),
)
for book in books:
print(book.approved_comments) # list,非 QuerySet
print(book.pending_comments) # list,非 QuerySet
print(book.comments.count()) # 原本的 Manager 不受影響(但會觸發新查詢!)
only() 與 defer():延遲載入欄位
當 Model 包含大型欄位(如 TextField、BinaryField)但你不需要這些欄位時,可以使用 only() 或 defer() 來減少傳輸的資料量。
only():白名單模式
# 只載入 id、title、author_id 三個欄位
books = Book.objects.only('id', 'title', 'author')
for book in books:
print(book.title) # 正常存取,不觸發額外查詢
print(book.content) # 觸發額外查詢!延遲載入的欄位
-- only() 產生的 SQL
SELECT id, title, author_id FROM book;
-- 存取延遲欄位時觸發的額外查詢
SELECT id, content FROM book WHERE id = 1;
defer():黑名單模式
# 排除 content 和 html_content 欄位,其他欄位正常載入
books = Book.objects.defer('content')
for book in books:
print(book.title) # 正常存取
print(book.author_id) # 正常存取
print(book.content) # 觸發額外查詢!
選擇建議
| 方法 | 模式 | 適用場景 |
|---|---|---|
only() | 白名單:只載入指定欄位 | 只需要少數欄位時 |
defer() | 黑名單:排除指定欄位 | 需要大部分欄位,只想排除少數大型欄位時 |
values() 與 values_list():回傳字典/元組
如果你不需要 Model 實例的完整功能(如方法呼叫),使用 values() 或 values_list() 可以跳過 Model 實例化,直接回傳輕量的字典或元組,效能更好。
values():回傳字典
# 回傳 QuerySet of dict
books = Book.objects.values('id', 'title', 'author__name')
# [{'id': 1, 'title': 'Django 入門', 'author__name': '小明'}, ...]
# 可搭配聚合函數
from django.db.models import Count
tag_stats = Tag.objects.values('name').annotate(
book_count=Count('books')
).order_by('-book_count')
# [{'name': 'Python', 'book_count': 42}, {'name': 'Django', 'book_count': 35}, ...]
values_list():回傳元組
# 回傳 QuerySet of tuple
books = Book.objects.values_list('id', 'title')
# [(1, 'Django 入門'), (2, 'Python 進階'), ...]
# flat=True:單一欄位時回傳扁平化列表
emails = Author.objects.values_list('email', flat=True)
# ['alice@example.com', 'bob@example.com', ...]
效能比較
import timeit
# Model 實例化(最慢)
list(Book.objects.all()) # ~50ms
# only()(較快,但仍建立 Model 實例)
list(Book.objects.only('id', 'title')) # ~30ms
# values()(最快,回傳 dict)
list(Book.objects.values('id', 'title')) # ~15ms
# values_list()(最快,回傳 tuple)
list(Book.objects.values_list('id', 'title')) # ~12ms
iterator():大批量資料處理
當需要遍歷大量資料(如數十萬筆)時,Django 預設會將整個 QuerySet 的結果一次性載入記憶體。iterator() 方法改為使用伺服器端游標(Server-side Cursor),逐批取得資料,大幅降低記憶體用量:
# 不佳:一次載入所有資料到記憶體
for book in Book.objects.all(): # 10 萬筆 → 全部載入記憶體
process(book)
# 改善:使用 iterator() 逐批處理
for book in Book.objects.all().iterator(chunk_size=2000):
process(book)
# chunk_size 控制每批取得的筆數,預設為 2000
注意事項:
iterator()會停用 QuerySet 的內部快取,因此不適合需要重複遍歷的場景- 搭配
only()或values()使用,可進一步降低每筆資料的記憶體佔用 prefetch_related()與iterator()搭配使用時,prefetch 仍然會一次載入所有關聯資料
django-debug-toolbar SQL 面板偵測 N+1
除了人工審查程式碼,Django Debug Toolbar 的 SQL 面板是偵測 N+1 問題最直觀的工具。
辨識 N+1 的徵兆
- 大量相似查詢:SQL 面板中出現多條結構相同但參數不同的查詢
- 紅色背景標記:相似查詢超過 2 次會被標記為紅色
- 查詢數量異常:一個簡單的列表頁面卻有幾十甚至幾百條 SQL 查詢
程式化偵測:assertNumQueries
在測試中使用 assertNumQueries 可以防止 N+1 問題在重構後重新出現:
from django.test import TestCase
class BookListViewTest(TestCase):
def setUp(self):
author = Author.objects.create(name='Test Author')
for i in range(20):
Book.objects.create(author=author, title=f'Book {i}')
def test_list_view_no_n_plus_1(self):
"""確保書籍列表頁面不產生 N+1 查詢"""
with self.assertNumQueries(2): # 1 條書籍 + 1 條作者(或 1 條 JOIN)
response = self.client.get('/books/')
self.assertEqual(response.status_code, 200)
def test_detail_view_query_count(self):
"""確保詳情頁面的查詢數量固定"""
with self.assertNumQueries(3): # 書籍 + 作者 + 標籤
response = self.client.get('/books/1/')
self.assertEqual(response.status_code, 200)
實戰對比:優化前 vs 優化後
以一個書籍列表頁面為例,顯示每本書的標題、作者名稱和標籤列表。假設資料庫中有 100 本書、50 位作者、30 個標籤。
優化前
# views.py - 未優化版本
def book_list(request):
books = Book.objects.all() # 只取得書籍
return render(request, 'books/list.html', {'books': books})
<!-- books/list.html -->
{% for book in books %}
<h3>{{ book.title }}</h3>
<p>作者:{{ book.author.name }}</p> <!-- 觸發 N+1! -->
<p>標籤:
{% for tag in book.tags.all %} <!-- 觸發 N+1! -->
{{ tag.name }}
{% endfor %}
</p>
{% endfor %}
SQL 查詢數量:1(書籍)+ 100(作者)+ 100(標籤)= 201 次
頁面載入時間:~850ms
優化後
# views.py - 優化版本
def book_list(request):
books = Book.objects.select_related(
'author', # ForeignKey → JOIN
).prefetch_related(
'tags', # ManyToMany → 獨立查詢
).only(
'id', 'title', 'author', # 排除不需要的大型欄位
)
return render(request, 'books/list.html', {'books': books})
SQL 查詢數量:1(書籍 JOIN 作者)+ 1(標籤)= 2 次
頁面載入時間:~45ms
效能對比總結
| 指標 | 優化前 | 優化後 | 改善幅度 |
|---|---|---|---|
| SQL 查詢數量 | 201 次 | 2 次 | 減少 99% |
| 頁面載入時間 | ~850ms | ~45ms | 提升 18 倍 |
| 資料庫負載 | 極高 | 極低 | 顯著降低 |
常用 QuerySet 優化方法速查表
| 方法 | 功能 | 適用場景 |
|---|---|---|
select_related() | SQL JOIN 載入 FK/O2O 關聯 | ForeignKey、OneToOneField |
prefetch_related() | 獨立查詢載入 M2M/反向關聯 | ManyToManyField、反向 FK |
Prefetch() | 自訂 prefetch 查詢條件 | 帶篩選/排序的 prefetch |
only() | 只載入指定欄位 | 減少資料量 |
defer() | 排除指定欄位 | 排除大型欄位 |
values() | 回傳字典 | 不需要 Model 實例 |
values_list() | 回傳元組 | 高效取得單一欄位 |
iterator() | 逐批載入 | 大批量資料處理 |
exists() | 檢查是否有資料 | 取代 bool(qs) 或 count() |
count() | 計算筆數 | 取代 len(qs) |
總結
N+1 查詢問題是 Django ORM 使用者最常踩到的效能陷阱,但解決方案非常明確:ForeignKey 和 OneToOneField 使用 select_related() 透過 SQL JOIN 一次載入;ManyToManyField 和反向關聯使用 prefetch_related() 透過獨立查詢合併;需要自訂 prefetch 條件時使用 Prefetch 物件搭配 queryset 和 to_attr 參數。在此基礎上,only() 和 defer() 可以減少不必要的欄位載入,values() 和 values_list() 則在不需要 Model 實例時提供更高效的替代方案,iterator() 則適合大批量資料的逐批處理。搭配 Django Debug Toolbar 的 SQL 面板和測試中的 assertNumQueries,你可以在開發階段就及時發現並修正 N+1 問題,確保應用在資料量增長後依然保持良好效能。