Приблизительный подсчет в Django и Postgres
В этой статье рассматривается, как ускорить подсчет с помощью Django и PostgreSQL. В ней рассматриваются два разных подхода.
Предпочитаете вообще избегать подсчета? Ознакомьтесь с Избегайте подсчета в разбивке на страницы Django.
По мере роста вашего проекта на Django вы можете заметить, что загрузка сайта администратора занимает все больше времени. В основном это связано с пагинатором Django, который использует медленный запрос SELECT COUNT(*)
для вычисления количества строк в таблице. В какой-то момент на этот запрос будет приходиться более 95% времени загрузки страницы.
Но проблема не ограничивается сайтом администратора. Простой вызов count()
в вашем наборе запросов приведет к той же проблеме.
В этой статье мы рассмотрим два подхода к сокращению времени загрузки:
- Приблизительное количество строк с использованием Оценки строк в Postgres
- Расширение пагинатора Django для отображения только предыдущей и следующей страниц
Эта статья основана на предположении, что точное количество не имеет значения при работе с большим количеством объектов.
Настройка проекта
Я подготовил простой проект на Django, чтобы вам было проще следить за ним. Проект содержит приложение для электронной коммерции, которое позволяет вам управлять продуктами и покупками. Кроме того, в нем есть команда для заполнения базы данных.
Сначала клонируем base
ветку репозитория GitHub:
$ git clone https://github.com/duplxey/django-count-approximation.git \
--single-branch --branch base && cd django-count-approximation
Затем используйте Docker для запуска экземпляра Postgres:
$ docker run --name ecomm-postgres -p 5432:5432 \
-e POSTGRES_USER=ecomm -e POSTGRES_PASSWORD=complexpassword123 \
-e POSTGRES_DB=ecomm -d postgres
В качестве альтернативы вы можете использовать локально установленный экземпляр Postgres. Просто обновите
DATABASES
в core/settings.py файле соответствующим образом.
Создайте новую виртуальную среду и активируйте ее:
$ python3 -m venv venv && source venv/bin/activate
Установите требования и перенесите базу данных:
(venv)$ pip install -r requirements.txt
(venv)$ python manage.py migrate
Заполнить базу данных:
(venv)$ python manage.py populate_db
Команда создаст суперпользователя, добавит несколько продуктов и добавит в базу данных 2,5 миллиона покупок. Поскольку команда работает довольно медленно, я предлагаю вам запустить несколько экземпляров для ускорения процесса.
Запустите сервер:
(venv)$ python manage.py runserver
Наконец, откройте свой любимый веб-браузер и перейдите по ссылке http://localhost:8000/admin . Войдите в систему под именем:
user: admin
pass: password
Отлично, вы успешно запустили проект.
Начальный тест
Я предлагаю вам действовать только в том случае, если у вас в базе данных 10 миллионов или более покупок.
Проект поставляется с предустановленным django-silk. Django Silk - это инструмент для динамического профилирования и проверки, который мы будем использовать для тестирования сайта администратора. Мы сосредоточимся на времени выполнения SQL-запросов.
Сначала откройте сайт администратора и перейдите к списку изменений "Покупки".
Вы заметите относительно длительное время загрузки.
Чтобы просмотреть точную статистику, вы можете использовать Django Silk, перейдя по ссылке http://localhost:8000/silk . Нажмите на запрос "/admin/ecomm/purchase", чтобы просмотреть его подробную информацию.
Ой, запрос занял 4049
миллисекунду, 3695
из которых были потрачены на SQL-запросы.
Выбор "SQL" на панели навигации показывает, что SQL-запрос, который занял больше всего времени, был SELECT COUNT(*)
, что заняло 3686
миллисекунды.
Приблизительное количество строк
В этом разделе статьи мы рассмотрим, как приблизить количество объектов, используя встроенную в Postgres оценку строк. Этот подход чрезвычайно быстр, но не дает точного подсчета.
Сначала создайте managers.py файл в приложении ecomm и поместите в него следующий код:
# ecomm/managers.py
from django.db import connections
from django.db.models import QuerySet, Manager
class ApproximateCountQuerySet(QuerySet):
def count(self):
if self.query.where:
return super(ApproximateCountQuerySet, self).count()
cursor = connections[self.db].cursor()
cursor.execute("SELECT reltuples FROM pg_class "
"WHERE relname = '%s';" % self.model._meta.db_table)
return int(cursor.fetchone()[0])
ApproximateCountManager = Manager.from_queryset(ApproximateCountQuerySet)
Этот код создает новый класс с именем ApproximateCountQuerySet
, который наследуется от QuerySet
. Вместо использования класса по умолчанию SELECT COUNT(*)
, он использует класс Postgres' reltuples
. Оценка выполняется только в том случае, если фильтрация не применяется.
Мы также создали менеджер, чтобы иметь возможность использовать ApproximateCountQuerySet
непосредственно в наших моделях.
Затем установите Purchase
менеджер модели в ecomm/models.py примерно так:
# ecomm/models.py
class Purchase(models.Model):
# ...
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = ApproximateCountManager() # new
def __str__(self):
return f"Purchase #{self.id}"
Не забудьте об импорте:
from ecomm.managers import ApproximateCountManager
Дождитесь обновления сервера разработки и повторите тестирование приложения.
Вы должны заметить значительное улучшение. В моем случае время сократилось с 4000
миллисекунд до 155
миллисекунд. Это примерно в 16 раз быстрее!
Расширить пагинатор Django
В этом разделе мы рассмотрим, как избежать запроса на подсчет строк, изменив разбиение на страницы таким образом, чтобы отображались только предыдущая и следующая страницы. Этот подход немного быстрее, чем приблизительное количество строк, но может оказаться не столь удобным.
Набор запросов
Сначала добавьте класс InfiniteCountQuerySet
в ecomm/managers.py:
# ecomm/managers.py
# ...
class InfiniteCountQuerySet(QuerySet):
def count(self):
return 999_999_999
InfiniteCountManager = Manager.from_queryset(InfiniteCountQuerySet)
Этот набор запросов всегда возвращает количество строк, равное 999,999,999
, без учета фактического количества строк. Было выбрано число 999,999,999
, предполагая, что количество строк у вас всегда будет меньше этого значения. Если нет, то смело увеличивайте число.
Затем обновите Purchase
менеджер модели:
# ecomm/models.py
class Purchase(models.Model):
# ...
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
objects = InfiniteCountManager()
def __str__(self):
return f"Purchase #{self.id}"
Не забудьте об импорте:
from ecomm.managers import InfiniteCountManager
Дождитесь обновления сервера и протестируйте приложение еще раз.
Вы заметите результаты, аналогичные результатам предыдущего подхода. В моем случае это заняло от 4000
миллисекунд до примерно 133
миллисекунд. Недостатком этого подхода является то, что ваш queryset count()
всегда будет возвращать 999,999,999
даже в обычных представлениях.
Разбиение на страницы
Нам удалось избежать подсчета строк, но теперь разбивка на страницы выглядит не так. Отображаются страницы от 1 до 10000000000 независимо от того, сколько объектов у нас в таблице базы данных.
Давайте исправим это!
Сначала создайте paginator.py файл в приложении ecomms со следующим содержимым:
# ecomm/paginators.py
from django.core.paginator import Paginator
class PreviousNextPaginator(Paginator):
def get_elided_page_range(self, number=1, *, on_each_side=3, on_ends=2):
return super().get_elided_page_range(number, on_each_side=1, on_ends=0)
Этот код заменяет значение по умолчанию Paginator
на get_elided_page_range()
, отображая только предыдущую и следующую страницу с помощью on_each_side=1
.
Далее обновите PurchaseAdmin
вот так:
# ecomm/admin.py
class PurchaseAdmin(admin.ModelAdmin):
# ...
paginator = PreviousNextPaginator # new
Не забудьте об импорте:
from ecomm.paginators import PreviousNextPaginator
На этом этапе ваша разбивка на страницы должна выглядеть следующим образом:
Последнее, что мы хотим сделать, это удалить текст "999999999 покупок". Чтобы сделать это, мы можем переопределить шаблон администратора Django по умолчанию pagination.html.
Сначала создайте следующую структуру каталогов в приложении ecomm:
templates/
└── admin/
└── ecom/
└── pagination.html
Затем поместите следующее содержимое в pagination.html:
<!-- ecomm/templates/admin/ecomm/pagination.html -->
{% load admin_list %}
{% load i18n %}
<p class="paginator">
{% if pagination_required %}
{% for i in page_range %}
{% paginator_number cl i %}
{% endfor %}
{% endif %}
{% if cl.result_count < 999999999 %}
{{ cl.result_count }}
{% if cl.result_count == 1 %}
{{ cl.opts.verbose_name }}
{% else %}
{{ cl.opts.verbose_name_plural }}{% endif %}
{% endif %}
{% if show_all_url %}
<a href="{{ show_all_url }}" class="showall">{% translate 'Show all' %}</a>
{% endif %}
{% if cl.formset and cl.result_count %}
<input
type="submit"
name="_save"
class="default"
value="{% translate 'Save' %}"
>
{% endif %}
</p>
Отлично, разбивка на страницы теперь отображает количество только в том случае, если InfiniteCountQuerySet
не используется.
Окончательная разбивка на страницы должна выглядеть следующим образом:
Заключение
В заключение мы рассмотрели два способа ускорить подсчет в Django.
Оба подхода дали отличные результаты. Используя их, мы сократили время загрузки таблицы базы данных с 30 миллионами записей в 16 раз. Не стесняйтесь настраивать или комбинировать эти подходы, чтобы наилучшим образом удовлетворить ваши потребности.
Для получения дополнительной информации ознакомьтесь со следующими ресурсами:
- Быстрый способ узнать количество строк в таблице в PostgreSQL
- Приблизительные значения для ускорения работы списка изменений администратора Django
- Разбиение на страницы больших таблиц от Django: Масштабирование страниц администратора Django
- джанго-postgres-fuzzycount
- Избегайте подсчета в разбивке на страницы Django