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
AD

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

  • Результат:50бали,
  • Рейтинг балів-4
m
  • molni99
  • 26 жовтня 2024 р. 01:37

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

  • Результат:80бали,
  • Рейтинг балів4
m
  • molni99
  • 26 жовтня 2024 р. 01:29

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

  • Результат:20бали,
  • Рейтинг балів-10
Останні коментарі
ИМ
Игорь Максимов22 листопада 2024 р. 11:51
Django - Підручник 017. Налаштуйте сторінку входу до Django Добрый вечер Евгений! Я сделал себе авторизацию аналогичную вашей, все работает, кроме возврата к предидущей странице. Редеректит всегда на главную, хотя в логах сервера вижу запросы на правильн…
Evgenii Legotckoi
Evgenii Legotckoi31 жовтня 2024 р. 14:37
Django - Урок 064. Як написати розширення для Python Markdown Добрый день. Да, можно. Либо через такие же плагины, либо с постобработкой через python библиотеку Beautiful Soup
A
ALO1ZE19 жовтня 2024 р. 08:19
Читалка файлів fb3 на Qt Creator Подскажите как это запустить? Я не шарю в программировании и кодинге. Скачал и установаил Qt, но куча ошибок выдается и не запустить. А очень надо fb3 переконвертировать в html
ИМ
Игорь Максимов05 жовтня 2024 р. 07:51
Django - Урок 064. Як написати розширення для Python Markdown Приветствую Евгений! У меня вопрос. Можно ли вставлять свои классы в разметку редактора markdown? Допустим имея стандартную разметку: <ul> <li></li> <li></l…
d
dblas505 липня 2024 р. 11:02
QML - Урок 016. База даних SQLite та робота з нею в QML Qt Здравствуйте, возникает такая проблема (я новичок): ApplicationWindow неизвестный элемент. (М300) для TextField и Button аналогично. Могу предположить, что из-за более новой верси…
Тепер обговоріть на форумі
Evgenii Legotckoi
Evgenii Legotckoi24 червня 2024 р. 15:11
добавить qlineseries в функции Я тут. Работы оень много. Отправил его в бан.
t
tonypeachey115 листопада 2024 р. 06:04
google domain [url=https://google.com/]domain[/url] domain [http://www.example.com link title]
NSProject
NSProject04 червня 2022 р. 03:49
Всё ещё разбираюсь с кешем. В следствии прочтения данной статьи. Я принял для себя решение сделать кеширование свойств менеджера модели LikeDislike. И так как установка evileg_core для меня не была возможна, ибо он писался…
9
9Anonim25 жовтня 2024 р. 09:10
Машина тьюринга // Начальное состояние 0 0, ,<,1 // Переход в состояние 1 при пустом символе 0,0,>,0 // Остаемся в состоянии 0, двигаясь вправо при встрече 0 0,1,>…

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