Django 面試準備 10-4:查詢性能分析工具

掌握各種工具診斷和優化 Django 查詢性能

10-4. 查詢性能分析工具(Query Performance Tools)

📌 為什麼需要性能分析工具?

問題: 如何發現查詢性能問題?

  1. 🔍 發現 N+1 查詢
  2. 📊 識別慢查詢
  3. 優化瓶頸
  4. 📈 監控性能

🔧 工具 1:Django Debug Toolbar

安裝配置

pip install django-debug-toolbar
# settings.py
INSTALLED_APPS = [
    ...
    'debug_toolbar',
]

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

INTERNAL_IPS = [
    '127.0.0.1',
]

# 可選配置
DEBUG_TOOLBAR_CONFIG = {
    'SHOW_TOOLBAR_CALLBACK': lambda request: DEBUG,
    'SQL_WARNING_THRESHOLD': 100,  # 查詢時間超過 100ms 警告
}

# urls.py
from django.urls import include, path

urlpatterns = [
    ...
    path('__debug__/', include('debug_toolbar.urls')),
]

使用方法

1. 訪問網頁,點擊右側工具欄

2. 查看 SQL 面板

SQL 查詢統計:
- Total queries: 52
- Total time: 234.5ms
- Duplicate queries: 47  ⚠️ N+1 問題!

相似查詢:
SELECT * FROM author WHERE id = 1;  (47 次) ⚠️

3. 查看每條查詢的詳情

  • SQL 語句
  • 執行時間
  • 調用堆棧(在哪行代碼觸發)

實用功能

檢測 N+1 查詢

# views.py
def post_list(request):
    posts = Post.objects.all()  # 1 次查詢

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

# template.html
{% for post in posts %}
    {{ post.author.name }}  <!-- N 次查詢 -->
{% endfor %}

Debug Toolbar 顯示:

Similar queries: 100
SELECT * FROM author WHERE id = ?  (100 次)

⚠️ 建議:使用 select_related('author')

查看查詢計劃

點擊「Explain」按鈕查看執行計劃:

QUERY PLAN
----------
Seq Scan on posts  (cost=0.00..1500.00 rows=100000 width=100)
  Filter: (status = 'published')

🔧 工具 2:django-silk

安裝配置

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

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

# 可選配置
SILKY_PYTHON_PROFILER = True  # 啟用 Python 性能分析
SILKY_PYTHON_PROFILER_BINARY = True
SILKY_MAX_REQUEST_BODY_SIZE = 1024  # 最大請求體大小(KB)
SILKY_MAX_RESPONSE_BODY_SIZE = 1024

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

# 執行遷移
python manage.py migrate

使用方法

1. 訪問 /silk/ 查看所有請求

最近請求:
/api/posts/    200    234ms    52 queries    5 分鐘前
/api/users/    200    89ms     12 queries    10 分鐘前
/admin/        200    1.2s     156 queries   15 分鐘前 ⚠️

2. 點擊請求查看詳情

  • 請求/響應
  • SQL 查詢列表
  • Python 性能分析
  • 查詢時間分布圖

實用功能

1. 自動標記重複查詢

SQL 查詢 (52 total):

🔴 SELECT * FROM author WHERE id = ?  (47 次)  ⚠️ N+1
   ├─ Time: 235ms (total)
   ├─ Avg: 5ms per query
   └─ Traceback: views.py:42

✅ SELECT * FROM post WHERE id = ?  (1 次)
   └─ Time: 3ms

2. SQL 查詢聚合

按表統計:
author: 47 queries, 235ms
post: 5 queries, 45ms
comment: 12 queries, 89ms

3. Python 性能分析

Function                   Calls    Time
------------------------------------
views.post_list            1        234ms
  ├─ Post.objects.all()    1        3ms
  ├─ render()              1        231ms
      └─ template render   1        228ms
          └─ author.name   100      220ms  ⚠️

4. 導出分析報告

