跳轉到

ORM 的陷阱

開始之前

任務目標

在這個章節中,我們會完成:

  • 了解什麼是 N+1 查詢問題
  • 使用 runserver_plus --print-sql 觀察 SQL 查詢
  • 識別並解決 ForeignKey 的 N+1 問題
  • 識別並解決 ManyToManyField 的 N+1 問題
  • 學習使用 select_relatedprefetch_related
  • 掌握 ORM 效能優化的最佳實踐

什麼是 N+1 查詢問題?

N+1 查詢問題是使用 ORM 時最常見的效能問題之一。

問題場景

假設你要顯示 10 篇文章及其作者名稱:

# 取得所有文章
articles = Article.objects.all()  # 1 次查詢

# 在 template 中顯示
for article in articles:
    print(article.author.name)  # 每篇文章都會查詢 1 次作者!

查詢次數爆炸

  • 取得文章列表:1 次查詢
  • 取得每篇文章的作者:N 次查詢(N = 文章數量)
  • 總共:1 + N 次查詢

如果有 100 篇文章,就會執行 101 次資料庫查詢

為什麼會發生?

Django ORM 採用「延遲載入」(Lazy Loading)策略:

  1. 查詢 Article.objects.all() 時,只載入文章資料
  2. 當存取 article.author 時,才會再查詢資料庫取得作者資料
  3. 每次存取都會觸發一次資料庫查詢

這個設計在單一物件時很有效率,但在處理列表時就會造成效能問題。

觀察 N+1 問題

讓我們實際觀察文章列表頁面執行了多少 SQL 查詢。

啟動 runserver_plus 並監控 SQL

使用 --print-sql 參數啟動開發伺服器:

uv run manage.py runserver_plus --print-sql

回顧 Django Extensions

runserver_plus --print-sql 是 Django Extensions 提供的功能。

如果還沒安裝,請參考前一章節「Django Extensions」。

訪問文章列表頁面

在瀏覽器中開啟:http://127.0.0.1:8000/blog/articles/

在終端機中,你會看到類似這樣的 SQL 查詢輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       "blog_article"."content",
       "blog_article"."author_id",
       "blog_article"."created_at",
       "blog_article"."updated_at"
FROM "blog_article"

Execution time: 0.001s [Database: default]

SELECT "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio"
FROM "blog_author"
WHERE "blog_author"."id" = 1

Execution time: 0.001s [Database: default]

SELECT "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio"
FROM "blog_author"
WHERE "blog_author"."id" = 2

Execution time: 0.001s [Database: default]

SELECT "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio"
FROM "blog_author"
WHERE "blog_author"."id" = 1

Execution time: 0.001s [Database: default]

... 更多類似的查詢 ...

發現問題了嗎?

仔細觀察這些 SQL 查詢:

  1. 第一個查詢:取得所有文章
  2. 後續查詢每篇文章都查詢一次作者資料

如果有 10 篇文章,就會執行 11 次查詢!

這就是典型的 N+1 查詢問題

為什麼會這樣?

回顧 blog/templates/blog/components/article_card.html

<h6 class="card-subtitle mb-2 text-muted">
  {{ article.author.name }}
</h6>

當 template 渲染時:

  1. for article in articles 迴圈遍歷每篇文章
  2. 每次存取 article.author.name 時,Django 發現作者資料還沒載入
  3. Django 立即查詢資料庫取得該作者的資料
  4. 重複 N 次(N = 文章數量)

select_related 可以解決 ForeignKey 和 OneToOneField 的 N+1 問題。

select_related 使用 SQL 的 JOIN 語法,在一次查詢中就取得關聯資料。

# ❌ 會產生 N+1 問題
articles = Article.objects.all()

# ✅ 使用 select_related,只執行一次查詢
articles = Article.objects.select_related('author').all()

修改 article_list View

修改 blog/views.py

blog/views.py
from django.shortcuts import get_object_or_404, render

from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").all()
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    return render(request, "blog/article_detail.html", {"article": article})

只需要在原本的查詢加上 .select_related("author")

select_related 參數

