Избегайте подсчета в пагинации Django

В этой статье рассматривается, как избежать запроса на подсчет объектов в разбивке на страницы Django.

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


Используемый по умолчанию Пагинатор использует объектный COUNT(*) запрос для вычисления количества страниц. По мере роста вашей базы данных этот запрос может значительно замедляться и в конечном итоге привести к увеличению времени загрузки страницы, а иногда и к превышению 90%.

В этой статье мы реализуем пагинатор, который пропускает запрос object COUNT(*). Реализованный пагинатор предполагает, что знание количества страниц не обязательно для вашего варианта использования. Другими словами, разбивщик страниц не будет знать, сколько в нем страниц.

Содержимое

Настройка проекта

Чтобы упростить изучение руководства, я подготовил простое приложение для Django. Все, что приложение делает, - это сохраняет сообщения журнала в базе данных и возвращает их в виде ответа API с разбивкой по страницам.

Если хотите, можете создать свой собственный проект на Django. Однако ваш проект может оказаться немного сложнее для тестирования.

Сначала клонируем base ветку репозитория GitHub:

$ git clone https://github.com/duplxey/django-count-avoid.git \
    --single-branch --branch base && cd django-count-avoid

Далее создайте новую виртуальную среду и активируйте ее:

$ python3 -m venv venv && source venv/bin/activate

Установите требования и перенесите базу данных:

(venv)$ pip install -r requirements.txt
(venv)$ python manage.py migrate

Заполнить базу данных:

$ python manage.py populate_db

При каждом запуске команды populate в базу данных добавляется 2,5 миллиона записей. Я предлагаю вам выполнить эту команду два или три раза.

Команда также создает суперпользователя со следующими учетными данными: admin:password.

Запустите сервер:

(venv)$ python manage.py runserver

Отлично, вы успешно настроили проект.

Начальный тест

Для тестирования API мы будем использовать пакет Django Silk. Django Silk - это простой инструмент для профилирования и мониторинга проектов Django. Он отслеживает время запроса к базе данных и загрузки просмотра и помогает выявить узкие места в вашем проекте.

Silk уже установлен, если вы используете предложенный проект управления журналами.

Сначала откройте свой любимый веб-браузер и перейдите к http://localhost:8000/api/.

Вы заметите относительно длительное время загрузки.

Затем перейдите к http://localhost:8000/silk/requests/, чтобы просмотреть статистику запросов.

Django Silk Initial Benchmark

Ой, запрос занял 867 миллисекунду, 855 из которых были потрачены на SQL-запросы.

Выбор "SQL" на панели навигации показывает, что SQL-запрос, который занял больше всего времени, был SELECT COUNT(*), что заняло 854 миллисекунды.

Django Silk Initial Benchmark SQL

Бесчисленный разбиватель на страницы

Давайте реализуем пагинатор, который не требует запроса SELECT COUNT(*).

Для этого мы определим два класса:

  1. Класс CountlessPage (основан на странице класса Django)
  2. Класс CountlessPaginator (основан на классе Paginator от Django)

Создайте paginator.py файл в приложении журналы и поместите в него следующий код:

# logs/paginators.py

import collections

from django.core.paginator import EmptyPage, PageNotAnInteger
from django.utils.translation import gettext_lazy as _


class CountlessPage(collections.abc.Sequence):
    def __init__(self, object_list, number, page_size):
        self.object_list = object_list
        self.number = number
        self.page_size = page_size

        if not isinstance(self.object_list, list):
            self.object_list = list(self.object_list)

        self._has_next = \
            len(self.object_list) > len(self.object_list[: self.page_size])
        self._has_previous = self.number > 1

    def __repr__(self):
        return "<Page %s>" % self.number

    def __len__(self):
        return len(self.object_list)

    def __getitem__(self, index):
        if not isinstance(index, (int, slice)):
            raise TypeError
        return self.object_list[index]

    def has_next(self):
        return self._has_next

    def has_previous(self):
        return self._has_previous

    def has_other_pages(self):
        return self.has_next() or self.has_previous()

    def next_page_number(self):
        if self.has_next():
            return self.number + 1
        else:
            raise EmptyPage(_("Next page does not exist"))

    def previous_page_number(self):
        if self.has_previous():
            return self.number - 1
        else:
            raise EmptyPage(_("Previous page does not exist"))


class CountlessPaginator:
    def __init__(self, object_list, per_page) -> None:
        self.object_list = object_list
        self.per_page = per_page

    def validate_number(self, number):
        try:
            if isinstance(number, float) and not number.is_integer():
                raise ValueError
            number = int(number)
        except (TypeError, ValueError):
            raise PageNotAnInteger(_("Page number is not an integer"))
        if number < 1:
            raise EmptyPage(_("Page number is less than 1"))
        return number

    def get_page(self, number):
        try:
            number = self.validate_number(number)
        except (PageNotAnInteger, EmptyPage):
            number = 1
        return self.page(number)

    def page(self, number):
        number = self.validate_number(number)
        bottom = (number - 1) * self.per_page
        top = bottom + self.per_page + 1
        return CountlessPage(self.object_list[bottom:top], number, self.per_page)

Оба класса в основном основаны на исходном коде Django. Единственные два отличия заключаются в том, что они пропускают запрос count и используют другой метод для проверки наличия следующей и предыдущей страниц.

Чтобы определить, существует ли следующая страница, мы пытаемся передать дополнительный объект из paginator на страницу. На странице мы возвращаем список объектов к его первоначальному размеру и проверяем, присутствует ли дополнительный объект. Если это так, то на следующей странице есть по крайней мере один объект; следовательно, он существует.

В отличие от этого, для предыдущей страницы мы просто проверяем, соответствует ли номер страницы number > 1.

Затем используйте CountlessPaginator в index_view в logs/views.py вот так:

# logs/views.py

def index_view(request):
    logs = Log.objects.all()
    paginator = CountlessPaginator(logs, 25)  # modified
    page_number = request.GET.get("page")
    page_obj = paginator.get_page(page_number)

    return JsonResponse(
        {
            "has_next": page_obj.has_next(),
            "has_previous": page_obj.has_previous(),
            "results": [log.to_json() for log in page_obj],
        }
    )

Не забудьте об импорте:

from logs.paginators import CountlessPaginator

Протестируйте приложение еще раз.

Запрос теперь выполняется всего за 12 миллисекунду. Это примерно в 70 раз быстрее, чем раньше.

Заключение

В этой статье мы рассмотрели реализацию пагинатора, который не полагается на подсчет объектов.

Заменив paginator Django по умолчанию на пользовательский, мы значительно улучшили время отклика API с разбивкой на страницы. В нашем случае время отклика увеличилось примерно с 800 миллисекунд до 12 миллисекунд.

Для получения дополнительной информации ознакомьтесь со следующими ресурсами:

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