# 導出為 JSON
curl http://localhost:8000/silk/request/123/ > report.json

# 使用 silk_clearsilk 命令清理舊數據
python manage.py silk_clear_request_log

🔧 工具 3:connection.queries

手動查看查詢

from django.db import connection
from django.test.utils import override_settings

@override_settings(DEBUG=True)
def view(request):
    # 執行查詢
    posts = Post.objects.all()
    for post in posts:
        _ = post.author.name

    # 打印所有查詢
    print(f"Total queries: {len(connection.queries)}")

    for i, query in enumerate(connection.queries, 1):
        print(f"{i}. {query['sql'][:100]}...")
        print(f"   Time: {query['time']}s\n")

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

輸出:

Total queries: 101

1. SELECT * FROM post
   Time: 0.003s

2. SELECT * FROM author WHERE id = 1
   Time: 0.002s

3. SELECT * FROM author WHERE id = 2
   Time: 0.002s

...

創建裝飾器

# utils.py
from functools import wraps
from django.db import connection, reset_queries
from django.conf import settings

def query_debugger(func):
    """裝飾器:打印查詢統計"""
    @wraps(func)
    def wrapper(*args, **kwargs):
        if not settings.DEBUG:
            return func(*args, **kwargs)

        reset_queries()
        result = func(*args, **kwargs)

        queries = connection.queries
        print(f"\n{'='*50}")
        print(f"Function: {func.__name__}")
        print(f"Queries: {len(queries)}")

        total_time = sum(float(q['time']) for q in queries)
        print(f"Total time: {total_time:.3f}s")

        # 找出重複查詢
        from collections import Counter
        sql_counter = Counter(q['sql'] for q in queries)
        duplicates = {sql: count for sql, count in sql_counter.items() if count > 1}

        if duplicates:
            print(f"\n⚠️ Duplicate queries:")
            for sql, count in duplicates.items():
                print(f"  {count}x: {sql[:80]}...")

        print(f"{'='*50}\n")

        return result
    return wrapper

# 使用
@query_debugger
def get_posts():
    posts = Post.objects.all()
    for post in posts:
        _ = post.author.name
    return posts

🔧 工具 4:EXPLAIN(數據庫查詢計劃)

PostgreSQL EXPLAIN

from django.db import connection

def explain_query():
    cursor = connection.cursor()

    # 獲取查詢計劃
    cursor.execute("EXPLAIN SELECT * FROM post WHERE status = 'published'")
    plan = cursor.fetchall()

    for row in plan:
        print(row[0])

輸出:

Seq Scan on post  (cost=0.00..1500.00 rows=10000 width=100)
  Filter: (status = 'published'::text)

EXPLAIN ANALYZE(實際執行)

cursor.execute("""
    EXPLAIN ANALYZE
    SELECT * FROM post
    WHERE status = 'published'
    ORDER BY created_at DESC
    LIMIT 10
""")

plan = cursor.fetchall()
for row in plan:
    print(row[0])

輸出:

Limit  (cost=0.42..0.78 rows=10 width=100) (actual time=0.123..0.456 rows=10 loops=1)
  ->  Index Scan using post_created_at_idx on post  (cost=0.42..3587.21 rows=100000 width=100) (actual time=0.122..0.453 rows=10 loops=1)
        Filter: (status = 'published'::text)
        Rows Removed by Filter: 5
Planning Time: 0.234 ms
Execution Time: 0.567 ms

解讀 EXPLAIN 輸出

關鍵指標:

  1. Seq Scan(全表掃描) ⚠️

    Seq Scan on post  (cost=0.00..1500.00 rows=100000 width=100)

    → 需要添加索引

  2. Index Scan(索引掃描)

    Index Scan using post_created_at_idx on post  (cost=0.42..0.78 rows=10)

    → 使用了索引,性能好

  3. Index Only Scan(僅索引掃描) 🌟

    Index Only Scan using post_status_idx on post

    → 最優,所有數據都在索引中

  4. Bitmap Scan(位圖掃描)

    Bitmap Heap Scan on post
      Recheck Cond: (status = 'published'::text)
      ->  Bitmap Index Scan on post_status_idx

    → 適合返回大量數據