select_related 的參數是要載入的關聯欄位名稱:

  • select_related("author"):載入 author 關聯
  • select_related("author", "category"):載入多個關聯
  • select_related("author__country"):載入巢狀關聯(作者的國家)

再次觀察 SQL 查詢

重新整理瀏覽器頁面,觀察終端機的 SQL 輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       "blog_article"."content",
       "blog_article"."author_id",
       "blog_article"."created_at",
       "blog_article"."updated_at",
       "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio"
FROM "blog_article"
LEFT OUTER JOIN "blog_author"
  ON ("blog_article"."author_id" = "blog_author"."id")

Execution time: 0.002s [Database: default]

問題解決!

現在只執行了 1 次查詢

使用 LEFT OUTER JOIN 將文章和作者的資料一次取回。

無論有多少篇文章,都只需要 1 次查詢。

效能比較

方法 文章數量 SQL 查詢次數 執行時間
未優化 10 11 次 ~10ms
select_related 10 1 次 ~2ms
未優化 100 101 次 ~100ms
select_related 100 1 次 ~3ms

效能差異

隨著資料量增加,效能差異會更加明顯:

  • 減少資料庫查詢次數
  • 降低網路往返時間
  • 提升頁面載入速度

在生產環境中,這可能是 100ms 和 10ms 的差異!

在文章列表顯示標籤

現在讓我們在文章卡片中顯示標籤資訊。

修改 article_card.html

修改 blog/templates/blog/components/article_card.html

blog/templates/blog/components/article_card.html
<div class="col-12 col-sm-6 col-md-6 col-lg-4 col-xl-3 mb-4">
  <div class="card h-100">
    <div class="card-body d-flex flex-column">
      <h5 class="card-title">
        {{ article.title }}
      </h5>
      <h6 class="card-subtitle mb-2 text-muted">
        {{ article.author.name }}
      </h6>
      {% if article.tags.exists %}
        <div class="mb-2">
          {% for tag in article.tags.all %}
            <span class="badge bg-secondary me-1">{{ tag.name }}</span>
          {% endfor %}
        </div>
      {% endif %}
      <p class="card-text flex-grow-1">
        {{ article.content|truncatewords:30 }}
      </p>
    </div>
    <div class="card-footer bg-transparent">
      <div class="d-flex justify-content-between align-items-center">
        <small class="text-muted">
          {{ article.created_at|date:"Y-m-d" }}
        </small>
        <a href="{% url 'blog:article_detail' article.id %}"
           class="btn btn-primary btn-sm">
          閱讀更多
        </a>
      </div>
    </div>
  </div>
</div>

我們新增了一個區塊來顯示文章的標籤。

ManyToManyField 的 N+1 問題

觀察 SQL 查詢

重新整理頁面,觀察終端機的 SQL 輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       "blog_article"."content",
       "blog_article"."created_at",
       "blog_article"."updated_at",
       "blog_article"."is_published",
       "blog_article"."author_id",
       "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio",
       "blog_author"."created_at"
  FROM "blog_article"
  LEFT OUTER JOIN "blog_author"
    ON ("blog_article"."author_id" = "blog_author"."id")

Execution time: 0.000100s [Database: default]
SELECT 1 AS "a"  -- (1)!
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 2
 LIMIT 1

Execution time: 0.000108s [Database: default]
SELECT "blog_tag"."id",
       "blog_tag"."name"
  FROM "blog_tag"
 INNER JOIN "blog_article_tags"
    ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
 WHERE "blog_article_tags"."article_id" = 2

Execution time: 0.000140s [Database: default]
SELECT 1 AS "a"
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 3
 LIMIT 1

Execution time: 0.000068s [Database: default]
SELECT "blog_tag"."id",
       "blog_tag"."name"
  FROM "blog_tag"
 INNER JOIN "blog_article_tags"
    ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
 WHERE "blog_article_tags"."article_id" = 3

Execution time: 0.000099s [Database: default]
SELECT 1 AS "a"
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 4
 LIMIT 1

Execution time: 0.000143s [Database: default]
SELECT 1 AS "a"
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 5
 LIMIT 1

Execution time: 0.000070s [Database: default]
SELECT "blog_tag"."id",
       "blog_tag"."name"
  FROM "blog_tag"
 INNER JOIN "blog_article_tags"
    ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
 WHERE "blog_article_tags"."article_id" = 5

