Зарегистрируйтесь, чтобы продолжить обучение

Эффективное использование ORM Python: Django ORM

Как и любое средство повышения уровня абстракции, ORM может выдавать не самые эффективные запросы в некоторых случаях. К сожалению, по-другому быть не может: тонко настроенные SQL-запросы находятся на существенно более низком уровне. В Django ORM можно переписывать код запроса руками, но есть ли в этом необходимость? Вовсе нет.

Для начала прочтем отрывок из документации к фреймворку:

  1. Сначала измерьте. У любого QuerySet можно запросить описание предполагаемых запросов с помощью вызова метода .explain(). Тут пригодится умение читать SQL и понимать то, о чем рассказывает через explain ваша СУБД
  2. Добавьте индексы. SQL explain часто может сказать, что в каком-то месте делается "full scan" то есть полный перебор, а это означает, что где-то не хватает индексов. Стоит подумать о том, чтобы оные добавить. Подробности можно почитать в документации
  3. Делайте как можно больше работы силами СУБД. Аннотируйте, агрегируйте. СУБД знает, как это оптимизировать и как кэшировать результаты.
  4. В крайнем случае пишите необходимый минимум SQL.
  5. Знайте, как работают QuerySets и помните:
  • Насколько QuerySets ленив — он не делает лишних запросов, пока вы не попросите
  • В какой момент QuerySet вычисляется (финализируется)
  • Каким образом данные представлены в памяти
  • Как QuerySet кеширует результаты запросов и подзапросов

Следует помнить, что многие ситуации разрешаются задолго до того, как вы дойдете до написания SQL.

Explain

Один из ключевых приемов для анализа запросов это функция EXPLAIN. Он показывает, как база данных планирует выполнить ваш запрос. Он раскрывает такие важные детали, как использование индексов, порядок соединения таблиц и предполагаемую стоимость операций. В Django метод .explain() предоставляет удобный доступ к этой функциональности, помогая понять, почему некоторые запросы могут работать медленнее других и как их можно оптимизировать.

from django.db.models import Count, Q
from django.db import connection

# Создаем сложный запрос для анализа
complex_query = Post.objects.annotate(
    comment_count=Count('comments'),
    tag_count=Count('tags')
).filter(
    Q(comment_count__gte=10) | 
    Q(tag_count__gte=5)
).select_related('author')

print(complex_query.explain())

"""
->  Hash Join  (cost=35.50..60.53 rows=100 width=40)
  ->  Seq Scan on blog_post  (cost=0.00..25.00 rows=100 width=20)
  ->  Hash  (cost=20.50..20.50 rows=50 width=20)
        ->  Index Scan using blog_tags_pkey on blog_tags
"""

EXPLAIN выведет структуру запроса конкретной базы данных. Выше пример для Postgres, в другой базе вывод будет своим.

Ограничение состава загружаемых данных

Довольно часто нам не требуются все поля модели — нужна всего пара полей из нескольких десятков. Тут пригодится метод .values_list(имена, полей), который возвращает новый QuerySet. Его элементами будут кортежи со значениями указанных полей в указанном же порядке. Такой QuerySet удобно обходить в цикле с одновременной распаковкой:

for pid, title in Post.objects.values_list('id', 'title'):
    print(pid, '|', title)

# SELECT "blog_post"."id",
#        "blog_post"."title"
#   FROM "blog_post"

# Execution time: 0.000571s [Database: default]
# => 1 | Intro
# => 2 | Update

Если значений нужно не два и не три, то есть смысл использовать метод .values(имена, полей). При обходе итогового QuerySet он даст уже не кортежи, а словари с ключами, совпадающими с именами указанных полей.

Однако ни кортежи, ни словари не дают воспользоваться методами модели, а в некоторых ситуациях это все-таки требуется. Если точно известно, какие поля понадобятся при работе с моделью и при вызове ее методов, подойдет метод .only(имена, полей): возращаемый им QuerySet выдает объекты модели, но в каждом объекте заполнены только указанные поля. Так мы можем использовать все возможности модели. Но следует помнить, что первое же обращение к полю, не указанному в вызове .only(), породит запрос. Он сработает для текущего объекта — запросит данные для этого поля и запомнит их на будущее:

ps = Post.objects.only('title')
post = ps[0]  # Первый пост, загружаются только заголовки (и id)
# SELECT "blog_post"."id",
#        "blog_post"."title"
#   FROM "blog_post"
#  LIMIT 1

# Execution time: 0.000327s [Database: default]

post.title
# => 'Intro'
post.body  # Поле потребует загрузки
# SELECT "blog_post"."id",
#        "blog_post"."body"
#   FROM "blog_post"
#  WHERE "blog_post"."id" = 1
#  LIMIT 21

# Execution time: 0.000262s [Database: default]