Django ORM 使用 EXPLAIN

# Django 2.1+
queryset = Post.objects.filter(status='published').explain()
print(queryset)

# 添加選項
queryset = Post.objects.filter(status='published').explain(
    verbose=True,
    analyze=True
)
print(queryset)

🔧 工具 5:慢查詢日誌

PostgreSQL 慢查詢配置

# postgresql.conf
log_min_duration_statement = 100  # 記錄執行時間超過 100ms 的查詢
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d '
log_statement = 'all'  # 記錄所有語句(可選)

重啟 PostgreSQL:

sudo service postgresql restart

Django 慢查詢日誌

# settings.py
LOGGING = {
    'version': 1,
    'disable_existing_loggers': False,
    'formatters': {
        'verbose': {
            'format': '{levelname} {asctime} {module} {message}',
            'style': '{',
        },
    },
    'filters': {
        'slow_query': {
            '()': 'myapp.filters.SlowQueryFilter',
        }
    },
    'handlers': {
        'file': {
            'level': 'DEBUG',
            'class': 'logging.FileHandler',
            'filename': 'slow_queries.log',
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'django.db.backends': {
            'level': 'DEBUG',
            'handlers': ['file'],
            'filters': ['slow_query'],
        },
    },
}

# myapp/filters.py
import logging

class SlowQueryFilter(logging.Filter):
    def filter(self, record):
        # 只記錄執行時間超過 100ms 的查詢
        if hasattr(record, 'duration'):
            duration = float(record.duration)
            return duration > 0.1
        return False

🔧 工具 6:Django 單元測試

assertNumQueries

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):
        """測試查詢次數"""

        # ❌ 沒有優化:11 次查詢
        with self.assertNumQueries(11):
            posts = Post.objects.all()
            for post in posts:
                _ = post.author.name

        # ✅ 使用 select_related:1 次查詢
        with self.assertNumQueries(1):
            posts = Post.objects.select_related('author').all()
            for post in posts:
                _ = post.author.name

自定義斷言

from django.db import connection
from django.test import TestCase

class PerformanceTestCase(TestCase):
    def assertQueryCountLessThan(self, max_queries):
        """斷言查詢次數小於指定值"""
        actual = len(connection.queries)
        if actual > max_queries:
            self.fail(
                f"Too many queries! Expected <= {max_queries}, got {actual}\n"
                f"Queries: {connection.queries}"
            )

    def test_post_list_performance(self):
        # 執行操作
        posts = Post.objects.select_related('author').all()
        list(posts)

        # 檢查查詢次數
        self.assertQueryCountLessThan(2)

📊 綜合案例:性能優化流程

步驟 1:發現問題(Django Debug Toolbar)

訪問 /api/posts/
發現:52 queries, 234ms
⚠️ Similar queries: 47x SELECT * FROM author

步驟 2:定位代碼(django-silk)

views.py:42
for post in posts:
    print(post.author.name)  ← 觸發 N+1 查詢

步驟 3:優化查詢

# 修改前
posts = Post.objects.all()

# 修改後
posts = Post.objects.select_related('author').all()

步驟 4:驗證效果(connection.queries)

@query_debugger
def get_posts():
    return Post.objects.select_related('author').all()

# 輸出:
# Queries: 1  ✅
# Total time: 0.003s  ✅

步驟 5:分析執行計劃(EXPLAIN)

queryset = Post.objects.select_related('author').filter(
    status='published'
).explain(analyze=True)

print(queryset)

輸出:

Nested Loop  (cost=0.71..28.46 rows=10 width=200) (actual time=0.023..0.145 rows=10 loops=1)
  ->  Index Scan using post_status_idx on post  (cost=0.42..12.89 rows=10) ✅
  ->  Index Scan using author_pkey on author  (cost=0.29..1.56 rows=1) ✅
Planning Time: 0.234 ms
Execution Time: 0.189 ms  ✅

步驟 6:添加單元測試

def test_post_list_queries(self):
    """確保查詢次數不增加"""
    with self.assertNumQueries(1):
        posts = Post.objects.select_related('author').all()
        list(posts)

💡 工具選擇指南

工具適用場景優點缺點
Debug Toolbar開發環境,快速調試即時可視化,易用只能看當前請求
django-silk性能分析,歷史記錄記錄所有請求,Python profiling有性能開銷
connection.queries代碼調試,單元測試簡單直接,無依賴需要手動打印
EXPLAINSQL 優化,索引分析最詳細的執行計劃需要理解 SQL
慢查詢日誌生產環境監控持續監控需要配置數據庫

💡 最佳實踐

1. 開發環境必備工具

# settings/dev.py
INSTALLED_APPS += [
    'debug_toolbar',
    'silk',
]

MIDDLEWARE = [
    'debug_toolbar.middleware.DebugToolbarMiddleware',
    'silk.middleware.SilkyMiddleware',
] + MIDDLEWARE

2. 單元測試覆蓋

class ViewTestCase(TestCase):
    def test_all_views_query_count(self):
        """測試所有視圖的查詢次數"""
        test_cases = [
            ('/api/posts/', 2),     # 期望 2 次查詢
            ('/api/users/', 1),     # 期望 1 次查詢
            ('/api/comments/', 3),  # 期望 3 次查詢
        ]

        for url, max_queries in test_cases:
            with self.subTest(url=url):
                with self.assertNumQueries(max_queries):
                    self.client.get(url)

3. 持續監控

# middleware.py
from django.db import connection

class QueryCountMiddleware:
    """查詢次數監控中間件"""

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        # 重置查詢計數
        connection.queries_log.clear()

        response = self.get_response(request)

        # 記錄查詢統計
        query_count = len(connection.queries)
        if query_count > 50:  # 超過 50 次查詢發出警告
            logger.warning(
                f"Too many queries: {query_count} for {request.path}"
            )

        # 添加響應頭
        response['X-DB-Query-Count'] = query_count

        return response

💡 面試要點

Q1: 如何發現 Django 應用的性能問題?

答:

  1. Django Debug Toolbar:開發環境即時查看
  2. django-silk:記錄和分析所有請求
  3. 慢查詢日誌:生產環境監控
  4. 單元測試:assertNumQueries 防止退化

Q2: EXPLAIN 的主要輸出類型有哪些?

答:

  • Seq Scan:全表掃描(慢)
  • Index Scan:使用索引(快)
  • Index Only Scan:只訪問索引(最快)
  • Bitmap Scan:位圖掃描(適合大量數據)

Q3: 生產環境如何監控查詢性能?

答:

  1. 數據庫慢查詢日誌
  2. APM 工具(New Relic, Datadog)
  3. 自定義中間件記錄查詢統計
  4. 定期審查 django-silk 數據

Q4: Django Debug Toolbar 會影響性能嗎?

答:

  • 會,因為要收集和顯示大量信息
  • 只應在開發環境使用
  • 生產環境必須禁用(DEBUG = False

🎓 總結

性能優化三步走:

  1. 測量

    • 使用工具發現問題
    • 不要憑感覺優化
  2. 分析

    • N+1 查詢?→ select_related/prefetch_related
    • 慢查詢?→ EXPLAIN 分析執行計劃
    • 缺索引?→ 添加合適的索引
  3. 驗證

    • 再次測量確認優化效果
    • 添加單元測試防止退化

恭喜!你已完成第 10 章「SQL 查詢優化」的所有內容!

系列文章:

下一章: 11. 線程安全

0%