Execution time: 0.000069s [Database: default]
SELECT 1 AS "a"
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 6
 LIMIT 1

Execution time: 0.000093s [Database: default]
SELECT "blog_tag"."id",
       "blog_tag"."name"
  FROM "blog_tag"
 INNER JOIN "blog_article_tags"
    ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
 WHERE "blog_article_tags"."article_id" = 6

Execution time: 0.000083s [Database: default]
... 更多類似的查詢 ...
  1. 因為我們使用 .exists 所以會產生這個類型的 Query

又出現 N+1 問題了!

這次是標籤的 N+1 問題:

  • 第一個查詢:取得所有文章(包含作者,已優化)
  • 後續查詢:每篇文章都需要確認標籤資料是否存在,若存在再查詢一次標籤資料

如果有 10 篇文章,就會執行 1 + 10 = 11 次查詢(先忽略確認是否存在的那次,計算上來只會更多)。

select_related 的限制

select_related 只能用於:

  • ForeignKey(多對一關聯)
  • OneToOneField(一對一關聯)

不能用於 ManyToManyField(多對多關聯)!

為什麼?因為:

  • ForeignKey 使用簡單的 JOIN 就能取得資料
  • ManyToManyField 需要透過中介表,無法用簡單的 JOIN 一次取得所有資料

prefetch_related 可以解決 ManyToManyField 和反向 ForeignKey 的 N+1 問題。

prefetch_related 使用額外的 SQL 查詢,但會批次載入所有關聯資料。

# ❌ 會產生 N+1 問題
articles = Article.objects.all()

# ✅ 使用 prefetch_related
articles = Article.objects.prefetch_related('tags').all()
特性 select_related prefetch_related
適用關聯 ForeignKey, OneToOneField ManyToManyField, 反向 ForeignKey
查詢方式 使用 SQL JOIN,一次查詢 分開查詢,然後在 Python 中組合
查詢次數 1 次 2 次(但是批次查詢)
適用場景 一對一、多對一 一對多、多對多

修改 article_list View

修改 blog/views.py,同時使用 select_relatedprefetch_related

blog/views.py
from django.shortcuts import get_object_or_404, render

from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")  # (1)!
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(Article, id=article_id)
    return render(request, "blog/article_detail.html", {"article": article})
  1. objects 後有跟其他查詢的情況下 .all() 是可以被省略的

我們在原本的查詢後面加上 .prefetch_related("tags")

鏈式呼叫

Django ORM 支援鏈式呼叫,可以組合多個方法:

Article.objects.select_related("author").prefetch_related("tags").filter(is_published=True).order_by("-created_at")

每個方法都會回傳一個新的 QuerySet,可以繼續串接其他方法。

再次觀察 SQL 查詢

重新整理頁面,觀察 SQL 輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       ...
FROM "blog_article"
LEFT OUTER JOIN "blog_author"
  ON ("blog_article"."author_id" = "blog_author"."id")

Execution time: 0.002s [Database: default]

SELECT ("blog_article_tags"."article_id") AS "_prefetch_related_val_article_id",
       "blog_tag"."id",
       "blog_tag"."name"
FROM "blog_tag"
INNER JOIN "blog_article_tags"
  ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
WHERE "blog_article_tags"."article_id" IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10)

Execution time: 0.001s [Database: default]

標籤問題也解決了!

現在總共只執行了 2 次查詢

  1. 第一次查詢:取得所有文章和作者(使用 JOIN)
  2. 第二次查詢:批次取得所有文章的標籤(使用 IN 查詢)

注意第二個查詢使用了 WHERE article_id IN (1, 2, 3, ...),一次取得所有文章的標籤!

效能比較

優化階段 查詢次數(10 篇文章) 說明
未優化 21 次 1 (文章) + 10 (作者) + 10 (標籤)
select_related 11 次 1 (文章+作者) + 10 (標籤)
完整優化 2 次 1 (文章+作者) + 1 (批次標籤)

查詢次數固定

