Часто определение разницы между select_related и prefetch_related звучит как “первый для ForeignKey полей, второй для ManyToMany”, однако это описание не раскрывает суть работы этих методов. Ниже я попробовал на примерах показать разницу между этими методами и какое влияние они оказывают на сгенерированный SQL для получения данных.

TLDR: Статья будет в первую очередь полезна тем кто начинает свое знакомство с Django, а также тем, кто использует select_related/prefetch_related в ежедневной работе, но не углублялся в глубь Django.

Перед тем как пойти дальше, небольшое описание моделей, которые будут использовать для примеров кода
from django.db import models


class Department(models.Model):
    name = models.CharField(max_length=64)
    description = models.TextField()


class Country(models.Model):
    name = models.CharField(max_length=64)


class City(models.Model):
    name = models.CharField(max_length=64)
    country = models.ForeignKey(Country, on_delete=models.CASCADE,
                                related_name="cities")

class Employee(models.Model):
    first_name = models.CharField(max_length=64)
    last_name = models.CharField(max_length=64)

    department = models.ForeignKey(Department, on_delete=models.CASCADE,
                                   related_name="employees")
    email = models.EmailField()
    city = models.ForeignKey(City, on_delete=models.CASCADE,
                             related_name="employees")

Какую проблему решают select_related/prefetch_related

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

Например мы хотим вывести на экран список сотрудников и отдел в котором они работают:

for employee in Employee.objects.all():
    print(employee.id, employee.first_name, employee.last_name, employee.department.name)

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

Решение проблемы с помощью select_related

Чтобы решить проблему выше, мы можем использовать select_related. Метод select_related загружает связанные объекты используя JOIN.

Как будет выглядеть запрос:

for employee in Employee.objects.all().select_related("department"):
    print(employee.id, employee.first_name, employee.last_name, employee.department.name)

SQL запрос в данном примере будет выглядеть примерно вот так:

SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_employee"."department_id",
       "employee_employee"."email",
       "employee_employee"."city_id",
       "employee_department"."id",
       "employee_department"."name",
       "employee_department"."description"
FROM "employee_employee"
INNER JOIN "employee_department" ON ("employee_employee"."department_id" = "employee_department"."id")

Теперь при обращении к атрибуту department не будет создаваться дополнительный запрос в базу.

В качестве параметров select_related принимает имена ForeignKey/OneToOne полей или related_name поля OneToOne в связанной таблице. Также можно передавать имена полей в связанных через отношение внешнего ключа таблицах, например:

Employee.objects.all().select_related("city", "city__country")
# или вот так
Employee.objects.all().select_related("city").select_related("city__country")
# или вот так
Employee.objects.all().select_related("city__country")

В последнем случае данные для поля city также будут загружены.

В качестве параметра можно передать None, чтобы очистить список связанных сущностей которые нужно загрузить вместе с основным запросом. Например в данном случае вместе с основным запросом будут загружены данные только из модели City:

Employee.objects.all()
    .select_related("city", "city__country")
    .select_related(None)
    .select_related("city")

Минус select_related

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

Например результатом вот такого запроса:

Employee.objects.all().select_related("city", "city__country")

Может быть вот такая таблица:

id

email

department_id

department__name

1

ymiller@example.net

14

Отдел строительства

2

kevin68@example.org

16

Отдел планирования

3

ppetersen@example.com

16

Отдел планирования

4

omartinez@example.com

16

Отдел планирования

5

ingramjoel@example.com

16

Отдел планирования

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

Альтернативный подход - prefetch_related

В отличие от select_related, prefetch_related загружает связанные объекты отдельным запросом для каждого поля переданного в качестве параметра и производит связывание объектов внутри python.

Такой подход позволяет загружать загружать объекты для ManyToMany полей и записи которые ссылаются на нашу таблицу через ForeignKey поле используя related_name.

Однако prefetch_related можно также использовать там, где мы используем select_related, чтобы загрузить связанные записи используя дополнительный запрос, вместо JOIN.

Используя prefetch_related, можно ускорить выполнение запроса из прошлого примера:

Employee.objects.all().prefetch_related("city", "city__country")

Результатом будет вот такой SQL:

SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_employee"."department_id",
       "employee_employee"."email",
       "employee_employee"."city_id"
FROM "employee_employee"

SELECT "employee_city"."id",
       "employee_city"."name",
       "employee_city"."country_id"
FROM "employee_city"
WHERE "employee_city"."id" IN (22, 23, 25, 26, 27, 28)

SELECT "employee_country"."id",
       "employee_country"."name"
FROM "employee_country"
WHERE "employee_country"."id" IN (4, 5, 6)

В итоге получилось 3 запроса: 1 для получения данных из основной модели, один для получения данных из модели City и один для получения данных из таблицы Country.

Не смотря на то что было выполнено 3 запроса, суммарное время может получиться меньше, чем время выполнения с использованием select_related в некоторых случаях. Главный повод задуматься об использовании prefetch_related вместо select_related это большое количество повторений в таблице которая присоединяется через JOIN.

Пример с загрузкой объектов которые ссылаются на основную модель: для каждого отдела выводим список сотрудников, используем prefetch_related для оптимизации:

