Избегайте подсчета в пагинации 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/, чтобы просмотреть статистику запросов.
Ой, запрос занял 867
миллисекунду, 855
из которых были потрачены на SQL-запросы.
Выбор "SQL" на панели навигации показывает, что SQL-запрос, который занял больше всего времени, был SELECT COUNT(*)
, что заняло 854
миллисекунды.
Бесчисленный разбиватель на страницы
Давайте реализуем пагинатор, который не требует запроса SELECT COUNT(*)
.
Для этого мы определим два класса:
- Класс
CountlessPage
(основан на странице класса Django) - Класс
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
миллисекунд.
Для получения дополнительной информации ознакомьтесь со следующими ресурсами:
- Как избежать запроса COUNT, который выполняет пагинатор Django?
- Добавьте новую мета-опцию: не выполняйте подсчет(*) в admin
- Приблизительный подсчет в Django и Postgres
- Исходный код пагинатора Django по умолчанию