Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Эффективное использование 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.

Построитель запросов Django ORM позволяет решать многие задачи, не сильно усложняя код запросов. Разберем два класса улучшений: ограничение состава запрашиваемых данных и раннюю загрузку связанных данных.

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

Довольно часто нам не требуются все поля модели — нужна всего пара полей из нескольких десятков. Тут пригодится метод .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 выводит посты блога в виде списка диалогов, причем выводит тело только для пары первых пунктов. Это очень лаконичное решение с точки зрения кода, которое еще и не слишком нагружает базу.

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

Попробуем написать такой цикл:

for p in Post.objects.all():
   print((p.title, p.creator.email))

# SELECT ...
#   FROM "blog_post"

# Execution time: 0.002823s [Database: default]
# SELECT ...
#   FROM "blog_user"
#  WHERE "blog_user"."id" = 1

# Execution time: 0.000459s [Database: default]

# => (Intro, bob@blogs.org)

# SELECT ...
#   FROM "blog_user"
#  WHERE "blog_user"."id" = 1
#  LIMIT 21

# Execution time: 0.000123s [Database: default]

# => (Update, bob@blogs.org)

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

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

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

post = Post.objects.select_related('creator').only('title', 'creator__email')[0]
# SELECT "blog_post"."id",
#        "blog_post"."title",
#        "blog_post"."creator_id",
#        "blog_user"."id",
#        "blog_user"."email"
#   FROM "blog_post"
#  INNER JOIN "blog_user"
#     ON ("blog_post"."creator_id" = "blog_user"."id")
#  LIMIT 1

# Execution time: 0.000421s [Database: default]

print((post.title, post.creator.email))
# => ('Intro', 'bob@blogs.org')


print(post.body)
# SELECT "blog_post"."id",
#        "blog_post"."body"
#   FROM "blog_post"
#  WHERE "blog_post"."id" = 1
#  LIMIT 21

# Execution time: 0.000161s [Database: default]

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


print(post.creator.first_name)
# SELECT "blog_user"."id",
#        "blog_user"."first_name"
#   FROM "blog_user"
#  WHERE "blog_user"."id" = 1
#  LIMIT 21

# Execution time: 0.000346s [Database: default]

# => 'Bob'

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

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

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

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

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

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

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

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

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

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

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

Об обучении на Хекслете

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

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

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

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

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

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

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

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

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

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

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

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