for department in Department.objects.all().prefetch_related("employees"):
    print(department.name, "":")
    for employee in department.employees.all():
        print("    ", employee.first_name, employee.last_name)

Сгенерированный SQL:

SELECT "employee_department"."id",
       "employee_department"."name",
       "employee_department"."description"
FROM "employee_department"

SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_employee"."department_id",
       "employee_employee"."email",
       "employee_employee"."city_id"
FROM "employee_employee"
WHERE "employee_employee"."department_id" IN (13, 14, 15, 16)

Интересный момент, в случае такого комбинированного QuerySet, в итоге выполнится 2 запроса в базу, так как Django определит что объекты для отношения city были загружены через select_related:

Employee.objects.all().select_related("city").prefetch_related("city__country")
SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_employee"."department_id",
       "employee_employee"."email",
       "employee_employee"."city_id",
       "employee_city"."id",
       "employee_city"."name",
       "employee_city"."country_id"
FROM "employee_employee"
INNER JOIN "employee_city" ON ("employee_employee"."city_id" = "employee_city"."id")

SELECT "employee_country"."id",
       "employee_country"."name"
FROM "employee_country"
WHERE "employee_country"."id" IN (4, 5, 6)

Аналогично select_related, список отношений для загрузки можно очистить:

Employee.objects.all().prefetch_related("city", "city__country").prefetch_related(None)

Дополнительные оптимизации, объект Prefetch

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

Объект Prefetch получает на вход 3 параметра:

  • lookup для поиска отношения, аналогично строке которую мы передаем в случае если не используем объект Prefetch;

  • queryset, опциональный параметр для настройки QuerySet который будет использоваться для загрузки связанных объектов;

  • to_attr, опциональный параметр с помощью которого можно изменить поле в которое будут загружены связанные объекты, загруженные таким образом объекты будут собраны в список, а не объект QuerySet.

Главное ограничение на передаваемый queryset, это запрет на использование методов values и values_list, так как в таком случае результирующим объектом перестанет быть экземпляр модели, однако можно применять фильтрации и аннотации как и к обычному queryset.

В качестве примера загрузим список сотрудников для каждого департамента, которые живут в определенной стране и используем аннотацию для добавления информации о городе, а также исключим поле email, которое мы не используем в нашем скрипте:

employees = Employee.objects.defer("email")
    .filter(city__country_id=4)
    .annotate(city_name=F("city__name"))

queryset = Department.objects.all().prefetch_related(Prefetch("employees", queryset=employees))

for department in queryset:
    print(department.name, ":")
    for employee in department.employees.all():
        print("    ", employee.first_name, employee.last_name, employee.city_name)
SELECT "employee_department"."id",
       "employee_department"."name",
       "employee_department"."description"
FROM "employee_department"

SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_employee"."department_id",
       "employee_employee"."city_id",
       "employee_city"."name" AS "city_name"
FROM "employee_employee"
INNER JOIN "employee_city" ON ("employee_employee"."city_id" = "employee_city"."id")
WHERE ("employee_city"."country_id" = 4
       AND "employee_employee"."department_id" IN (13, 14, 15, 16))

Как случайно поломать оптимизацию

Используя prefetch_related нужно помнить о том что мы работаем с объектами QuerySet, чтобы избежать неожиданного поведения.

Фильтрация загруженных объектов приведет к инвалидации кеша на уровне объекта QuerySet, а значит приведет к дополнительному запросу.

В примере ниже будет выполнен дополнительный запрос для каждого объекта department:

for department in Department.objects.all().prefetch_related("employees"):
    print(department.name, ":")
    for employee in department.employees.filter(city__country_id=4):
        print("    ", employee.first_name, employee.last_name)

Так как в случае prefetch_related связывание объектов происходит на уровне python, исключение поля содержащего ссылку на основную модель с помощью методов only/defer приведет к дополнительным запросом для получения этого поля для каждого загруженного объекта в момент связывания.

Например в случае такого скрипта:

employees = Employee.objects.only("first_name", "last_name")
    .filter(city__country_id=4)
    .annotate(city_name=F("city__name"))

queryset = Department.objects.all().prefetch_related(Prefetch("employees", queryset=employees))

for department in queryset:
    print(department.name, ":")
    for employee in department.employees.all():
        print("    ", employee.first_name, employee.last_name, employee.city_name)

Будет сделано 2 запроса для загрузки объектов Department и Employee

SELECT "employee_department"."id",
       "employee_department"."name",
       "employee_department"."description"
FROM "employee_department"

SELECT "employee_employee"."id",
       "employee_employee"."first_name",
       "employee_employee"."last_name",
       "employee_city"."name" AS "city_name"
FROM "employee_employee"
INNER JOIN "employee_city" ON ("employee_employee"."city_id" = "employee_city"."id")
WHERE ("employee_city"."country_id" = 4
       AND "employee_employee"."department_id" IN (13, 14, 15, 16))

и множество одинаковых запросов для получения department_id:

SELECT "employee_employee"."id",
       "employee_employee"."department_id"
FROM "employee_employee"
WHERE "employee_employee"."id" = 63265
LIMIT 21

Заключение

В заключение хочу сказать что описанные выше примеры покрывают основные случаи использования select_related/prefetch_related.

Более детальное описание методов и объекта Prefetch можно найти в документации:

https://docs.djangoproject.com/en/4.2/ref/models/querysets/#select-related

https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-related

https://docs.djangoproject.com/en/4.2/ref/models/querysets/#prefetch-objects

Комментарии (0)