Django 面試準備 10-1:N+1 查詢問題

深入理解 Django ORM 最常見的性能陷阱

10-1. N+1 查詢問題(N+1 Query Problem)

📌 什麼是 N+1 查詢問題?

簡單說: 查詢 N 條數據,卻執行了 N+1 次數據庫查詢

定義: 在查詢主對象時執行 1 次查詢,然後在遍歷時為每個對象的關聯數據又執行 N 次查詢。


🔴 問題演示

場景:查詢所有文章及其作者

# models.py
class Author(models.Model):
    name = models.CharField(max_length=100)
    email = models.EmailField()

class Post(models.Model):
    title = models.CharField(max_length=200)
    content = models.TextField()
    author = models.ForeignKey(Author, on_delete=models.CASCADE)
    created_at = models.DateTimeField(auto_now_add=True)

❌ 有問題的代碼

# views.py
def post_list(request):
    # 查詢所有文章
    posts = Post.objects.all()  # 第 1 次查詢:SELECT * FROM post

    # 遍歷文章,獲取作者名稱
    for post in posts:
        print(f"{post.title} by {post.author.name}")  # 每次都查詢!

🔍 執行的 SQL

-- 第 1 次查詢:獲取所有文章
SELECT * FROM post;  -- 返回 100 條文章

-- 第 2 次查詢:獲取第 1 篇文章的作者
SELECT * FROM author WHERE id = 1;

-- 第 3 次查詢:獲取第 2 篇文章的作者
SELECT * FROM author WHERE id = 5;

-- 第 4 次查詢:獲取第 3 篇文章的作者
SELECT * FROM author WHERE id = 2;

...

-- 第 101 次查詢:獲取第 100 篇文章的作者
SELECT * FROM author WHERE id = 23;

結果: 1 + 100 = 101 次數據庫查詢


📊 性能影響

時間對比

import time
from django.db import connection, reset_queries
from django.conf import settings

# 啟用查詢日誌
settings.DEBUG = True

# ❌ N+1 查詢
reset_queries()
start = time.time()

posts = Post.objects.all()
for post in posts:
    _ = post.author.name

end = time.time()

print(f"查詢次數: {len(connection.queries)}")  # 101 次
print(f"執行時間: {end - start:.2f} 秒")       # 0.52 秒

結果示例:

查詢次數: 101
執行時間: 0.52 秒

數據量增長的影響

文章數量查詢次數執行時間
10110.06s
1001010.52s
100010015.2s ⚠️
100001000152s 💥

問題: 查詢次數與數據量呈線性增長,性能急劇下降!


🎯 如何檢測 N+1 問題?

方法 1:Django Debug Toolbar

# settings.py
INSTALLED_APPS = [
    ...
    'debug_toolbar',
]

MIDDLEWARE = [
    ...
    'debug_toolbar.middleware.DebugToolbarMiddleware',
]

INTERNAL_IPS = [
    '127.0.0.1',
]

使用:

  • 瀏覽器打開頁面
  • 點擊右側的 Debug Toolbar
  • 查看 “SQL” 面板
  • 看到大量 “Similar queries” 就是 N+1 問題

方法 2:django-silk

pip install django-silk
# settings.py
INSTALLED_APPS = [
    ...
    'silk',
]

MIDDLEWARE = [
    ...
    'silk.middleware.SilkyMiddleware',
]

# urls.py
urlpatterns = [
    ...
    path('silk/', include('silk.urls', namespace='silk')),
]

使用:

  • 訪問 /silk/
  • 查看每個請求的 SQL 查詢
  • 自動標記重複查詢

方法 3:手動打印查詢

from django.db import connection

def show_queries():
    """打印所有 SQL 查詢"""
    for i, query in enumerate(connection.queries, 1):
        print(f"{i}. {query['sql'][:100]}...")

# 使用
posts = Post.objects.all()
for post in posts:
    _ = post.author.name

show_queries()

方法 4:assertNumQueries 測試

# tests.py
from django.test import TestCase
from django.test.utils import override_settings

class PostViewTest(TestCase):
    def setUp(self):
        # 創建測試數據
        author = Author.objects.create(name='Alice')
        for i in range(10):
            Post.objects.create(
                title=f'Post {i}',
                content='Content',
                author=author
            )

    @override_settings(DEBUG=True)
    def test_post_list_queries(self):
        """測試查詢次數"""
        with self.assertNumQueries(2):  # 期望只有 2 次查詢
            posts = Post.objects.select_related('author').all()
            for post in posts:
                _ = post.author.name

