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 秒數據量增長的影響
| 文章數量 | 查詢次數 | 執行時間 |
|---|---|---|
| 10 | 11 | 0.06s |
| 100 | 101 | 0.52s |
| 1000 | 1001 | 5.2s ⚠️ |
| 10000 | 10001 | 52s 💥 |
問題: 查詢次數與數據量呈線性增長,性能急劇下降!
🎯 如何檢測 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}")✅ 使用 prefetch_related
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_related vs prefetch_related
| 特性 | select_related | prefetch_related |
|---|---|---|
| 適用關係 | ForeignKey, OneToOneField | ManyToManyField, 反向 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 條評論
| 方法 | 查詢次數 | 執行時間 | 內存使用 |
|---|---|---|---|
| 無優化 | 201 | 1.05s | 10MB |
| select_related(‘author’) | 101 | 0.53s | 10MB |
| + prefetch_related(‘comments’) | 2 | 0.08s | 12MB |
結論: 優化後查詢時間減少 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_related或prefetch_related解決
Q2: select_related 和 prefetch_related 的區別?
答:
- select_related:用於 ForeignKey/OneToOne,使用 JOIN
- prefetch_related:用於 ManyToMany/反向 FK,分別查詢後組合
- select_related 查詢次數更少,但不適合一對多
Q3: 如何檢測 N+1 問題?
答:
- Django Debug Toolbar
- django-silk
connection.queries查看 SQL- 單元測試
assertNumQueries
Q4: 所有查詢都要用 select_related 嗎?
答:
- 不一定,要根據實際需求
- 如果不訪問關聯對象,不需要
- 如果只訪問少數幾個,按需優化
- JOIN 太多也會降低性能
🔗 下一篇
在下一篇文章中,我們將深入學習 select_related vs prefetch_related,詳細對比兩者的使用場景和最佳實踐。
閱讀時間:10 分鐘