Оптимизация запросов Django ORM
Оригинальная статья: 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 знать, что вам нужно заранее
Используйте select_related для ForeignKey
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 для полей ManyToMany
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 также могут быть медленными.
Спасибо за полезную статью, в данный момент я тоже рассматриваю ORM.
Очень полезная и понятная статья, спасибо большое!