✅ 解決方案 1:select_related(外鍵和一對一)

適用場景: ForeignKey、OneToOneField

# ✅ 使用 select_related
posts = Post.objects.select_related('author').all()

for post in posts:
    print(f"{post.title} by {post.author.name}")  # 不再查詢!

生成的 SQL

-- 只有 1 次查詢!使用 JOIN
SELECT
    post.id,
    post.title,
    post.content,
    post.author_id,
    author.id,
    author.name,
    author.email
FROM post
INNER JOIN author ON (post.author_id = author.id);

結果: 100 條數據,只需 1 次查詢


多層關聯

# models.py
class Category(models.Model):
    name = models.CharField(max_length=50)

class Author(models.Model):
    name = models.CharField(max_length=100)
    category = models.ForeignKey(Category, on_delete=models.CASCADE)

class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

# ✅ 多層 select_related
posts = Post.objects.select_related('author__category').all()

for post in posts:
    print(f"{post.title} by {post.author.name} in {post.author.category.name}")

SQL:

SELECT *
FROM post
INNER JOIN author ON (post.author_id = author.id)
INNER JOIN category ON (author.category_id = category.id);

✅ 解決方案 2:prefetch_related(多對多和反向外鍵)

適用場景: ManyToManyField、反向 ForeignKey

# models.py
class Post(models.Model):
    title = models.CharField(max_length=200)
    author = models.ForeignKey(Author, on_delete=models.CASCADE)

class Comment(models.Model):
    post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
    content = models.TextField()
    created_at = models.DateTimeField(auto_now_add=True)

❌ 有問題的代碼

posts = Post.objects.all()

for post in posts:
    print(f"{post.title}:")
    for comment in post.comments.all():  # N+1 問題!
        print(f"  - {comment.content}")
posts = Post.objects.prefetch_related('comments').all()

for post in posts:
    print(f"{post.title}:")
    for comment in post.comments.all():  # 不再查詢!
        print(f"  - {comment.content}")

SQL:

-- 第 1 次查詢:獲取所有文章
SELECT * FROM post;

-- 第 2 次查詢:批量獲取所有評論
SELECT * FROM comment WHERE post_id IN (1, 2, 3, ..., 100);

結果: 只需 2 次查詢


特性select_relatedprefetch_related
適用關係ForeignKey, OneToOneFieldManyToManyField, 反向 ForeignKey
SQL 策略JOIN(單次查詢)分別查詢 + Python 組合
查詢次數1 次2 次(或更多)
內存使用較低較高(需要在 Python 中組合)
適合場景一對一、多對一一對多、多對多

💡 進階技巧

1. 組合使用

# 同時使用兩者
posts = Post.objects.select_related('author').prefetch_related('comments').all()

for post in posts:
    print(f"{post.title} by {post.author.name}")
    for comment in post.comments.all():
        print(f"  - {comment.content}")

2. Prefetch 對象自定義查詢

from django.db.models import Prefetch

# 只預取最近 5 條評論
recent_comments = Comment.objects.order_by('-created_at')[:5]

posts = Post.objects.prefetch_related(
    Prefetch('comments', queryset=recent_comments)
).all()

for post in posts:
    print(f"{post.title}:")
    for comment in post.comments.all():  # 只有最近 5 條
        print(f"  - {comment.content}")

3. 多對多關聯

# models.py
class Post(models.Model):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField('Tag', related_name='posts')

class Tag(models.Model):
    name = models.CharField(max_length=50)

# ✅ 使用 prefetch_related
posts = Post.objects.prefetch_related('tags').all()

for post in posts:
    tags = ', '.join([tag.name for tag in post.tags.all()])
    print(f"{post.title} - Tags: {tags}")

🎯 實戰案例

案例 1:電商商品列表

# models.py
class Product(models.Model):
    name = models.CharField(max_length=200)
    category = models.ForeignKey('Category', on_delete=models.CASCADE)

class ProductImage(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='images')
    image = models.ImageField(upload_to='products/')

class Review(models.Model):
    product = models.ForeignKey(Product, on_delete=models.CASCADE, related_name='reviews')
    rating = models.IntegerField()
    comment = models.TextField()

