Автоматизация тестирования производительности в Django

Неэффективный запрос к базе данных - одна из наиболее распространенных проблем с производительностью в Django. В частности, запросы N+1 могут негативно сказаться на производительности вашего приложения на ранних стадиях. Они возникают, когда вы выбираете записи из связанной таблицы, используя отдельный запрос для каждой записи, а не собираете все записи в одном запросе. К сожалению, такие недостатки довольно легко устранить с помощью ORM Django. Тем не менее, их можно быстро выявить и предотвратить с помощью автоматизированного тестирования.

В этой статье рассматривается, как:

  1. Проверьте количество запросов, выполняемых запросом, а также продолжительность запросов
  2. Предотвратите N+1 запрос с помощью пакета nplusone

N+1 запросов

Пример приложения, с которым мы будем работать на протяжении всей этой статьи, можно найти на GitHub.

Допустим, например, вы работаете с приложением Django, которое имеет следующие модели:

# courses/models.py

from django.db import models


class Author(models.Model):
    name = models.CharField(max_length=100)

    def __str__(self):
        return self.name


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

    def __str__(self):
        return self.title

Теперь, если перед вами стоит задача создать новое представление для возврата JSON-ответа по всем курсам с названием и именем автора, вы могли бы написать следующий код:

# courses/views.py

from django.http import JsonResponse

from courses.models import Course


def all_courses(request):
    queryset = Course.objects.all()

    courses = []

    for course in queryset:
        courses.append(
            {"title": course.title, "author": course.author.name}
        )

    return JsonResponse(courses, safe=False)

Этот код будет работать, но он очень неэффективен, так как будет выполняться слишком много запросов к базе данных:

  • 1 запрос для получения всех курсов
  • N запросов для получения ветви на каждой итерации

Прежде чем обратиться к этому вопросу, давайте посмотрим, сколько всего запросов выполняется, и измерим время выполнения.

Промежуточное программное обеспечение для метрик

Вы заметите, что проект включает пользовательское промежуточное программное обеспечение, которое вычисляет и регистрирует время выполнения каждого запроса:

# core/middleware.py

import logging
import time

from django.db import connection, reset_queries


def metric_middleware(get_response):
    def middleware(request):
        reset_queries()

        # Get beginning stats
        start_queries = len(connection.queries)
        start_time = time.perf_counter()

        # Process the request
        response = get_response(request)

        # Get ending stats
        end_time = time.perf_counter()
        end_queries = len(connection.queries)

        # Calculate stats
        total_time = end_time - start_time
        total_queries = end_queries - start_queries

        # Log the results
        logger = logging.getLogger("debug")
        logger.debug(f"Request: {request.method} {request.path}")
        logger.debug(f"Number of Queries: {total_queries}")
        logger.debug(f"Total time: {(total_time):.2f}s")

        return response

    return middleware

Запустите начальную команду базы данных, чтобы добавить в базу данных 10 авторов и 100 курсов:

$ python manage.py seed_db

Когда сервер разработки Django будет запущен, перейдите по ссылке http://localhost:8000/courses/ в вашем браузере. Вы должны увидеть ответ в формате JSON. Вернувшись в свой терминал, обратите внимание на показатели:

Request: GET /courses/
Number of Queries: 101
Total time: 0.10s

Это слишком много запросов! Это очень неэффективно. Для каждого добавленного автора и курса потребуется дополнительный запрос к базе данных, поэтому производительность будет продолжать снижаться по мере роста базы данных. К счастью, исправить это довольно просто: вы можете добавить метод select_related для создания SQL-соединения, которое будет включать авторов в исходный запрос к базе данных.

queryset = Course.objects.select_related("author").all()

Прежде чем вносить какие-либо изменения в код, давайте сначала проведем несколько тестов.

Тесты производительности

Начните со следующего теста, в котором используется django_assert_num_queries функция pytest, чтобы убедиться, что доступ к базе данных осуществляется только один раз, когда в базе данных присутствует одна или несколько записей автора и курса:

import json

import pytest
from faker import Faker
from django.test import override_settings

from courses.models import Course, Author


@pytest.mark.django_db
def test_number_of_sql_queries_all_courses(client, django_assert_num_queries):
    fake = Faker()

    author_name = fake.name()
    author = Author(name=author_name)
    author.save()
    course_title = fake.sentence(nb_words=4)
    course = Course(title=course_title, author=author)
    course.save()

    with django_assert_num_queries(1):
        res = client.get("/courses/")
        data = json.loads(res.content)

        assert res.status_code == 200
        assert len(data) == 1

        author_name = fake.name()
        author = Author(name=author_name)
        author.save()
        course_title = fake.sentence(nb_words=4)
        course = Course(title=course_title, author=author)
        course.save()

        res = client.get("/courses/")
        data = json.loads(res.content)

        assert res.status_code == 200
        assert len(data) == 2

Не используете pytest? Используйте метод тестирования assertNumQueries вместо django_assert_num_queries.

Более того, мы также можем использовать nplusone, чтобы предотвратить появление в будущем N+1 запросов. После установки пакета и добавления его в файл настроек вы можете добавить его в свои тесты с помощью @override_settings декоратора:

...
@pytest.mark.django_db
@override_settings(NPLUSONE_RAISE=True)
def test_number_of_sql_queries_all_courses(client, django_assert_num_queries):
    ...

Или, если вы хотите автоматически включить nplusone для всего набора тестов, добавьте в свой корневой каталог тестов следующее conftest.py файл:

from django.conf import settings


def pytest_configure(config):
    settings.NPLUSONE_RAISE = True

Вернитесь к образцу приложения и запустите тесты. Вы должны увидеть следующую ошибку:

nplusone.core.exceptions.NPlusOneError: Potential n+1 query detected on `Course.author`

Теперь внесите рекомендуемые изменения - добавьте метод select_related - и снова запустите тесты. Теперь они должны пройти успешно.

Заключение

В этой статье рассматривается, как предотвратить автоматическое выполнение N+1 запросов в вашей кодовой базе с помощью пакета nplusone и протестировать количество выполненных запросов с помощью django_assert_num_queries pytest fixture.

Это должно помочь избежать снижения производительности по мере роста числа ваших приложений и привлечения большего числа пользователей. Если вы добавляете это в существующую кодовую базу, вам, вероятно, потребуется потратить некоторое время на исправление ошибочных запросов, чтобы они имели постоянное количество обращений к базе данных. Если после исправлений и оптимизации вы по-прежнему сталкиваетесь с проблемами производительности, возможно, вам потребуется добавить дополнительные уровни кэширования, денормализовать части базы данных и/или настроить индексы базы данных.

Вернуться на верх