Профилирование запросов в Django: архитектурные последствия
Один эндпоинт генерирует 140 SQL-запросов на страницу, а вы узнаёте об этом, когда пользователи начинают жаловаться на задержки. Разбираем, как профилирование запросов в Django меняет не только код отдельных вьюх, но и архитектуру проекта целиком.

Почему количество запросов — архитектурная проблема, а не баг вьюхи
Django ORM удобен ровно до момента, когда проект перерастает десяток моделей с вложенными связями. Типичная картина: страница каталога со списком товаров, у каждого — категория, бренд, набор изображений, средний рейтинг. Без явного контроля ORM генерирует по одному запросу на каждый связанный объект каждой строки — классическая проблема N+1. На 50 товарах это легко даёт 150–200 обращений к базе за один HTTP-запрос.
Само по себе это не новость. Новость в том, что после профилирования команда обычно начинает не с оптимизации одного запроса, а с пересмотра того, как данные вообще собираются и передаются между слоями. Профилирование — это диагностика, но лечение часто оказывается архитектурным.
Инструменты: что показывают и чего не показывают
В экосистеме Django 4.x–5.x есть три основных способа увидеть, что происходит с запросами.
django-debug-toolbar
Панель в браузере, которая показывает каждый SQL-запрос текущего HTTP-запроса: текст, время выполнения, стек вызовов. Подходит для разработки, бесполезна в проде и при работе с API без HTML-ответа. Главное ограничение — показывает запросы по одному, без группировки по паттернам. Вы видите 140 строк, но не сразу понимаете, что 120 из них — одинаковый SELECT к одной таблице.
connection.queries и логирование
Django хранит список выполненных запросов в django.db.connection.queries, если DEBUG=True. Это можно обернуть в middleware, который пишет количество и суммарное время в лог. В проде DEBUG=True включать нельзя, но можно настроить логгер django.db.backends на уровне DEBUG для конкретного эндпоинта через feature-флаг.
Сторонние профайлеры: nplusone, django-silk, kolo
Пакет nplusone ловит N+1-запросы и кидает предупреждение или исключение прямо в тестах — удобно для CI. django-silk записывает профиль каждого запроса в базу и даёт веб-интерфейс с фильтрацией по эндпоинту и времени. kolo визуализирует вызовы прямо в VS Code. Все три инструмента добавляют накладные расходы и не предназначены для постоянной работы в продакшене.
| Инструмент | Где работает | Что показывает | Ограничения |
|---|---|---|---|
| django-debug-toolbar | Браузер, только DEBUG | Список запросов, стек, дубликаты | Не работает с DRF/JSON, не для прода |
| connection.queries + middleware | Любой ответ, только DEBUG | Количество и суммарное время | Нет группировки, нет стека |
| nplusone | Тесты, dev-сервер | N+1 конкретно | Не ловит другие паттерны |
| django-silk | Dev и staging | Полный профиль с фильтрацией | Пишет в БД, замедляет ответ |
Профилирование запросов — не разовая акция перед релизом. Это обратная связь, которая должна работать постоянно: хотя бы в CI через nplusone или ассерт на количество запросов в тестах. Иначе каждый новый сериализатор с вложенным объектом незаметно добавляет десятки обращений к базе.
Три паттерна, которые профилирование выносит на поверхность
1. N+1 в сериализаторах DRF
Самый частый случай. Сериализатор с вложенным SerializerMethodField или вложенным сериализатором обращается к связанным объектам в цикле. select_related и prefetch_related решают проблему на уровне queryset, но только если вьюха или viewset передаёт правильно подготовленный queryset. В проектах с «толстыми» сериализаторами и «тонкими» вьюхами queryset часто формируется в менеджере модели без знания о том, какие поля будут сериализоваться.
Архитектурное последствие: queryset и сериализатор должны проектироваться вместе. Если сериализатор добавляет поле — он должен знать, что queryset подготовлен. Один из подходов — метод get_queryset на viewset, который явно перечисляет все select_related и prefetch_related, а не делегирует это менеджеру.
2. Агрегации в цикле
Средний рейтинг, количество комментариев, сумма заказов — всё это часто считается в property модели или в методе сериализатора, по одному объекту за раз. Профилирование показывает десятки одинаковых SELECT COUNT(*) или SELECT AVG(...). Решение — annotate на уровне queryset. Но annotate утяжеляет сам SQL, а если аннотаций много, запрос становится нечитаемым и медленным из-за джойнов.
Архитектурное последствие: появляется выбор между «много лёгких запросов» и «один тяжёлый с аннотациями». Ни один из них не масштабируется бесконечно. На определённом объёме данных приходится выносить агрегаты в денормализованные поля или материализованные представления — а это уже другой уровень сложности миграций и консистентности.
3. Скрытые запросы в шаблонах
В проектах с серверным рендерингом (а их по-прежнему много) шаблон может обращаться к связанным объектам через точечную нотацию: {{ order.customer.city.region.name }}. Каждая точка — потенциальный запрос, если цепочка не была загружена заранее. Профилирование через django-debug-toolbar показывает эти запросы в стеке с указанием строки шаблона, и часто выясняется, что один шаблонный тег генерирует больше SQL, чем вся вьюха.
Архитектурное последствие: контекст шаблона должен содержать уже подготовленные данные, а не живые объекты ORM. Это толкает к DTO-подобным структурам или к values/only, что ограничивает гибкость шаблонов, но делает количество запросов предсказуемым.
Как профилирование меняет границы слоёв
В «наивной» архитектуре Django-проекта модель знает о данных, вьюха знает о HTTP, сериализатор знает о формате. Запросы к базе размазаны по всем трём слоям. После профилирования обычно происходит одно из двух:
- Queryset-центричный подход. Вся логика выборки, включая
select_related,prefetch_related,annotate,only, собирается в одном месте — чаще всего в кастомном менеджере или вget_querysetviewset. Сериализатор работает с уже загруженными данными и не имеет права дёргать базу. Плюс — контроль. Минус — менеджер разрастается, а разные эндпоинты требуют разных наборовprefetch. - Сервисный слой. Между вьюхой и ORM появляется функция или класс, который формирует queryset, выполняет его и возвращает простые структуры данных. Вьюха и сериализатор работают с результатом, а не с queryset. Плюс — изоляция и тестируемость. Минус — дополнительный слой абстракции, который не все в команде примут.
Оба варианта — следствие одного наблюдения: если не контролировать, где именно происходят обращения к базе, профилирование превращается в бесконечную игру «найди лишний запрос». Контроль требует архитектурного решения, а не точечного фикса.
Ассерты на количество запросов: дешёвый контракт
Django предоставляет контекстный менеджер self.assertNumQueries(n) в тестах. Это самый дешёвый способ зафиксировать контракт: эндпоинт списка товаров выполняет не больше 5 запросов, независимо от количества товаров. Если кто-то добавит вложенный сериализатор без prefetch, тест упадёт.
# Django 4.x / 5.x, стандартный TestCase
from django.test import TestCase
from myapp.models import Product, Category
class ProductListQueryTest(TestCase):
def setUp(self):
category = Category.objects.create(name="Электроника")
# Создаём 30 товаров — достаточно, чтобы N+1 себя проявил
Product.objects.bulk_create(
[Product(name=f"Товар {i}", category=category) for i in range(30)]
)
def test_list_query_count(self):
# Ожидаем фиксированное число запросов, не зависящее от количества объектов
with self.assertNumQueries(2): # 1 — товары с join, 2 — count для пагинации
response = self.client.get("/api/products/")
self.assertEqual(response.status_code, 200)
Число в assertNumQueries — не магическая константа. Оно устанавливается после первого профилирования и пересматривается, когда добавляются новые связи. Главное — тест ловит регрессии: если число выросло, разработчик разбирается, почему, до того как код попадёт в прод.
Граничные случаи и где подход ломается
- Prefetch с фильтрацией.
Prefetch-объект с кастомным queryset решает много задач, но если фильтр внутриPrefetchзависит от параметров запроса пользователя, логика быстро усложняется. ВложенныеPrefetchс условиями — частый источник багов, которые профилирование не покажет: запросов мало, но данные неправильные. - Кеширование как затычка. После профилирования соблазнительно обернуть тяжёлый queryset в кеш. Это снимает нагрузку, но скрывает архитектурную проблему. Когда кеш инвалидируется (а это отдельная тема), нагрузка возвращается. Кеш — не замена нормальному queryset.
- Асинхронные ORM-вызовы. Django 4.1+ поддерживает асинхронный ORM, но
select_relatedиprefetch_relatedработают иначе в async-контексте. Профилирование черезconnection.queriesв async-вьюхах требует аккуратности — список запросов привязан к потоку, а не к корутине. Актуальные ограничения стоит проверять в документации конкретной версии Django. - Множественные базы данных. Если проект использует реплику для чтения, профилирование через
connection.queriesпокажет запросы только к default-соединению. Для реплик нужно обращаться кconnections['replica'].queriesявно.
Сноска: N+1 — паттерн, при котором для получения данных N связанных объектов ORM выполняет 1 запрос на список «родителей» и N дополнительных запросов на связанные записи. Итого N+1 запрос вместо одного-двух с джойном или подзапросом.
Что делать дальше
Первый шаг — подключить nplusone в CI: пусть тесты падают на каждый новый N+1. Второй — добавить assertNumQueries к интеграционным тестам ключевых эндпоинтов. Третий — договориться в команде, где живёт логика формирования queryset: в менеджере, во viewset или в сервисном слое. Без этого соглашения каждый разработчик будет решать проблему по-своему, и через полгода профилирование покажет ту же картину, что и сегодня.
Мерить стоит не абстрактное «время ответа», а конкретные метрики: количество SQL-запросов на эндпоинт, суммарное время в базе, отношение времени в базе к общему времени ответа. Если база занимает больше 60–70% от времени ответа, оптимизация запросов даст ощутимый эффект. Если меньше — проблема в другом месте, и профилирование SQL не поможет.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django Documentation — Database access optimization, django-silk — profiling for Django
Частые вопросы
Можно ли использовать django-debug-toolbar с Django REST Framework?
assertNumQueries ломается при каждом изменении кода — не проще ли убрать?
select_related или prefetch_related — когда что?
Профилирование показало 200 запросов, но страница грузится быстро — стоит ли исправлять?
Работает ли assertNumQueries с асинхронными вьюхами Django 5.x?
Не приведёт ли сервисный слой к overengineering на маленьком проекте?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.