# ✅ 優化的查詢
def get_product_list():
    products = Product.objects.select_related('category').prefetch_related(
        'images',
        Prefetch('reviews', queryset=Review.objects.filter(rating__gte=4))
    ).all()

    return products

# 使用
products = get_product_list()
for product in products:
    print(f"{product.name} ({product.category.name})")
    print(f"Images: {product.images.count()}")
    print(f"Good reviews: {product.reviews.count()}")

查詢次數: 3 次(products, images, reviews)


案例 2:部落格文章列表

# views.py
def blog_index(request):
    posts = Post.objects.select_related(
        'author',
        'author__profile'
    ).prefetch_related(
        'comments',
        'tags',
        Prefetch(
            'comments',
            queryset=Comment.objects.select_related('user').filter(is_approved=True)
        )
    ).order_by('-created_at')[:20]

    return render(request, 'blog/index.html', {'posts': posts})

案例 3:API 序列化

# serializers.py
from rest_framework import serializers

class AuthorSerializer(serializers.ModelSerializer):
    class Meta:
        model = Author
        fields = ['id', 'name', 'email']

class PostSerializer(serializers.ModelSerializer):
    author = AuthorSerializer(read_only=True)
    comments_count = serializers.SerializerMethodField()

    class Meta:
        model = Post
        fields = ['id', 'title', 'author', 'comments_count']

    def get_comments_count(self, obj):
        return obj.comments.count()

# views.py
class PostViewSet(viewsets.ModelViewSet):
    queryset = Post.objects.select_related('author').prefetch_related('comments').all()
    serializer_class = PostSerializer

📊 性能對比總結

優化前後對比

場景: 查詢 100 篇文章,每篇有 5 條評論

方法查詢次數執行時間內存使用
無優化2011.05s10MB
select_related(‘author’)1010.53s10MB
+ prefetch_related(‘comments’)20.08s12MB

結論: 優化後查詢時間減少 92%


💡 最佳實踐

1. 永遠先優化

# ❌ 不好
posts = Post.objects.all()

# ✅ 好
posts = Post.objects.select_related('author').prefetch_related('comments').all()

2. 在視圖層優化,不在模板層

# ❌ 不好:在模板中才訪問關聯
def view(request):
    posts = Post.objects.all()
    return render(request, 'template.html', {'posts': posts})

# template.html
{% for post in posts %}
    {{ post.author.name }}  <!-- N+1 問題 -->
{% endfor %}

# ✅ 好:在視圖中預先優化
def view(request):
    posts = Post.objects.select_related('author').all()
    return render(request, 'template.html', {'posts': posts})

3. 使用 only() 和 defer() 減少字段

# 只獲取需要的字段
posts = Post.objects.only('id', 'title', 'author__name').select_related('author').all()

# 排除大字段
posts = Post.objects.defer('content').select_related('author').all()

4. 監控查詢

# 開發環境啟用查詢日誌
LOGGING = {
    'version': 1,
    'handlers': {
        'console': {
            'class': 'logging.StreamHandler',
        },
    },
    'loggers': {
        'django.db.backends': {
            'handlers': ['console'],
            'level': 'DEBUG',
        },
    },
}

💡 面試要點

Q1: 什麼是 N+1 查詢問題?

答:

  • 查詢主對象 1 次,遍歷時為每個對象的關聯查詢 N 次
  • 總共 N+1 次查詢,性能很差
  • 使用 select_relatedprefetch_related 解決

答:

  • select_related:用於 ForeignKey/OneToOne,使用 JOIN
  • prefetch_related:用於 ManyToMany/反向 FK,分別查詢後組合
  • select_related 查詢次數更少,但不適合一對多

Q3: 如何檢測 N+1 問題?

答:

  1. Django Debug Toolbar
  2. django-silk
  3. connection.queries 查看 SQL
  4. 單元測試 assertNumQueries

答:

  • 不一定,要根據實際需求
  • 如果不訪問關聯對象,不需要
  • 如果只訪問少數幾個,按需優化
  • JOIN 太多也會降低性能

🔗 下一篇

在下一篇文章中,我們將深入學習 select_related vs prefetch_related,詳細對比兩者的使用場景和最佳實踐。

閱讀時間:10 分鐘

0%