Evgenii Legotckoi
Evgenii Legotckoi22 жовтня 2019 р. 01:39

Django - Урок 049. Оптимізація продуктивності Django на прикладі бойового проекту

Останнім часом я присвятив багато часу оптимізації сайту і тепер хотілося б розповісти про це.
У даній статті буде пояснено використання методів 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',
    ...
]

Ще на знадобиться додати urls з 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 просто з'їсть всі ваші ресурси. Тому експериментуйте тільки на development сервері.

Робота з оптимізацією

Для початку подивимося, наскільки все погано з запитами до бази даних на головній сторінці форуму. Для ясності подивимося, як виглядає ця сторінка

Для цього нам потрібен всього лише завантажити цікаву для нас сторінку і подивитися статистику запиту в Django Silk.

Перший крок оптимізації головної сторінки форуму сайту EVILEG

В даний момент всі запити будуть показані в дебаг режимі.

Ситуація виходить гнітюча, оскільки на завантаження головної сторінки форуму доводиться:

  • 325запитів до бази даних
  • на які витрачається 155 мс
  • 568 мс на всю сторінку

Це дуже великі витрати за часом, тим більше, що для кожного запиту утсанавлівается з'єднання з базою даних, а потім всі необхідні дані потрібно ще завантажити в об'єкти.

Витрата ресурсів виходить величезним. Вважаю, що це одна з причин, чому багато хто вважає Django повільним, але насправді вони просто не розібралися, як налаштувати і оптимізувати запити до бази даних.

Виконуємо оптимізацію

Подивимося, як виглядає початковий QuerySet для головної сторінки форуму.

def get_queryset(self):
    return ForumTopic.objects.all()

Як бачите нічого складного. Саме такі запити як правило і пишуть на самому початку використання Django ORM. А вже потім починаються питання, як оптимізувати продуктивність Django.

Справа в тому, що первинний queryset, який потрібно для відображення цієї сторінки забирає з бази даних тільки об'єкти 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

Поліпшення продуктивності з використанням select_related

Ситуація з кількістю запитів вже стала краще

  • 256 запитів до бази даних
  • на які витрачається 131 мс
  • 444 мс на всю сторінку

На наступному малюнку наведено рядок з новим запитом, який має 4 join операції.

Як бачите, тривалість цього запиту склала 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 я отримую наступний результат

Використання select_related і prefetch_related

У підсумку маємо наступне:

  • 6 запитів до бази даних
  • на які витрачається 26 мс
  • 148 мс на всю сторінку

Це просто відмінний результат, якого можна досягти. При цьому для користувача по відчуття сторінка вже починає завантажуватися дуже швидко.

Але це ще не все, зауважте що запит, які имет 4 join операції і надалі перебуває в районі 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 запитів до бази даних
  • на які витрачається 20 мс
  • 136 мс на всю сторінку

Звичайно, я привожу кращі можливі результати, оскільки завжди є деякі коливання в вимірах, але з даного скриншоту видно, що тривалість основного запиту знизилася з 17-19 мс до 11-13 мс . Крім іншого вибірка тільки потрібних полів знижує і споживання пам'яті, якщо з бази даних забираються наприклад дуже великі шматки текстових даних, які при цьому не використовуються в рендеринге сторінки.

А тепер давайте трохи пограємось з виконанням запитів select_related і prefetch_related

Додаткова оптимізація

Дочитавши до цього моменту, Ви, я думаю, переконалися, що використання select_related дозволяє дуже класно оптимізувати запити до бази даних. Але є одне але . Деякі проблеми можуть виникнути в використанням класу Paginator , який використовується у мене на даній сторінці. І справа в тому, що для Paginatorа необхідно виконувати запит count, щоб підрахувати правильну кількість сторінок. А якщо запит буде дуже складним, то і тривалість запиту 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 запитів * до бази даних
  • на які витрачається 14 мс
  • 141 мс на всю сторінку

Звичайно, Ви можете сказати, що в даному випадку тут не дуже-то великий виграш. Тим більше, що загальна швидкість завантаження навіть трохи впала (5 мс), а запитів до бази даних стало на 2 більше, але в той же час я отримав прірорст продуктивності запитів на 42 відсотки , а це вже дечого варто. Таким чином, якщо у вас на сайті є дуже тривалі запити, які використовуються в пагінацію і мають велику кількість join операцій, то можливо варто переписати використання select_related на prefetch_related . Це насправді може допомогти зробити ваш сайт на Django значно швидше.

Висновок

  • Використовуйте select_related , щоб одночасно з основним запитом вибирати і відповідні поля з інших таблиць
  • Використовуйте prefetch_related , щоб додатково довантажувати одним запитом всі об'єкти інших моделей, які мають ForeignKey на ваш основний queryset
  • Використовуйте only , щоб обмежити забираються стовпці, це також прискорить запити і зменшить споживання пам'яті
  • Якщо ви використовуєте Paginator , то упевніться, що основний запит не генерує дуже важкий запит count , інакше, можливо, що варто частина select_related запитів довантажувати як prefetch_related
Рекомендуємо хостинг TIMEWEB
Рекомендуємо хостинг TIMEWEB
Стабільний хостинг, на якому розміщується соціальна мережа EVILEG. Для проектів на Django радимо VDS хостинг.

