Django N+1 查詢問題與 QuerySet 優化實戰 | Django 教學

2026/06/29 2026/05/22
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 在單一查詢中同時取得主物件和關聯物件,適用於 ForeignKeyOneToOneField 關係。

基本用法

# 使用 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, ...);
# 最佳實踐:根據關聯類型選擇合適的方法
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 包含大型欄位(如 TextFieldBinaryField)但你不需要這些欄位時,可以使用 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 的徵兆

  1. 大量相似查詢:SQL 面板中出現多條結構相同但參數不同的查詢
  2. 紅色背景標記:相似查詢超過 2 次會被標記為紅色
  3. 查詢數量異常:一個簡單的列表頁面卻有幾十甚至幾百條 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 物件搭配 querysetto_attr 參數。在此基礎上,only()defer() 可以減少不必要的欄位載入,values()values_list() 則在不需要 Model 實例時提供更高效的替代方案,iterator() 則適合大批量資料的逐批處理。搭配 Django Debug Toolbar 的 SQL 面板和測試中的 assertNumQueries,你可以在開發階段就及時發現並修正 N+1 問題,確保應用在資料量增長後依然保持良好效能。

BenZ Software Developer

熱愛技術的軟體開發者,在這裡分享程式開發經驗與學習筆記。