Часто определение разницы между 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 |
department_id |
department__name |
|
1 |
14 |
Отдел строительства |
|
2 |
16 |
Отдел планирования |
|
3 |
16 |
Отдел планирования |
|
4 |
16 |
Отдел планирования |
|
5 |
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