Вам це подобається? Поділіться в соціальних мережах!

Руслан Волшебник
  • 24 жовтня 2019 р. 11:37

Спасибо. Хорошая статья.

Я нашёл 2 опечатки. Выделил жирным.

prefetch_related
prefetch_related отличается тем, что позволяет подгрузить не только объекты, которые используются в ForeignKey полях модели, но и те объекты, модели...
Должно вроде быть "но и те ".

Дополнительная оптимизация
...То есть у вас может быть ситуация, когда лучше выполнить ещё пару дополнительных запросов, через перегружать join операциями основной запрос.
Тут видимо имелось ввиду чем .

Evgenii Legotckoi
  • 25 жовтня 2019 р. 02:54

Спасибо, поправил

p
  • 25 листопада 2019 р. 09:11

Стоило бы упомянуть про Prefetch объекты со специально сформированными querysetами. Про кеширование. Помимо only есть defer. В некоторых случаях в drf можно автоматически делать select/prefetch_related. И запросы можно смотреть в django_debug_toolbar или в shell_plus --print-sql

Evgenii Legotckoi
  • 25 листопада 2019 р. 09:21

Стоило бы упомянуть про Prefetch объекты со специально сформированными querysetами

Вы про это?

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'
    )

В некоторых случаях в drf можно автоматически делать select/prefetch_related

Для drf можно сделать отдельную статью, я вообще не рассматривал в данной статье drf

И запросы можно смотреть в django_debug_toolbar или в shell_plus --print-sql

В качестве альтернативы

D
  • 02 березня 2021 р. 02:48

Огромное спасибо вам за статью! Для меня стали открытием select_related и prefetch_related

Коментарі

Only authorized users can post comments.
Please, Log in or Sign up
Дмитрий

C++ - Тест 004. Указатели, Массивы и Циклы

  • Результат:60бали,
  • Рейтинг балів-1
Дмитрий

C++ - Тест 003. Условия и циклы

  • Результат:92бали,
  • Рейтинг балів8
d
  • dsfs
  • 26 квітня 2024 р. 16:56

C++ - Тест 004. Указатели, Массивы и Циклы

  • Результат:80бали,
  • Рейтинг балів4
Останні коментарі
k
kmssr09 лютого 2024 р. 07:43
Qt Linux - Урок 001. Автозапуск програми Qt під Linux как сделать автозапуск для флэтпака, который не даёт создавать файлы в ~/.config - вот это вопрос ))
АК
Анатолий Кононенко05 лютого 2024 р. 14:50
Qt WinAPI - Урок 007. Робота з ICMP Ping в Qt Без строки #include <QRegularExpressionValidator> в заголовочном файле не работает валидатор.
EVA
EVA25 грудня 2023 р. 23:30
Boost - статичне зв&#39;язування в проекті CMake під Windows Ошибка LNK1104 часто возникает, когда компоновщик не может найти или открыть файл библиотеки. В вашем случае, это файл libboost_locale-vc142-mt-gd-x64-1_74.lib из библиотеки Boost для C+…
J
JonnyJo25 грудня 2023 р. 21:38
Boost - статичне зв&#39;язування в проекті CMake під Windows Сделал всё по-как у вас, но выдаёт ошибку [build] LINK : fatal error LNK1104: не удается открыть файл "libboost_locale-vc142-mt-gd-x64-1_74.lib" Хоть убей, не могу понять в чём дел…
G
Gvozdik19 грудня 2023 р. 10:01
Qt/C++ - Урок 056. Підключення бібліотеки Boost в Qt для компіляторів MinGW і MSVC Для решения твой проблемы добавь в файл .pro строчку "LIBS += -lws2_32" она решит проблему , лично мне помогло.
Тепер обговоріть на форумі
G
George1307 травня 2024 р. 12:27
добавить qlineseries в функции в функции: "GPlotter::addSeries(QString title, QVector &arr)" я вызываю метод setChart(...), я в конструктор передал адрес на QChartView элемент
BlinCT
BlinCT05 травня 2024 р. 17:46
Написать свой GraphsView Всем привет. В Qt есть давольно старый обьект дял работы с графиками ChartsView и есть в 6.7 новый но очень сырой и со слабым функционалом GraphsView. По этой причине я хочу написать х…
PS
Peter Son04 травня 2024 р. 05:57
Best Indian Food Restaurant In Cincinnati OH Ready to embark on a gastronomic journey like no other? Join us at App india restaurant and discover why we're renowned as the Best Indian Food Restaurant In Cincinnati OH . Whether y…
Evgenii Legotckoi
Evgenii Legotckoi03 травня 2024 р. 02:07
Мобильное приложение на C++Qt и бэкенд к нему на Django Rest Framework Добрый день. По моему мнению - да, но то, что будет касаться вызовов к функционалу Андроида, может создать огромные трудности.
IscanderChe
IscanderChe30 квітня 2024 р. 16:22
Во Flask рендер шаблона не передаётся в браузер Доброе утро! Имеется вот такой шаблон: <!doctype html><html> <head> <title>{{ title }}</title> <link rel="stylesheet" href="{{ url_…

Слідкуйте за нами в соціальних мережах