Оптимизация запросов Django ORM

Spread the love

Оригинальная статья: Rocio Aramberri — Optimizing Django ORM Queries

Django ORM (Object Relational Mapping) — одна из самых мощных функций Django. Благодаря ей мы можем взаимодействовать с базой данных, используя код Python вместо SQL.

Спонсор поста Раскрутка сайта в Краснодаре — Веб-студия KubiQ:

Веб-студия Kubiq.studio предлагает услугу раскрутки сайтов в Краснодаре. Ответственность при выполнении задач — наш главный козырь.

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

Использование ORM дает несколько преимуществ:

  • Благодаря ORM у нас есть миграция: мы можем легко изменять наши таблицы, обновлять наши модели, и Django автоматически сгенерирует сценарии миграции, необходимый для обновления таблиц базы данных.
  • Есть транзакции: мы можем делать несколько обновлений базы данных в рамках транзакции и, если что-то не получится, легко откатиться до того состояния, которое было при запуске.

Но также у ORM есть некоторые недостатки:

  • Поскольку это абстракция поверх SQL, то мы не всегда знаем точно, какие SQL-запросы будут генерироваться из нашего кода Python.
  • Django не может угадать, когда нам нужно использовать связанную таблицу, поэтому она не будет выполнять JOINs для нас, когда они нам нужны.
  • ORM дает нам неверное ощущение легкости, и мы не всегда понимаем то, что мы делаем, что может создавать большую нагрузку на сервер. У нас нет простого способа узнать, что доступ к атрибуту в объекте может вызвать дополнительный запрос к базе данных, который можно было бы предотвратить с помощью JOIN.

Чтобы преодолеть недостатки, нужно поближе познакомиться с ORM и понять как она работает под капотом.

Разбираемся как работает ORM

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

Вот несколько механизмов проверки запросов SQL по мере их выполнения:

1. connection.queries

Когда установлен debug = True, можно получить доступ к запросам, которые были выполнены, через connection.queries:

>>> from django.db import connection
>>> Post.objects.all()
>>> connection.queries
[
   {
      'sql': 'SELECT "blogposts_post"."id", "blogposts_post"."title", '
             '"blogposts_post"."content", "blogposts_post"."blog_id", '
             '"blogposts_post"."published" FROM "blogposts_post" LIMIT 21',
      'time': '0.000'
   }
]

connection.queries содержит список SQL-запросов в виде словарей, содержащих код SQL и время выполнения.

Со времени список запросов может легко вырасти в большой словарь с которым будет сложно разобраться. Чтобы это исправить, Django предоставляет способ очистки:

>>> from django.db import reset_queries
>>> reset_queries()

2. shell_plus –print-sql

Существует хорошая библиотека django-extensions, которая имеет несколько полезных функций.

Одна из них shell_plus. Это оболочка Django с дополнительными расширениями. Если вы вызываете ее с параметром —print-sql, то будут отображаться SQL-запросы по мере их выполнения при запуске вашего кода.

Я буду использовать shell_plus в этом посте, чтобы вы могли видеть запросы SQL, которые выполняются по мере выполнения кода.

Вот быстрый пример того, как будет выглядеть вывод:

$ ./manage.py shell_plus --print-sql
>>> post = Post.objects.get(id=1)
SELECT "blogposts_post"."id",
     	"blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

3. django-silk

