Проблемы с производительностью веб-приложений чаще всего возникают из-за базы данных: чем больше данных запрашивают пользователи, тем дольше выполняется запрос, а значит, тем медленнее работает вся система.
Чтобы ускорить работу с базой данных в Django, можно использовать методы defer(), only() и exclude(), которые помогают уменьшить объем извлекаемой информации. Далее мы разберемся, как они работают, но сначала обсудим особенности проекта и сделаем тест исходной производительности приложения.
Настройки проекта
В качестве примера мы возьмем веб-приложение для агентства недвижимости. В нем есть две модели: Property (недвижимость) и Location (местоположение).
Файл models.py:
class Location(models.Model):
city = models.CharField(max_length=128)
state = models.CharField(max_length=128)
country = models.CharField(max_length=32)
zip_code = models.CharField(max_length=32)
# ...
class Property(models.Model):
name = models.CharField(max_length=256)
description = models.TextField()
property_type = models.CharField(max_length=20, choices=PROPERTY_TYPES)
location = models.ForeignKey(Location, on_delete=models.CASCADE)
square_feet = models.PositiveIntegerField()
bedrooms = models.PositiveSmallIntegerField()
bathrooms = models.PositiveSmallIntegerField()
has_garage = models.BooleanField(default=False)
has_balcony = models.BooleanField(default=False)
# ...
Оба модели содержат несколько полей и включают метод to_json()
, который сериализует все атрибуты модели в словарь Python. В приложении реализованы следующие API-эндпоинты:
/
— возвращает короткий список всех объектов недвижимости./<int:id>/
— возвращает подробную информацию о конкретном объекте./<int:id>/amenities/
— возвращает список удобств (бассейн, балкон и т.п.) для конкретного объекта.
Тест производительности
Прежде чем оптимизировать производительность веб-приложения, важно измерить, насколько быстро оно работает в текущем состоянии. Для этого мы воспользуемся Django Silk — инструментом для измерения времени выполнения HTTP-запросов и запросов к базе данных.
Запустите сервер и выполните HTTP-запросы к API в браузере или через curl:
- http://127.0.0.1:8000/
- http://127.0.0.1:8000/1/
- http://127.0.0.1:8000/1/amenities/
Просмотрите отчеты на странице Silk — http://127.0.0.1:8000/silk/requests/.
В нашем случае результаты выглядят так:
- Формирование списка объектов
/
занимает 765 мс, из них 301 мс тратится на запросы к базе данных. - Выдача данных по деталям
/1/
и удобствам/1/amenities/
объекта выполняется за 11 мс (из них ~1 мс на базу).
Если посмотреть SQL-запросы, видно, что все три эндпоинта запрашивают все поля из таблицы Property, даже если они не используются в выводе.
SQL-запрос для списка объектов
: /
SELECT * FROM "estates_property"
INNER JOIN "estates_location"
ON "estates_property"."location_id" = "estates_location"."id";
SQL-запрос для деталей /1/
:
SELECT * FROM "estates_property"
INNER JOIN "estates_location"
ON "estates_property"."location_id" = "estates_location"."id"
WHERE "estates_property"."id" = 1;
SQL-запрос для удобств /1/amenities/
:
SELECT * FROM "estates_property"
WHERE "estates_property"."id" = 1;
Что здесь можно улучшить:
- Список объектов
/
загружает все поля, хотя нужен только небольшой их набор. - Запрос на удобства
/1/amenities/
получает всю информацию об объекте, хотя сам объект уже известен. - Детальный запрос
/1/
также загружает все поля, даже если некоторые из них можно исключить при первичном отображении.
Чтобы ускорить выполнение запросов, мы можем:
- Использовать only() или defer(), чтобы загружать только нужные поля.
- Применить select_related() и prefetch_related() для оптимизации JOIN-запросов.
- Проверить, нужны ли все загружаемые данные.
Далее мы разберем, как внедрить эти оптимизации.
Метод defer() в Django
Метод defer() позволяет исключить определенные поля при выполнении запроса к базе данных. Это полезно, когда нам не нужны все поля модели, и мы хотим ускорить запрос, загружая только необходимые данные.
Как работает defer()
Когда мы выполняем запрос к базе данных, Django по умолчанию загружает все поля модели:
-- Без defer(): загружаются все колонки
SELECT * FROM some_table;
Если мы используем defer(), Django исключает указанные поля и загружает только оставшиеся:
-- С defer(): исключены некоторые колонки
SELECT column_1, column_2, ... column_n FROM some_table;
Допустим, у нас есть список объектов недвижимости, но мы не хотим загружать описание description
, так как оно может содержать длинный текст.
Стандартный запрос без defer()
Этот запрос загружает все поля, включая description
, что может замедлять выполнение:
properties = Property.objects.select_related("location")
Оптимизированный запрос с defer():
properties = Property.objects.select_related("location").defer("description")
Если теперь выполнить тест производительности, можно заметить, что использование defer("description")
уменьшило время выполнения запроса с 765 мс до 184 мс, т.е. в 4 раза!
Когда использовать defer():
- Для текстовых
TextField
и JSON-полей, которые содержат много данных. - Если поле редко используется, а его загрузка замедляет запрос.
- При создании списков объектов, когда не нужны все детали.
Метод only() в Django
Метод only() — это противоположность метода defer(). Вместо того, чтобы исключать определенные поля, он позволяет явно указать, какие поля нужно загрузить из базы данных. Использовать only() полезно, когда нам нужен только небольшой набор полей, а загружать остальные не имеет смысла.
Как работает only()
Обычный SQL-запрос без only() загружает все колонки таблицы:
-- Без only(): загружаются все колонки
SELECT * FROM some_table;
А с only() загружаются только указанные колонки:
-- С only(): загружаются только нужные колонки
SELECT only_column1, only_column2, ... only_column_n FROM some_table;
Это позволяет уменьшить объем данных, загружаемых из базы, и ускорить выполнение запроса.
Допустим, у нас есть список объектов недвижимости, но для вывода в списке нам нужны только ID, название name
, месторасположение location
и цена price
.
Стандартный запрос без only()
Этот запрос загружает все поля модели Property, даже если они нам не нужны:
properties = Property.objects.select_related("location")
Оптимизированный запрос с only():
properties = Property.objects.select_related("location").only(
"id", "name", "location", "price"
)
Теперь Django будет загружать только указанные поля. Тест показывает, что время выполнения запроса уменьшилось с 184 мс до 154 мс, на 30 мс быстрее по сравнению с defer().
Дополнительные возможности only():
- Можно загружать только определенные поля из связанных моделей. Если нам нужен только город
city
из модели Location, можно сделать так:
properties = Property.objects.select_related("location").only(
"id", "name", "location__city", "price"
)
Когда использовать only(), а когда defer()
Лучший вариант зависит от конкретного запроса и структуры модели:
- only() полезен, когда нам нужны только несколько полей.
- defer() лучше, если мы знаем, какие поля исключить.
Метод exclude() в Django
Метод exclude() — это противоположность метода filter(). Если filter() выбирает объекты, соответствующие условиям, то exclude() исключает их из выборки.
Важно отметить, что defer() и only() работают на уровне полей (столбцов), а exclude() применяется на уровне строк, отфильтровывая ненужные записи.
Как работает exclude()
Допустим, у нас есть модель недвижимости Property, и мы хотим отобрать все квартиры:
apartments = Property.objects.filter(property_type=PROPERTY_TYPE_APARTMENT)
Этот код выполняет SQL-запрос:
SELECT * FROM "estates_property" WHERE ("estates_property"."property_type" = AP);
А теперь воспользуемся exclude() для выбора всех объектов, которые НЕ являются квартирами:
non_apartments = Property.objects.exclude(property_type=PROPERTY_TYPE_APARTMENT)
Теперь код будет выполнять этот SQL-запрос, и в выборке будут все объекты, кроме квартир:
SELECT * FROM "estates_property" WHERE NOT ("estates_property"."property_type" = AP);
Комбинирование условий с exclude()
Как и filter(), exclude() можно использовать с несколькими условиями. Допустим, нам нужны все здания, которые:
- Не являются земельными участками.
- Имеют площадь более 1000 квадратных футов.
big_buildings = (
Property.objects.
exclude(property_type=PROPERTY_TYPE_LAND, square_feet__lt=1000)
)
SQL-запрос будет таким:
SELECT * FROM "estates_property" WHERE NOT ("estates_property"."property_type" = LAND AND "estates_property"."square_feet" < 1000);
Когда использовать exclude():
- Когда нужно убрать ненужные записи из выборки.
- Когда удобнее убрать лишние записи, чем искать нужные с помощью filter().
- При сложных фильтрациях (например, выбрать все, кроме определенных категорий).
Подводные камни defer() и only() в Django
Методы defer() и only() помогают ускорить запросы, но неправильное их использование может сильно ухудшить производительность.
Главная проблема: хотя defer() и only() исключают поля из первоначального SQL-запроса, Django не запрещает доступ к этим полям в дальнейшем. Если позже в коде обратиться к исключенным полям, Django создаст дополнительные SQL-запросы, и в итоге приложение будет работать медленнее, чем если бы все данные загрузились сразу.
Пример ошибки с only()
В представлении property_amenities_view()
сначала запрашиваются только нужные поля:
def property_amenities_view(request, id):
property = Property.objects.only(
"id", "has_garage", "has_balcony", "has_basement", "has_pool"
).get(id=id)
return JsonResponse({
"id": property.id,
"has_garage": property.has_garage,
"has_balcony": property.has_balcony,
"has_basement": property.has_basement,
"has_pool": property.has_pool,
})
SQL-запрос выглядит так:
SELECT "estates_property"."id",
"estates_property"."has_garage",
"estates_property"."has_balcony",
"estates_property"."has_basement",
"estates_property"."has_pool"
FROM "estates_property" WHERE "estates_property"."id" = 1;
Здесь все оптимизировано: загружаются только нужные поля, ничего лишнего. Но если мы добавим новые поля, например, спальни bedrooms
и ванные bathrooms
, Django сделает дополнительные SQL-запросы для их загрузки:
def property_amenities_view(request, id):
property = Property.objects.only(
"id", "has_garage", "has_balcony", "has_basement", "has_pool"
).get(id=id)
return JsonResponse({
"id": property.id,
"bedrooms": property.bedrooms, # Новое поле
"bathrooms": property.bathrooms, # Новое поле
"has_garage": property.has_garage,
"has_balcony": property.has_balcony,
"has_basement": property.has_basement,
"has_pool": property.has_pool,
})
Теперь SQL-запросы будут такими:
-- Запрос на изначально выбранные поля
SELECT "estates_property"."id",
"estates_property"."has_garage",
"estates_property"."has_balcony",
"estates_property"."has_basement",
"estates_property"."has_pool"
FROM "estates_property" WHERE "estates_property"."id" = 1;
-- ❌ Дополнительный запрос для bedrooms
SELECT "estates_property"."id",
"estates_property"."bedrooms"
FROM "estates_property" WHERE "estates_property"."id" = 1;
-- ❌ Дополнительный запрос для bathrooms
SELECT "estates_property"."id",
"estates_property"."bathrooms"
FROM "estates_property" WHERE "estates_property"."id" = 1;
Теперь у нас выполняется три запроса вместо одного, и производительность ухудшилась.
Как избежать этой ошибки:
- При использовании only() заранее учитывайте, какие поля понадобятся.
- Если есть вероятность, что к полю могут обратиться позже — лучше сразу включить его в запрос.
- Следите за SQL-запросами в Django Silk или Django Debug Toolbar, они помогут вовремя заметить неожиданные дополнительные запросы.
- Используйте
django_assert_num_queries
в тестах — это позволит автоматически обнаруживать появление новых SQL-запросов.
Подведем итоги
Чтобы ускорить запросы в Django, нужно минимизировать объем данных, которые загружаются из базы. Это касается и строк (объектов модели), и столбцов (атрибутов модели):
- Для минимизации загружаемых атрибутов используйте defer() и only().
- Для исключения ненужных объектов используйте exclude().
Важно также следить за SQL-запросами (с помощью Django Silk или Django Debug Toolbar) и не допускать ошибок, которые могут привести к увеличению их количества.
Комментарии
properties = Property.objects.only("id", "name").defer("description")
Можно узнать логику этого запроса? Description ведь итак нет в запросе.