Программирование
February 20

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

Сегодня хочу рассказать про методы select_related и prefetch_related: показать разницу между ними, привести примеры использования.

Кратко

Если говорить совсем кратко, то: select_related — это JOIN, то есть происходит 1 запрос, в рамках которого связываются указанные сущности и приходят в python уже связанными, в то время, как prefetch_related — делает отдельные запросы по каждой указанной связанной сущности и производит связывание на уровне python.

В документации django сказано, что select_related стоит применять в тех случаях, когда вы используете ForeignKey, если вы используете ManyToManyField, ваш выбор — это prefetch_related.

Разбираем на примерах

Предположим, что наши модели выглядят следующим образом:

from django.db import models

class Genre(models.Model):
 name = models.CharField(max_length=512)

class City(models.Model):
    ...

class Person(models.Model):
    hometown = models.ForeignKey(
        City,
    )

class Book(models.Model):
    name = models.CharField(max_length=512)

    author = models.ForeignKey(Person, on_delete=models.CASCADE)

    genres = models.ManyToManyField(Genre)

Перед нами поставлена задача: получить информацию о книге, её авторе и его родном городе.

Код для решения этой задачи с select_related и без:

# Отправим запрос в БД, чтобы присоединить (выполнить JOIN) связанные записи по таблицам author и hometown.
book = Book.objects.select_related("author__hometown").get(id=4)
author = book.author  # Дополнительный запрос не будет отправлен.
city = author.hometown  # Дополнительный запрос не будет отправлен.

# Без использования select_related()...
book = Book.objects.get(id=4)  # Запрос будет отправлен.
author = book.author  # Запрос будет отправлен.
city = author.hometown  # Запрос будет отправлен.

Таким образом, при использовании select_related, мы можем отправить один запрос вместо трёх.

⚠️ В случае, если вы получаете объект Queryset, к которому уже были применены select_related и вы хотите от них избавиться, вам следует использовать select_related(None).

Кроме этой информации, у каждой книги может быть несколько жанров, следующая задача, которую нам предстоит решить: реализовать вывод названия книги и перечисление всех её жанров в строковом описании объекта модели. Реализация довольно простая и никаких проблем не вызывает:

from django.db import models

class Genre(models.Model):
    name = models.CharField(max_length=512)

class Book(models.Model):
    name = models.CharField(max_length=512)
    genres = models.ManyToManyField(Genre)

    def __str__(self):
        return f"{self.name} ({', '.join(genre.name for genre in self.genres.all())})"

Где же тут может понадобиться prefetch_related? Всё очень просто. Чаще всего, строковое представление помогает визуально отобразить в админ-панели понятный список объектов, в таком случае будет выполняться следующий запрос:

Book.objects.all()

Результатом выполнения такого запроса будет генерация дополнительного запроса к БД для получения списка жанров по каждой книге.

Исправить это можно, как раз, с помощью prefetch_related:

Book.objects.prefetch_related("genres")

В этом случае мы отправим всего 2 запроса:

  1. получение списка книг;
  2. получение списка всех жанров, связанных с книгами из полученного списка книг.

⚠️ В случае, если вы получаете объект Queryset, к которому уже были применены prefetch_related и вы хотите от них избавиться, вам следует использовать prefetch_related(None).

Подробности и подводные камни

Инвалидация кэша

⚠️ При использовании prefetch_related, важно помнить о такой вещи, как инвалидация кэша на уровне Queryset (инвалидация может быть вызвана любым дополнительным методом в цепочке, который подразумевает другой запрос к базе данных). Например, при использовании фильтрации в следующем примере, будут выполнены отдельные запросы для каждого объекта Book:

for book in Book.objects.prefetch_related("genres"):
    print(book.name, ":")
    for genre in Book.genres.filter(age_rating="18+"):
        print("    ", genre.name)

Объект Prefetch

Также, стоит упомянуть и о существовании объекта Prefetch, который позволяет использовать подготовленный Queryset вместе с prefetch_related. Например, мы хотим отобрать заранее только те жанры, которые имеют возрастной рейтинг "18+" и посчитать количество книг по каждому такому жанру.

Код для решения этой задачи выглядит следующим образом:

genres = Genre.objects.filter(age_rating="18+")
    .annotate(books_count=Count("book_set"))

queryset = Book.objects.all().prefetch_related(
    Prefetch("genres", queryset=genres)
)

Функция prefetch_related_objects

В случае, если вы получаете не объект Queryset, а коллекцию экземпляров моделей, вы всё ещё можете использовать prefetch_related посредством обращения к функции prefetch_related_objects, например:

from django.db.models import prefetch_related_objects

books = fetch_top_rated_books_from_cache()
prefetch_related_objects(books, "genres")

Источники

  1. Статья с хабра, в которой всё доходчиво объясняют: https://habr.com/ru/articles/752574/
  2. Документация Django: https://docs.djangoproject.com/en/5.0/ref/models/querysets/