使用 prefetch_related 後,無論有多少篇文章:

  • 未優化:1 + N + N = 1 + 2N 次查詢
  • 完整優化:2 次查詢(固定)

當 N = 100 時,差異是 201 次 vs 2 次!

檢查文章詳情頁面

現在讓我們檢查文章詳情頁面是否也有類似的問題。

觀察 article_detail 頁面

訪問任一文章的詳情頁面,例如:http://127.0.0.1:8000/blog/articles/1/

觀察終端機的 SQL 輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       "blog_article"."content",
       "blog_article"."created_at",
       "blog_article"."updated_at",
       "blog_article"."is_published",
       "blog_article"."author_id"
  FROM "blog_article"
 WHERE "blog_article"."id" = 3
 LIMIT 21

Execution time: 0.000163s [Database: default]
SELECT "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio",
       "blog_author"."created_at"
  FROM "blog_author"
 WHERE "blog_author"."id" = 1
 LIMIT 21

Execution time: 0.000250s [Database: default]
SELECT 1 AS "a"
  FROM "blog_article_tags"
 WHERE "blog_article_tags"."article_id" = 3
 LIMIT 1

Execution time: 0.000075s [Database: default]
SELECT "blog_tag"."id",
       "blog_tag"."name"
  FROM "blog_tag"
 INNER JOIN "blog_article_tags"
    ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
 WHERE "blog_article_tags"."article_id" = 3

Execution time: 0.000117s [Database: default]

詳情頁面也有問題

雖然只顯示一篇文章,但仍然執行了 4 次查詢:

  1. 查詢文章
  2. 查詢作者
  3. 確認是否有 tags
  4. 查詢標籤

單一文章時問題不大,但如果頁面需要顯示多個相關文章,問題就會浮現。 而且多次訪問資料庫也會產生一定的網路資源浪費。

優化 article_detail View

修改 blog/views.py

blog/views.py
from django.shortcuts import get_object_or_404, render

from blog.models import Article


def article_list(request):
    articles = Article.objects.select_related("author").prefetch_related("tags")
    return render(request, "blog/article_list.html", {"articles": articles})


def article_detail(request, article_id):
    article = get_object_or_404(
        Article.objects.select_related("author").prefetch_related("tags"),
        id=article_id,
    )
    return render(request, "blog/article_detail.html", {"article": article})

get_object_or_404 中使用優化

get_object_or_404 的第一個參數可以是:

  • Model:get_object_or_404(Article, id=1)
  • QuerySet:get_object_or_404(Article.objects.select_related("author"), id=1)

這讓我們可以在取得物件時就進行優化!

再次觀察 SQL 查詢

重新整理詳情頁面,觀察 SQL 輸出:

SELECT "blog_article"."id",
       "blog_article"."title",
       "blog_article"."content",
       "blog_article"."author_id",
       "blog_article"."created_at",
       "blog_article"."updated_at",
       "blog_author"."id",
       "blog_author"."name",
       "blog_author"."email",
       "blog_author"."bio"
FROM "blog_article"
LEFT OUTER JOIN "blog_author"
  ON ("blog_article"."author_id" = "blog_author"."id")
WHERE "blog_article"."id" = 1

Execution time: 0.001s [Database: default]

SELECT ("blog_article_tags"."article_id") AS "_prefetch_related_val_article_id",
       "blog_tag"."id",
       "blog_tag"."name"
FROM "blog_tag"
INNER JOIN "blog_article_tags"
  ON ("blog_tag"."id" = "blog_article_tags"."tag_id")
WHERE "blog_article_tags"."article_id" IN (1)

Execution time: 0.001s [Database: default]

優化完成

現在只需要 2 次查詢

  1. 取得文章和作者(使用 JOIN)
  2. 取得標籤

即使只有一篇文章,預先載入關聯資料也是好習慣,能確保效能一致。

最佳實踐

1. 永遠思考查詢效能

開發時的好習慣

在開發時使用 runserver_plus --print-sql

  • 即時看到每個頁面執行的 SQL 查詢
  • 發現 N+1 問題時立即優化
  • 養成效能意識

2. 根據關聯類型選擇方法

