Останнім часом я присвятив багато часу оптимізації сайту і тепер хотілося б розповісти про це.
У даній статті буде пояснено використання методів
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.
В даний момент всі запити будуть показані в дебаг режимі.
Ситуація виходить гнітюча, оскільки на завантаження головної сторінки форуму доводиться:
- 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
Ситуація з кількістю запитів вже стала краще
- 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 я отримую наступний результат
У підсумку маємо наступне:
- 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
Спасибо. Хорошая статья.
Я нашёл 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