Приблизительный подсчет в Django и Postgres

В этой статье рассматривается, как ускорить подсчет с помощью Django и PostgreSQL. В ней рассматриваются два разных подхода.

Предпочитаете вообще избегать подсчета? Ознакомьтесь с Избегайте подсчета в разбивке на страницы Django.

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

Но проблема не ограничивается сайтом администратора. Простой вызов count() в вашем наборе запросов приведет к той же проблеме.

В этой статье мы рассмотрим два подхода к сокращению времени загрузки:

  1. Приблизительное количество строк с использованием Оценки строк в Postgres
  2. Расширение пагинатора 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", чтобы просмотреть его подробную информацию.

Django Silk Benchmarks Default Details

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

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

Django Silk Benchmarks Default SQL

Приблизительное количество строк

В этом разделе статьи мы рассмотрим, как приблизить количество объектов, используя встроенную в 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

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

Django Mid Paginator

Последнее, что мы хотим сделать, это удалить текст "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 Extended Paginator

Заключение

В заключение мы рассмотрели два способа ускорить подсчет в Django.

Оба подхода дали отличные результаты. Используя их, мы сократили время загрузки таблицы базы данных с 30 миллионами записей в 16 раз. Не стесняйтесь настраивать или комбинировать эти подходы, чтобы наилучшим образом удовлетворить ваши потребности.

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

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