Отчёт в Django грузился 40 секунд: разбор оптимизации
Отчёт по заказам за квартал открывался 40 секунд и клал воркеры gunicorn по таймауту. Разбираю, где утекало время, что показал django-debug-toolbar и до каких цифр удалось довести запрос.

Страница отчёта по заказам за квартал открывалась 38–42 секунды и регулярно роняла воркеры gunicorn по --timeout 30. В базе — около 240 тысяч строк заказов и 900 тысяч строк позиций. Профиль показал: сам SQL честно отрабатывал за 900 мс, а остальное съедали 6100 дополнительных запросов в цикле шаблона и сериализация в Python. Ниже — как я это находил и до каких цифр довёл: итог 1,4 секунды на том же железе и данных.
Как я мерил, а не угадывал
Оптимизация вслепую — это перебор. Сначала я поставил замеры, чтобы понять, где именно утекает время: в базе, в ORM или в шаблоне.
- django-debug-toolbar — панель SQL показывает число запросов, дубли и время каждого. Именно она выявила 6100 запросов вместо ожидаемых 3–4.
- connection.queries — если toolbar недоступен (например, на стейджинге без GUI), при
DEBUG=TrueDjango копит запросы вdjango.db.connection.queries. Список и суммарное время видно после запроса. - QuerySet.explain() — метод есть в Django 2.1+ и отдаёт вывод
EXPLAINпрямо из ORM. На PostgreSQL 16 полезенqs.explain(analyze=True, buffers=True), чтобы увидеть реальное время и обращения к диску.
Условия замеров, чтобы цифры имели смысл: PostgreSQL 16, Django 5.0, 4 vCPU / 8 ГБ, база прогрета (второй прогон, не холодный кеш), 240 тыс. заказов, 900 тыс. позиций.
Пока вы не знаете, сколько запросов уходит на рендер одной страницы, вы оптимизируете предположения, а не систему. Первым делом — счётчик запросов, а не индексы.
Проблема первая: N+1 в цикле шаблона
Классика. В шаблоне я обходил заказы и для каждого дёргал order.customer.name и order.items.all. ORM ленивый: каждое обращение к связанному объекту — отдельный запрос. 240 тысяч заказов на страницу не выводились (была пагинация по 100), но в отчёте я агрегировал весь квартал в памяти — отсюда тысячи запросов.
Лечится select_related для связей «один» (ForeignKey, OneToOne — делают JOIN) и prefetch_related для связей «много» (обратный FK, ManyToMany — отдельный запрос + склейка в Python).
# Django 5.0. Было: обращение к customer и items в цикле — N+1
orders = Order.objects.filter(created__range=(start, end))
# Стало: одна связь через JOIN, вторая — отдельным запросом
orders = (
Order.objects
.filter(created__range=(start, end))
.select_related("customer") # FK -> JOIN, +0 запросов в цикле
.prefetch_related("items") # обратный FK -> 1 доп. запрос
)
Это убрало основную массу: 6100 запросов превратились в 2. Время упало с ~40 до ~6 секунд. Но 6 секунд — всё ещё много.
Проблема вторая: агрегация в Python вместо базы
Суммы по заказам я считал циклом на стороне Python: тянул все позиции и складывал quantity * price. База умеет это сама и делает быстрее — данные не покидают сервер СУБД.
from django.db.models import Sum, F, DecimalField
from django.db.models.functions import Coalesce
# Сумма позиций считается в SQL агрегатной функцией, не в цикле
orders = (
Order.objects
.filter(created__range=(start, end))
.select_related("customer")
.annotate(
total=Coalesce(
Sum(F("items__quantity") * F("items__price")),
0,
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)
)
Важный нюанс: annotate с Sum по связанной таблице делает JOIN и может размножить строки, если аннотаций несколько разных связей. Тогда суммы задваиваются. Если аннотируете сразу по двум разным «многим» связям — считайте их отдельными подзапросами или через Subquery, иначе получите неверные цифры. Здесь связь одна, поэтому обошлось без подзапроса.
После переноса агрегации в SQL — 2,8 секунды.
Проблема третья: тащили всё, а нужно пять полей
Отчёту нужны номер, дата, имя клиента и сумма. А ORM по умолчанию инстанцирует полные модели со всеми колонками и накладными расходами Python-объектов. Для отчёта, который только читает и отдаёт, полноценные модели не нужны.
# values() отдаёт словари, а не объекты модели — заметно дешевле по памяти и CPU
rows = (
Order.objects
.filter(created__range=(start, end))
.annotate(
total=Coalesce(
Sum(F("items__quantity") * F("items__price")),
0,
output_field=DecimalField(max_digits=12, decimal_places=2),
)
)
.values("number", "created", "customer__name", "total")
)
С values() отпадает select_related — доступ к customer__name через двойное подчёркивание делает JOIN сам. Инстанцирование объектов исчезло, память на запрос упала примерно втрое. Время — 1,9 секунды.
Проблема четвёртая: индекс под диапазон дат
EXPLAIN ANALYZE показал Seq Scan по orders на фильтре по created. Добавил составной индекс под конкретный запрос — по дате и клиенту, так как сортировка шла по клиенту.
class Order(models.Model):
created = models.DateTimeField(db_index=False)
# ...
class Meta:
indexes = [
models.Index(fields=["created", "customer"], name="order_created_cust_idx"),
]
После makemigrations/migrate планировщик перешёл на Index Scan. Итог — 1,4 секунды. На больших диапазонах индекс дал больше всего.
Что дало сколько
| Шаг | Что убрал | Время | Запросов |
|---|---|---|---|
| Исходный | — | ~40 с | ~6100 |
| select/prefetch_related | N+1 | ~6 с | 2 |
| Sum в SQL | агрегация в Python | ~2,8 с | 2 |
| values() | инстанцирование моделей | ~1,9 с | 2 |
| Индекс по created+customer | Seq Scan | ~1,4 с | 2 |
Цифры — для описанных выше условий. На вашем железе и объёме данных пропорции сохранятся, а абсолютные значения будут другими. Мерьте на своих данных.
Где этот подход ломается
- Несколько разных «многих» связей в одной аннотации. JOIN размножает строки,
Sumзадваивает. Разносите поSubqueryили считайте отдельными запросами. - Отчёт всё равно тяжёлый после SQL-оптимизации. Значит, узкое место не в коде, а в объёме: миллионы строк за раз в веб-запросе не отдают. Тогда — предагрегация в отдельную таблицу или материализованное представление, обновляемое по расписанию, и отдача пользователю уже готовых цифр.
- values() и потом методы модели. Словарь не знает про
@propertyи методы модели. Если в шаблоне вызываете методы объекта —values()не подойдёт, оставайтесь наonly(), который грузит подмножество полей, но возвращает объекты.
Что делать дальше
Если 1,4 секунды на синхронный веб-запрос всё ещё бьют по UX, вынесите генерацию отчёта в фоновую задачу (Celery или встроенный воркер) и отдавайте пользователю ссылку на готовый файл. Инвалидация и прогрев кеша отчёта — отдельная тема, здесь я её не разворачивал. В проде смотрите на p95 времени запроса и на количество запросов к БД на страницу — этот показатель ставьте в мониторинг сразу, он ловит регрессии N+1 раньше, чем их заметят пользователи.
Аналитик звонков: Разбор записи звонка по этапам сделки: где сорвалось, что упущено, три исправляемые рекомендации с цитатами.
Спросить за 90 ₽Источники: Django documentation — Database access optimization, Django documentation — QuerySet API: select_related, prefetch_related, annotate
Частые вопросы
У меня Django 4.2, а не 5.0 — код подойдёт?
Сделал select_related, а запросов всё равно много — почему?
Когда values() лучше only()?
Почему суммы в отчёте задвоились после annotate?
Индекс добавил, а EXPLAIN всё равно показывает Seq Scan — что не так?
Стоит ли сразу выносить отчёт в Celery?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.