Django База [2023]: Оптимизация SQL запросов, установка debug-toolbar, решение N+1 #15
В этой статье мы научимся оптимизировать SQL запросы Django ORM с помощью select_related()
и prefetch_related()
используя менеджер и Django Debug Toolbar. Решение проблем с N+1 в Django.
Если вы хотите выразить благодарность автору сайта, статей и курса по Django, вы можете сделать это по ссылке ниже:
Напоминаю, что кастомный менеджер Django мы создавали в прошлом уроке
Установка Django Debug Toolbar
Давайте установим в наш проект Django Debug Toolbar, делается это следующей командой: pip install django-debug-toolbar
Результат установки:
(venv) PS C:\Users\Razilator\Desktop\Base\backend> pip install django-debug-toolbar
Collecting django-debug-toolbar
Using cached django_debug_toolbar-3.8.1-py3-none-any.whl (221 kB)
Requirement already satisfied: django>=3.2.4 in c:\users\razilator\desktop\base\venv\lib\site-packages (from django-debug-toolbar) (4.1.5)
Requirement already satisfied: sqlparse>=0.2 in c:\users\razilator\desktop\base\venv\lib\site-packages (from django-debug-toolbar) (0.4.3)
Requirement already satisfied: asgiref<4,>=3.5.2 in c:\users\razilator\desktop\base\venv\lib\site-packages (from django>=3.2.4->django-debug-toolbar) (3.6.0)
Requirement already satisfied: tzdata in c:\users\razilator\desktop\base\venv\lib\site-packages (from django>=3.2.4->django-debug-toolbar) (2022.7)
Installing collected packages: django-debug-toolbar
Successfully installed django-debug-toolbar-3.8.1
[notice] A new release of pip available: 22.3 -> 22.3.1
[notice] To update, run: python.exe -m pip install --upgrade pip
После установки нам нужно отредактировать конфиг проекта, добавив приложение и middleware:
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'modules.blog.apps.BlogConfig',
'mptt',
'debug_toolbar',
]
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',
'debug_toolbar.middleware.DebugToolbarMiddleware',
]
INTERNAL_IPS = [
'127.0.0.1',
]
Теперь необходимо изменить корневой urls.py, добавив работу дебагера в режиме debug.
"""backend URL Configuration
The `urlpatterns` list routes URLs to views. For more information please see:
https://docs.djangoproject.com/en/4.1/topics/http/urls/
Examples:
Function views
1. Add an import: from my_app import views
2. Add a URL to urlpatterns: path('', views.home, name='home')
Class-based views
1. Add an import: from other_app.views import Home
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings
urlpatterns = [
path('admin/', admin.site.urls),
path('', include('modules.blog.urls')),
]
if settings.DEBUG:
urlpatterns = [path('__debug__/', include('debug_toolbar.urls'))] + urlpatterns
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
Отлично, теперь запустим наш проект и посмотрим результаты запросов на главной странице.
Если сделали все правильно из настроек выше, то получите следующее, как на скриншоте ниже:
И если мы нажмем на кнопку SQL, мы увидим следующие данные:
На данном скриншоте мы можем заметить одинаковые запросы к пользователю, а также к категориям. Это проблема, и называется она N+1. Если бы на странице было бы ещё больше статей, то и запросов SQL было бы пропорционально больше.
И решением данной проблемы является использование методов менеджера select_related()
и prefetch_related()
.
Что такое select_related(*fields)?
Возвращает QuerySet, который будет «следовать» отношениям внешнего ключа, выбирая дополнительные данные связанного объекта при выполнении своего запроса. Это повышение производительности, которое приводит к одному более сложному запросу, но означает, что дальнейшее использование отношений внешнего ключа не потребует запросов к базе данных.
Что такое prefetch_related(*lookups)?
Возвращает QuerySet, который автоматически извлекает в одном пакете связанные объекты для каждого из указанных поисков.
Это имеет цель, аналогичную select_related
, в том смысле, что оба предназначены для остановки потока запросов к базе данных, вызванного доступом к связанным объектам, но стратегия совершенно иная.
select_related
работает путем создания соединения (join) SQL и включения полей связанного объекта в оператор SELECT. По этой причине select_related получает связанные объекты в одном запросе к базе данных. Тем не менее, чтобы избежать гораздо большего результирующего набора, который мог бы возникнуть в результате объединения через отношение „many“, select_related
ограничен однозначными отношениями - внешним ключом и один-к-одному.
prefetch_related, с другой стороны, выполняет отдельный поиск для каждого отношения и выполняет «соединение» в Python. Это позволяет ему предварительно выбирать объекты «многие ко многим» и «многие к одному», что нельзя сделать с помощью select_related
, в дополнение к внешнему ключу и отношениям «один к одному», которые поддерживаются select_related
. Он также поддерживает предварительную выборку GenericRelation и GenericForeignKey, однако он должен быть ограничен однородным набором результатов. Например, предварительная выборка объектов, на которые ссылается GenericForeignKey, поддерживается только в том случае, если запрос ограничен одним ContentType.
Использование select_related(*fields) и prefetch_related(*lookups) в проекте
Давайте от теории перейдем к практике и оптимизируем наши запросы в менеджере модели Article (Статьи), который мы создали в прошлом уроке.
class Article(models.Model):
"""
Модель постов для сайта
"""
class ArticleManager(models.Manager):
"""
Кастомный менеджер для модели статей
"""
def all(self):
"""
Список статей (SQL запрос с фильтрацией для страницы списка статей)
"""
return self.get_queryset().select_related('author', 'category').filter(status='published')
STATUS_OPTIONS = (
('published', 'Опубликовано'),
('draft', 'Черновик')
)
title = models.CharField(verbose_name='Заголовок', max_length=255)
slug = models.CharField(verbose_name='Альт.название', max_length=255, blank=True, unique=True)
# Другие поля модели...
# Методы модели...
Пояснение:
К методу all()
кастомного менеджера модели я добавил до фильтрации статьи метод: select_related()
для ключа Foreign Key - Автора и Категорий, так как со статьи мы ссылаемся на автора и категорию.
Но я не применил prefect_related()
, так как у нас нет модели, которая будет ссылаться на модель Article, а также мы не используем поле ManyToMany. Мы могли бы добавить комментарии в данный метод, которые реализуем чуть позже.
Давайте посмотрим результат оптмизации:
Обновив главную страницу со статьями, я получаю 5 SQL запросов:
И все таки, раз уж применить пока-что в нашем проекта prefetch_related()
нельзя, покажу пример с книгой и множеством авторов ManyToMany:
Допустим, у нас есть модель для Книг (Book):
class Book(models.Model):
title = models.CharField(verbose_name='Заголовок', max_length=255)
authors = ManyToManyField('User')
category = ForeignKey('Category', on_delete=models.PROTECT, related_name='articles', verbose_name='Категория')
В этом случае, у книги имеется ни один автор, а множество и к каждой книге будет по 2-3 запроса, а если книг 10 штук на странице, смело умножайте на 3 запроса. Чтобы с этим бороться и понадобится prefetch_related()
.
class Book(models.Model):
class BookManager(models.Manager):
def all(self):
return self.get_queryset().select_related('category').prefetch_related('authors').filter(status='published')
title = models.CharField(verbose_name='Заголовок', max_length=255)
authors = ManyToManyField('User')
category = ForeignKey('Category', on_delete=models.PROTECT, related_name='articles', verbose_name='Категория')
objects = BookManager()
Таким образом, мы уменьшим количество запросов на страницу!