# => 'Hi, my name is Bob!'

Использование .only() отлично показывает всю высокоуровневость ORM: можно просто обращаться к полям объекта и не думать, что и когда подгружается. Еще view выводит посты блога в виде списка диалогов, причем выводит тело только для пары первых пунктов. Это очень лаконичное решение с точки зрения кода, которое еще и не слишком нагружает базу.

Ранняя загрузка связанных данных

Проблема N+1 запросов - это классическая ловушка производительности при работе с ORM. Она возникает, когда код сначала делает один запрос для получения списка объектов, а затем для каждого объекта выполняет дополнительный запрос для получения связанных данных.

Например, мы открываем список из 100 постов блога, и для каждого поста нужно показать имя автора. Наивный подход приведет к тому, что сначала будет выполнен один запрос для получения постов, а затем для каждого поста будет выполнен отдельный запрос для получения информации об авторе. В итоге получится 101 запрос к базе данных (1 + 100), отсюда и название "N+1".

Вот как это выглядит в коде:

posts = Post.objects.all()  # 1 запрос
for post in posts:
    author_name = post.author.name  # +N запросов

Здесь один запрос получает все посты, а затем для каждого поста запрашивается автор.

Нельзя сказать, что тут все плохо. Авторы подгружаются по мере необходимости, если мы хотим вывести список заголовков постов и показать автора первых двух постов, то это приемлемый код.

Если же все посты нужны вместе с авторами, нужно использовать метод .select_related(), который эквивалентен JOIN в SQL. Если при вызове метода не указывать аргументы, то будут присоединены все связанные таблицы. Если же указать имена связей, то указанные таблицы подгрузятся сразу, остальные — как обычно, по требованию. Это достаточно гибкое средство, особенно в сочетании с другими способами оптимизации:

post = Post.objects.select_related('author').only('title', 'author__email')[0]
print((post.title, post.author.email)) # => ('Intro', 'bob@blogs.org')
print(post.body) # => 'Hi, my name is Bob!'
print(post.author.first_name) # => 'Bob'

Обратите внимание, что первый запрос содержал только указанные поля — пусть даже и в двух таблицах — а также служебные поля вроде id и поля для связи таблиц. При этом с точки зрения наблюдателя post выглядит как цельный объект модели Post, а его атрибут .author — как цельный объект модели User. Все дополнительные данные прозрачно подгружаются по мере необходимости.

Кроме select_related(), в Django есть еще один похожий метод — prefetch_related(). С помощью обоих методов можно работать со связанными объектами, но немного в разных сценариях:

  • Метод select_related() используется с отношением один ко многим. Например, такое отношение есть между автором и постом в блоге, потому что каждый пост написан одним автором. Чтобы получить информацию об авторе каждого поста, можно добавить select_related() в тот же запрос к базе данных. Метод select_related() работает через выполнение SQL JOIN и включение полей связанного объекта в оператор SELECT

  • Метод prefetch_related() используется с отношением многие ко многим. Наглядный пример — пост в блоге, у которого может быть много комментариев. Метод prefetch_related() выполняет отдельные поиски для каждого отношения и выполняет объединение уже в Python. Так мы заранее извлекаем объекты «многие ко многим» и «многие к одному», что невозможно сделать через select_related()

class Post(models.Model):
    title = models.CharField(max_length=200)
    tags = models.ManyToManyField('Tag')
    author = models.ForeignKey('Author', on_delete=models.CASCADE)

class Comment(models.Model):
    post = models.ForeignKey(Post, related_name='comments')
    text = models.TextField()

# загрузит все связанные данные за 3 запроса
posts = Post.objects.prefetch_related(
    'comments',  # загрузит все комментарии
    'tags',      # загрузит все теги
    'comments__author'  # загрузит авторов комментариев
).select_related('author')  # загрузит автора поста через JOIN

Оба метода помогают избежать проблемы N+1, когда итерация по связанным объектам вызывает дополнительный запрос для каждого объекта. Но все таки один метод может быть более подходящим, чем другой — все зависит от типа отношения и конкретного случая.

Проще говоря, оба метода позволяют сказать: «Я получаю данные и знаю, что еще мне понадобятся эти другие связанные данные, поэтому получи все сразу, а не ходи туда-сюда в базу данных».

Самостоятельная работа

  1. На примере моделей из учебного проекта постройте несколько сложных запросов и понаблюдайте, какие запросы и в какой момент делает ORM.
  2. Попробуйте пооптимизировать запросы с помощью описанных в уроке средств.

Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

Курсы программирования для новичков и опытных разработчиков. Начните обучение бесплатно

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff
Рекомендуемые программы
профессия
от 25 000 ₸ в месяц
Разработка веб-приложений на Django
10 месяцев
с нуля
Старт 20 февраля

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»