В последнее время я много времени уделяю оптимизации сайта и сейчас хочу рассказать об этом.
В этой статье объясняется использование методов
select_related
и
prefetch_related
в QuerySet, а также их различия. Я также попытаюсь объяснить, почему Django считается медленным, и почему это до сих пор не так. Конечно, Django во многом медленнее того же Flask, но при этом в большинстве проектов проблема не в самом Django, а скорее в отсутствии оптимизации запросов к базе данных.
Поэтому давайте оптимизируем страницу форума сайта EVILEG . И поможет нам в этом батарея Django Silk, которая служит для измерения количества запросов к базе данных, а также измерения их продолжительности.
Установите и настройте Django Silk
Установите Django Silk
pip install django-silk
Добавьте его в INSTALLED_APPS
INSTALLED_APPS = [ ... 'silk' ]
а также добавить MIDDLEWARE
MIDDLEWARE = [ ... 'silk.middleware.SilkyMiddleware', ... ]
Вам также необходимо добавить URL-адреса из django-silk, чтобы вы могли просматривать статистику запросов.
from django.urls import path, include urlpatterns = [ path('silk/', include('silk.urls', namespace='silk')) ]
И последний шаг — применение миграции django-silk.
python manage.py migrate
Примечания к Django Silk
Не используйте Django Silk на рабочем сервере. По крайней мере с настройками, показанными в этой статье. Если у вас и так хорошая посещаемость на сайте, например 1400 человек в день, то с такими настройками Django Silk просто съест все ваши ресурсы. Поэтому экспериментируйте только на сервере разработки.
Оптимизация
Для начала посмотрим, насколько все плохо с запросами к базе данных на главной странице форума. Для наглядности посмотрим, как выглядит эта страница.
Для этого нам достаточно скачать интересующую нас страницу и посмотреть статистику запроса в Django Silk.
На данный момент все запросы будут отображаться в режиме отладки.
Ситуация удручающая, ведь на загрузку главной страницы форума приходится:
- 325 queries to the database
- spent 155 ms
- 568 ms full page
Это очень трудоемкая задача, тем более, что для каждого запроса устанавливается соединение с базой данных, а потом все необходимые данные нужно еще загрузить в объекты.
Расход ресурсов огромный. Я думаю, это одна из причин, почему многие люди считают Django медленным, но на самом деле они просто не понимали, как настраивать и оптимизировать запросы к базе данных.
Проводим оптимизацию
Посмотрим, как выглядит исходный QuerySet для главной страницы форума.
def get_queryset(self): return ForumTopic.objects.all()
1 493 / 5 000
Результаты перевода
Как видите, ничего сложного. Именно такие запросы обычно пишут в самом начале использования Django ORM. И только потом начинаются вопросы, как оптимизировать производительность Django.
Дело в том, что исходный набор запросов, необходимый для отображения этой страницы, берет только объекты ForumTopic из базы данных, но не берет другие объекты, которые добавляются в поля ForeignKey ForumTopic модель данных. Поэтому Django вынужден автоматически загружать все чрезвычайно большие объекты, когда они требуются. Но программист знает, что требуется для каждой отдельной страницы и может одним запросом указать Django все необъятные объекты, которые нужно подобрать заранее. Давайте сделаем это с помощью select_related.
select_related
Этот метод позволяет в одном запросе собрать дополнительные объекты из других таблиц. Это позволит объединить множество запросов в один и ускорит выборку, а также уменьшит накладные расходы на подключение к БД, так как количество подключений и так очень сильно уменьшено.
Попробуем выбрать некоторые данные в одном запросе, используя selected_related . Я знаю, что для моей модели ForumTopic следующие поля могут быть выбраны как related :
- article - статья на форуме
- answer - ответ, сообщение, которое было отмечено как ответ в ветке форума
- section - раздел, в котором был задан вопрос
- user - пользователь, который задал этот вопрос
Исходный запрос к базе данных можно изменить следующим образом:
def get_queryset(self, **kwargs): return ForumTopic.objects.all().select_related('article', 'answer', 'section', 'user')
Затем посмотрите на результат в Django Silk.
Ситуация с количеством запросов стала лучше
- 256 запросов к базе данных
- затраты по времени 131 мс
- 444 мс полная страница
На следующем рисунке показана строка с новым запросом, который имеет 4 операции соединения.
Как видите, продолжительность этого запроса составила 19,225 мс .
Уже хороший результат. Но я точно знаю, что это не предел. Дело в том, что структура главной страницы форума достаточно сложная, и на ней указано количество сообщений в каждой теме, последнее сообщение, ссылка на ответ решения, а также запрос в профиль пользователя. для ответов. И вот настала очередь метода prefetch_related .
prefetch_related
prefetch_related отличается тем, что позволяет загружать не только объекты, которые используются в полях ForeignKey модели, но и те объекты, модели которых имеют поле ForeignKey в той модели, которая задействована в основной базе данных запросить данные. То есть вы можете загружать сообщения в тему отдельным запросом. В этой ситуации я хочу загрузить следующие поля.
- comments - это сообщения в теме, модель ForumPost
- comments__user - внешний ключ пользователя, оставившего сообщение
- answer___parent — внешний ключ ForumTopic — это ответ, отмеченный разрешением темы. Теоретически можно было бы подобрать этот объект через select_related , но структура запроса стала очень сложной, что не позволяло эффективно использовать select_related . Да-да, использование этого метода должно быть разумным. Производительность, конечно, улучшается, но иногда лучше собрать какие-то данные отдельным запросом.
Тогда запрос к базе данных уже выглядит так:
def get_queryset(self, **kwargs): return ForumTopic.objects.all().select_related('article', 'answer', 'section', 'user').prefetch_related( 'comments', 'comments__user', 'answer___parent' )
И в Django Silk я получаю следующий результат
В результате имеем следующее:
- 6 queries to the database
- spent on 26 ms
- 148 ms full page
Это просто отличный результат, которого можно добиться. При этом пользователь уже чувствует, что страница загружается очень быстро.
Но и это еще не все, обратите внимание, что запрос, имеющий 4 операции соединения, еще находится в районе 17-20 мс. Можем ли мы что-то с этим сделать? Конечно можем, и для этого нам нужно будет использовать метод only .
only
Метод only позволяет выбрать только те столбцы, которые нам нужны для отображения страницы. Но в этом случае необходимо будет учитывать все столбцы, которые требуются, иначе каждый пропущенный столбец Django будет подхватываться отдельным запросом.
Итак, я написал следующий запрос к базе данных
def get_queryset(self, **kwargs): return ForumTopic.objects.all().select_related('article', 'answer', 'section', 'user').prefetch_related( 'comments', 'comments__user', 'answer___parent' ).only( 'user__first_name', 'user__last_name', 'section__title', 'section__title_ru', 'article__title', 'article__title_ru' )
И получил следующий результат
- 6 queries to the database
- spent on 20 ms
- 136 ms full page
Конечно, я привожу наилучшие возможные результаты, поскольку всегда имеются некоторые признаки в измерениях, но по анализу скриншота видно, что длительность запроса снизилась с 17-19 мс до 11-13 мс . Помимо выборки только необходимых объемов и потребления, если из базы данных забирается, например, очень крупных массивов текстовых данных, при этом не используются при рендеринге страниц.
Теперь давайте немного поиграем с запросами select_related и prefetch_related .
Additional optimization
Дочитав до этого места, вы, я думаю, убедились, что использование select_related позволяет очень здорово оптимизировать запросы к базе данных. Но есть одно НО . Некоторые проблемы могут возникнуть при использовании класса Paginator , который используется на моей странице. А дело в том, что для Paginator необходимо выполнить запрос count, чтобы посчитать правильное количество страниц. А если запрос очень сложный, то длительность запроса на подсчет может быть довольно большой и соизмеримой с выполнением обычного запроса. Поэтому важным условием может быть написание быстрого и эффективного основного запроса, а все остальные объекты будут лучше загружаться с помощью prefetch_related . То есть у вас может возникнуть ситуация, когда лучше выполнить пару дополнительных запросов, через перегрузку операций join основным запросом.
И я написал такой запрос в ORM для этой страницы
def get_queryset(self, **kwargs): return ForumTopic.objects.all().select_related('answer').prefetch_related( Prefetch('article', queryset=Article.objects.all().only('title', 'title_ru')), Prefetch('section', queryset=ForumSection.objects.all().only('slug', 'title', 'title_ru')), Prefetch('user', queryset=User.objects.all().only('username', 'first_name', 'last_name')), Prefetch('comments', queryset=ForumPost.objects.all().select_related('user').only( 'user__username', 'user__first_name', 'user__last_name', '_parent_id' )), Prefetch('answer___parent', queryset=ForumTopic.objects.all().only('id')) ).only( 'title', 'user_id', 'section_id', 'article_id', 'answer___parent_id', 'pub_date', 'lastmod', 'attachment' )
При этом я получил следующий результат производительности
- 8 queries to the database
- spent 14 ms
- 141 ms full page
Конечно, можно сказать, что в этом случае не очень большой выигрыш. Более того, общая скорость загрузки даже немного упала (5 мс), и запросов к базе стало на 2 больше, но при этом я получил прирост производительности запросов на 42 процента , а это уже что-то стоящее. Таким образом, если на вашем сайте есть очень длинные запросы, которые используются для разбиения на страницы и имеют большое количество операций соединения, возможно, стоит переписать использование select_related на prefetch_related . На самом деле это может помочь сделать ваш сайт Django намного быстрее.
Вывод
- Используйте select_related для выбора соответствующих полей из других таблиц одновременно с основным запросом
- Используйте prefetch_related для дополнительной загрузки одним запросом всех объектов других моделей, которые имеют ForeignKey в вашем основном наборе запросов.
- Используйте только , чтобы ограничить количество столбцов, которые нужно взять, это также ускорит запросы и уменьшит потребление памяти.
- Если вы используете Paginator , убедитесь, что основной запрос не генерирует очень тяжелый запрос count , в противном случае возможно, что некоторые select_related запросы загружаются как prefetch_related
Спасибо. Хорошая статья.
Я нашёл 2 опечатки. Выделил жирным.
prefetch_related
prefetch_related отличается тем, что позволяет подгрузить не только объекты, которые используются в ForeignKey полях модели, но и те объекты, модели...
Должно вроде быть "но и те ".
Дополнительная оптимизация
...То есть у вас может быть ситуация, когда лучше выполнить ещё пару дополнительных запросов, через перегружать join операциями основной запрос.
Тут видимо имелось ввиду чем .
Спасибо, поправил
Стоило бы упомянуть про Prefetch объекты со специально сформированными querysetами. Про кеширование. Помимо only есть defer. В некоторых случаях в drf можно автоматически делать select/prefetch_related. И запросы можно смотреть в django_debug_toolbar или в shell_plus --print-sql
Вы про это?
Для drf можно сделать отдельную статью, я вообще не рассматривал в данной статье drf
В качестве альтернативы
Огромное спасибо вам за статью! Для меня стали открытием select_related и prefetch_related