Django База [2023]: Оптимизация SQL запросов, установка debug-toolbar, решение N+1 #15
Django

Django База [2023]: Оптимизация SQL запросов, установка debug-toolbar, решение N+1 #15

Теги не заданы
Razilator

В этой статье мы научимся оптимизировать SQL запросы Django ORM с помощью select_related() и prefetch_related() используя менеджер и Django Debug Toolbar. Решение проблем с N+1 в Django.

Напоминаю, что кастомный менеджер Django мы создавали в прошлом уроке

Установка Django Debug Toolbar

Давайте установим в наш проект Django Debug Toolbar, делается это следующей командой: pip install django-debug-toolbar

Результат установки:

Shell
(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:

backend/settings.py
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/urls.py
"""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)

Отлично, теперь запустим наш проект и посмотрим результаты запросов на главной странице.

Если сделали все правильно из настроек выше, то получите следующее, как на скриншоте ниже:

Debug Toolbar работает
Debug Toolbar работает

И если мы нажмем на кнопку SQL, мы увидим следующие данные:

N+1
N+1

На данном скриншоте мы можем заметить одинаковые запросы к пользователю, а также к категориям. Это проблема, и называется она 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 (Статьи), который мы создали в прошлом уроке.

blog/models.py
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):

models.py
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().

models.py
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()

Таким образом, мы уменьшим количество запросов на страницу!

;