關聯類型 使用方法 範例
ForeignKey select_related select_related("author")
OneToOneField select_related select_related("profile")
ManyToManyField prefetch_related prefetch_related("tags")
反向 ForeignKey prefetch_related prefetch_related("comments")

常見錯誤

# ❌ 錯誤:對 ManyToManyField 使用 select_related
Article.objects.select_related("tags")

# ✅ 正確:使用 prefetch_related
Article.objects.prefetch_related("tags")

3. 組合使用

一個查詢可以同時使用兩種方法:

# ✅ 同時優化多種關聯
Article.objects.select_related(
    "author",           # ForeignKey
    "category",         # ForeignKey
).prefetch_related(
    "tags",             # ManyToManyField
    "comments",         # 反向 ForeignKey
)

4. 巢狀關聯

可以使用雙底線存取巢狀關聯:

# 載入文章、作者、以及作者的國家
Article.objects.select_related("author__country")

# 載入文章、標籤、以及每個標籤的分類
Article.objects.prefetch_related("tags__category")

5. 何時不需要優化?

不是所有情況都需要優化

以下情況可以不用優化:

  • 只顯示單一物件,且不存取關聯資料
  • 關聯資料不會被使用(例如只顯示文章標題,不顯示作者)

但是過度優化也可能造成不必要的記憶體使用。

除錯工具

Django Debug Toolbar

除了 --print-sql,還可以使用 Django Debug Toolbar

這是一個強大的除錯工具,可以在瀏覽器中直接顯示每個請求的詳細資訊。

安裝

使用 uv 安裝(加入 dev group,因為只在開發環境使用):

uv add --group=dev django-debug-toolbar

設定

步驟 1:在 INSTALLED_APPS 最後條件式加入

core/settings.py
INSTALLED_APPS = [
    # Django 內建 apps
    "django.contrib.admin",
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "django.contrib.sessions",
    "django.contrib.messages",
    "django.contrib.staticfiles",
    # 第三方 apps
    "django_bootstrap5",
    "django_extensions",
    # 本地 apps
    "practices",
    "blog",
]

if DEBUG: # (1)!
    INSTALLED_APPS += ["debug_toolbar"]
  1. 只在 DEBUG 模式下加入 Debug Toolbar

為什麼要條件式加入?

因為 django-debug-toolbar 是安裝在 dev group 中,只在開發環境需要。

使用 if DEBUG: 條件式加入可以確保:

  • 開發環境(DEBUG = True):自動啟用 Debug Toolbar
  • 正式環境(DEBUG = False):不會載入 Debug Toolbar

這是 Django 的最佳實踐!

步驟 2:在 MIDDLEWARE 最後條件式加入

core/settings.py
MIDDLEWARE = [
    "django.middleware.security.SecurityMiddleware",
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.middleware.common.CommonMiddleware",
    "django.middleware.csrf.CsrfViewMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django.contrib.messages.middleware.MessageMiddleware",
    "django.middleware.clickjacking.XFrameOptionsMiddleware",
]

if DEBUG:  # (1)!
    MIDDLEWARE = ["debug_toolbar.middleware.DebugToolbarMiddleware", *MIDDLEWARE]
  1. 只在 DEBUG 模式下加入 Debug Toolbar Middleware

Middleware 順序很重要

Debug Toolbar 的 middleware 要盡可能地放在 middleware 列表的前面

這樣可以確保它能捕捉到所有其他 middleware 的處理過程。

詳細說明請參考文件

步驟 3:設定 INTERNAL_IPS

core/settings.py
INTERNAL_IPS = [
    "127.0.0.1",
]

INTERNAL_IPS 的作用

INTERNAL_IPS 定義哪些 IP 位址可以看到 Debug Toolbar。

  • 127.0.0.1 是本地開發環境的 IP
  • 只有從這個 IP 發出的請求才會顯示 Debug Toolbar
  • 這是一個安全措施,避免在開發伺服器被外部存取時洩漏資訊

P.S. 這個設定不是 Debug Toolbar 提供的,而是 Django 內建的可以參考文件

修改 core/urls.py,加入 Debug Toolbar 的 URL 路由:

core/urls.py
from django.conf import settings
from django.contrib import admin
from django.urls import include, path