Так же существует инструмент профилирования django-silk (https://github.com/jazzband/django-silk). Он перехватывает запросы, записывает выполненные SQL-запросы и предоставляет способ их визуализации.

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

4. django-debug-toolbar

Библиотека django-debug-toolbar (https://github.com/jazzband/django-debug-toolbar) добавляет панель инструментов в ваш браузер, которая покажет вам много отладочной информации во время работы вашего проекта. Используя его, вы можете увидеть количество запросов SQL, которые были выполнены. Также возможно проверить эти запросы, проверить код SQL и посмотреть, в каком порядке они выполнялись и сколько времени занимал каждый из них.

Оптимизируем наши запросы

Используемый пример модели базы данных

Мы будем использовать следующие модели в качестве примера:

class Blog(models.Model):
   name = models.CharField(max_length=250)
   url = models.URLField()

   def __str__(self):
       return self.name


class Author(models.Model):
   name = models.CharField(max_length=250)
   email = models.EmailField()

   def __str__(self):
       return self.name


class Post(models.Model):
   title = models.CharField(max_length=250)
   content = models.TextField()
   published = models.BooleanField(default=False)

   blog = models.ForeignKey(Blog, on_delete=models.CASCADE)
   authors = models.ManyToManyField(Author, related_name="posts")

   def __str__(self):
       return self.title

Используйте кэшированные Foreign Key id

Если нам просто нужно получить id поля ForeignKey, мы можем использовать кэшированный id, который Django уже кешировал для нас, через <field_name>_id.

Давайте посмотрим на это на примере:

>>> Post.objects.first().blog.id
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

Execution time: 0.001668s [Database: default]
SELECT "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_blog"
WHERE "blogposts_blog"."id" = 1
LIMIT 21

Execution time: 0.000197s [Database: default]

При обращении к id блога через вложенный объект blog будет сгенерирован новый запрос SQL для получения всего объекта blog. Но так как нам не потребуется доступ к какому-либо другому атрибуту из объекта blog, мы можем полностью избежать выполнения вышеуказанного запроса, выполнив:

>>> Post.objects.first().blog_id
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

Execution time: 0.000165s [Database: default]

Дайте Django знать, что вам нужно заранее

Django не может предвидеть, когда нам потребуется доступ к отношениям ForeignKey из модели, к которой мы обращаемся. Функция select_related позволяет нам точно указать Django, какие связанные модели мы хотим, чтобы он мог заранее выполнить JOINs.

В нашем примере у нас есть модель Post. Post принадлежит определенному Blog. Это отношение представлено в базе данных через ForeignKey из Post в Blog.

Чтобы получить доступ к определенному объекту Post, мы можем сделать следующее:

>>> post = Post.objects.get(id=1)
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
ORDER BY "blogposts_post"."id" ASC
LIMIT 1

Если мы хотим получить доступ к объекту Blog из Post, мы можем сделать так:

>>> post.blog
SELECT "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_blog"
WHERE "blogposts_blog"."id" = 1
LIMIT 21

Execution time: 0.000602s [Database: default]
<Blog: Rocio's Blog>

Однако этот оператор сгенерировал новый запрос, чтобы получить информацию из blog. Но этого лучше избегать, особенно есть нужно будет обработать сотни объектов post. Для этого есть select_related. Чтобы использовать его, нам нужно обновить наш оригинальный запрос:

>>> post = Post.objects.select_related("blog").get(id=1)
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published",
      "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_post"
INNER JOIN "blogposts_blog"
   ON ("blogposts_post"."blog_id" = "blogposts_blog"."id")
WHERE "blogposts_post"."id" = 1
LIMIT 21

Execution time: 0.000150s [Database: default]

Обратите внимание, как Django использовал JOIN в SQL запросе выше, чтобы также получить атрибуты из таблицы blog. Теперь при доступе к объекту Blog из Post ему не потребуется дополнительный запрос, поскольку он уже будет кэширован:

>>> post.blog
<Blog: Rocio's Blog>

select_related также работает для наборов запросов. Мы можем предварительно выбрать объект blog для всего набора запросов. Если было 50 сообщений и мы не использовали select_related для предварительного выбора объекта блога, потребовалось бы 50 запросов Django для запуска следующего кода. С select_related он просто берет один:

>>> posts = Post.objects.select_related("blog").all()
>>> for post in posts:
       post.blog

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published",
      "blogposts_blog"."id",
      "blogposts_blog"."name",
      "blogposts_blog"."url"
 FROM "blogposts_post"
INNER JOIN "blogposts_blog"
   ON ("blogposts_post"."blog_id" = "blogposts_blog"."id")

Execution time: 0.000224s [Database: default]

prefetch_related похож на select_related, но он используется для предварительного выбора полей ManyToMany. prefetch_related работает по-другому, давайте рассмотрим это на примере.

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

>>> for post in Post.objects.all():
              post.authors.all()

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"

Execution time: 0.000158s [Database: default]
<QuerySet []>
SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_author"."id" = "blogposts_post_authors"."author_id")
WHERE "blogposts_post_authors"."post_id" = 1
LIMIT 21

Execution time: 0.000101s [Database: default]
<QuerySet []>
SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_author"."id" = "blogposts_post_authors"."author_id")
WHERE "blogposts_post_authors"."post_id" = 2
LIMIT 21
Execution time: 0.001043s [Database: default]

Execution time: 0.000101s [Database: default]
<QuerySet []>
SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_author"."id" = "blogposts_post_authors"."author_id")
WHERE "blogposts_post_authors"."post_id" = 3
LIMIT 21
Execution time: 0.001043s [Database: default]

Обратите внимание, что приведенный выше код сгенерировал 4 запроса: один для получения сообщений, а затем один запрос для каждого из сообщений для получения авторов (всего было 3 сообщения).

Это знаменитая проблема N + 1. Для получения N сообщений, будет выполнено N + 1 запросов. В этом сценарии у нас есть 3 сообщения, которые создадут 4 запроса. Это не так много, но это может очень легко усложниться, когда мы создадим больше сообщений. С 50 сообщениями этот код будет генерировать 51 запрос.

Чтобы избежать этого, мы можем предварительно выбрать авторов с помощью prefetch_related:

>>> for post in Post.objects.prefetch_related("authors").all():
              post.authors.all()

SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"

Execution time: 0.000158s [Database: default]
SELECT ("blogposts_post_authors"."post_id") AS "_prefetch_related_val_post_id",
      "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_author"."id" = "blogposts_post_authors"."author_id")
WHERE "blogposts_post_authors"."post_id" IN (1, 2, 3)

Execution time: 0.001043s [Database: default]

Теперь было выполнено только 2 запроса. Когда используется prefetch_related, Django сначала получает все сообщения Post, а затем запускает другой SQL-запрос, который извлекает всех авторов для всех сообщений.

Настройка Prefetch

В некоторых сценариях простого синтаксиса prefetch_related недостаточно, чтобы Django не выполнял дополнительные запросы. Для большего контроля предварительной выборки вы можете использовать объект предварительной выборки Prefetch.

В нашем примере базы данных есть модель Post и модель Author. Модель Post связана с моделью Author через поле ManyToMany. Допустим, мы хотели перейти от автора к автору и получить все публикации, которые были опубликованы этим автором:

>>> authors = Author.objects.all()
>>> for author in authors:
              print(author.posts.filter(published=True))

SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"

Execution time: 0.000251s [Database: default]
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE ("blogposts_post_authors"."author_id" = 1 AND "blogposts_post"."published" = 1)
LIMIT 21

Execution time: 0.000178s [Database: default]
<QuerySet [<Post: Optimizing Django ORM Queries>, <Post: Placeholder Post>, <Post: Placeholder Post 2>, <Post: Placeholder Post 3>, <Post: Placeholder Post 4>, <Post: Placeholder Post 6>, <Post: Placeholder Post 7>, <Post: Placeholder Post 8>, <Post: Placeholder Post 9>]>
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE ("blogposts_post_authors"."author_id" = 2 AND "blogposts_post"."published" = 1)
LIMIT 21

Execution time: 0.000081s [Database: default]
<QuerySet [<Post: Optimizing Django ORM Queries>]>

Как видите, в приведенном выше коде было сгенерировано 3 запроса: 1 для получения всех авторов, а затем 2 запроса для получения сообщений для каждого из авторов.

Что если мы использовали prefetch_related? Кажется, это правильно:

>>> authors = Author.objects.prefetch_related("posts").all()
>>> for author in authors:
       print(author.posts.filter(published=True))

SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"

Execution time: 0.000097s [Database: default]
SELECT ("blogposts_post_authors"."author_id") AS "_prefetch_related_val_author_id",
      "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE "blogposts_post_authors"."author_id" IN (1, 2)

Execution time: 0.000190s [Database: default]
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE ("blogposts_post_authors"."author_id" = 1 AND "blogposts_post"."published" = 1)
LIMIT 21

Execution time: 0.000074s [Database: default]
<QuerySet [<Post: Optimizing Django ORM Queries>, <Post: Placeholder Post>,
<Post: Placeholder Post 2>, <Post: Placeholder Post 3>,
<Post: Placeholder Post 4>, <Post: Placeholder Post 6>,
<Post: Placeholder Post 7>, <Post: Placeholder Post 8>,
<Post: Placeholder Post 9>]>
SELECT "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE ("blogposts_post_authors"."author_id" = 2 AND "blogposts_post"."published" = 1)
LIMIT 21

Execution time: 0.000070s [Database: default]
<QuerySet [<Post: Optimizing Django ORM Queries>]>

Что только что произошло? Мы использовали prefetch_related, чтобы уменьшить количество запросов, но мы фактически увеличили его на 1.

Это происходит потому, что мы фильтруем сообщения с published = True. Django не может использовать наши кэшированные сообщения, так как они не были отфильтрованы при запросе. Чтобы этого не происходило, мы можем настроить набор запросов, используя объект Prefetch:

>>> authors = Author.objects.prefetch_related(
       Prefetch(
          "posts",
          queryset=Post.objects.filter(published=True),
          to_attr="published_posts",
       )
    )
>>> for author in authors:
      print(author.published_posts)

SELECT "blogposts_author"."id",
      "blogposts_author"."name",
      "blogposts_author"."email"
 FROM "blogposts_author"

Execution time: 0.000129s [Database: default]
SELECT ("blogposts_post_authors"."author_id") AS "_prefetch_related_val_author_id",
      "blogposts_post"."id",
      "blogposts_post"."title",
      "blogposts_post"."content",
      "blogposts_post"."blog_id",
      "blogposts_post"."published"
 FROM "blogposts_post"
INNER JOIN "blogposts_post_authors"
   ON ("blogposts_post"."id" = "blogposts_post_authors"."post_id")
WHERE ("blogposts_post"."published" = 1 AND "blogposts_post_authors"."author_id" IN (1, 2))

Execution time: 0.000089s [Database: default]
[<Post: Optimizing Django ORM Queries>, <Post: Placeholder Post>,
<Post: Placeholder Post 2>, <Post: Placeholder Post 3>,
<Post: Placeholder Post 4>, <Post: Placeholder Post 6>,
<Post: Placeholder Post 7>, <Post: Placeholder Post 8>,
<Post: Placeholder Post 9>]
[<Post: Optimizing Django ORM Queries>]

Мы использовали объект Prefetch, чтобы сообщить Django:

  • Использовать определенный queryset для получения сообщений — через параметр queryset.
  • Сохранять отфильтрованные записи в новом атрибуте (published_posts) — через параметр to_attr.

Когда выполняется author.published_posts, новые запросы не будут создаваться, поскольку все уже будет кэшировано. Независимо от количества авторов в нашей системе, операция всегда будет генерировать 2 SQL запроса.

Заключение

Работая с Django ORM, очень важно думать о том, что происходит под капотом.

Концепции, которые вы узнали из этого поста в блоге, помогут вам написать более оптимизированные запросы и искать возможные варианты оптимизации при просмотре кода. Однако следует помнить, что вы всегда должны обращать внимание на время, которое занимает запрос до и после оптимизации, чтобы убедиться, что оптимизация работает. Иногда меньшее количество запросов не обязательно означает меньшее время, JOIN также могут быть медленными.

Была ли вам полезна эта статья?
[26 / 4.8]

Spread the love
Подписаться
Уведомление о
guest
2 Комментарий
Oldest
Newest Most Voted
Inline Feedbacks
View all comments
Катя
4 лет назад

Спасибо за полезную статью, в данный момент я тоже рассматриваю ORM.

Рамиль
Рамиль
1 год назад

Очень полезная и понятная статья, спасибо большое!