urlpatterns = [
    path("admin/", admin.site.urls),
    path("practices/", include("practices.urls")),
    path("blog/", include("blog.urls")),
]

if settings.DEBUG:
    from debug_toolbar.toolbar import debug_toolbar_urls

    urlpatterns = [*urlpatterns, *debug_toolbar_urls()]

條件式載入

使用 if settings.DEBUG: 確保 Debug Toolbar 只在開發環境啟用。

正式環境中(DEBUG = False),這些 URL 不會被載入。

使用

啟動開發伺服器:

uv run manage.py runserver

訪問任何頁面(例如文章列表),你會在頁面右側看到一個工具列:

  • SQL:顯示所有執行的 SQL 查詢
    • 可以看到每個查詢的執行時間
    • 會標示重複的查詢
    • 點擊可以看到完整的 SQL 語句和執行堆疊
  • Time:頁面渲染時間分析
  • Templates:使用的 template 列表
  • Static Files:載入的靜態檔案
  • Headers:HTTP 標頭資訊
  • Request:請求詳細資訊

Debug Toolbar 的優勢

相比 --print-sql,Debug Toolbar 提供:

  • 視覺化介面:更容易閱讀和分析
  • 重複查詢檢測:自動標示重複的查詢
  • 查詢時間分析:快速找出慢查詢
  • 完整的執行堆疊:知道查詢是從哪裡觸發的
  • 不需要看終端機:直接在瀏覽器中檢視

兩者可以搭配使用:

  • 開發時用 Debug Toolbar 視覺化分析
  • 需要完整 SQL 輸出時用 --print-sql

使用 Django Shell 測試

在優化前後使用 shell 測試查詢:

uv run manage.py shell_plus --print-sql
# 未優化
>>> articles = Article.objects.all()
>>> for article in articles:
...     print(article.author.name)
# 觀察執行了多少次查詢

# 優化後
>>> articles = Article.objects.select_related("author").all()
>>> for article in articles:
...     print(article.author.name)
# 應該只有一次查詢

常見問題

可以!而且經常需要一起使用:

Article.objects.select_related("author").prefetch_related("tags")

這樣可以同時優化不同類型的關聯。

會不會載入太多不需要的資料?

使用 select_relatedprefetch_related 確實會載入更多資料到記憶體,但:

利大於弊

  • 減少資料庫查詢次數遠比多載入一些資料重要
  • 資料庫往返時間通常是效能瓶頸
  • 記憶體使用增加通常可以接受
  • 可以用 .only().defer() 進一步控制載入的欄位

因為 ManyToManyField 的特性:

  • 使用中介表連接
  • 一篇文章可能有多個標籤
  • 一個標籤可能屬於多篇文章

如果使用 JOIN,結果會產生笛卡爾積(Cartesian Product),造成:

  • 大量重複資料
  • 記憶體浪費
  • 查詢效能下降

prefetch_related 使用分開查詢的方式,然後在 Python 中組合,效能更好。

任務結束

完成!

恭喜你完成了這個章節!現在你已經:

  • 了解什麼是 N+1 查詢問題
  • 使用 runserver_plus --print-sql 觀察 SQL 查詢
  • 識別並解決 ForeignKey 的 N+1 問題
  • 識別並解決 ManyToManyField 的 N+1 問題
  • 學習使用 select_relatedprefetch_related
  • 掌握 ORM 效能優化的最佳實踐

效能優化的黃金法則

  1. 永遠使用 --print-sql 檢查查詢

    • 在開發時就發現問題
    • 不要等到上線才發現效能問題
  2. 記住優化方法

    • ForeignKey / OneToOneField → select_related
    • ManyToManyField / 反向 ForeignKey → prefetch_related
  3. 養成好習慣

    • 在撰寫 view 時就考慮優化
    • 列表頁面一定要優化
    • 即使單一物件也建議優化
  4. 不要過度優化

    • 只優化會被使用的關聯
    • 測量實際效能影響
    • 在可維護性和效能間取得平衡

掌握這些技巧後,你的 Django 應用效能會大幅提升!

記住:效能問題通常出在資料庫查詢,優化 ORM 查詢是提升 Django 應用效能的關鍵。