Почему Django Views тормозят на больших таблицах: разбор инцидента
Список заказов в Django начинает отдавать 502 не из-за самого шаблона: узкие места обычно скрываются в N+1 запросах, сортировке без индекса и дорогом COUNT. Разбираем сценарий инцидента и план измерений.

Список заказов в Django может начать отдавать 502 уже после роста таблицы до миллионов строк, хотя локально тот же View отвечает за сотни миллисекунд. В типовом инциденте задержка складывается из N+1 запросов, полного сканирования при сортировке и отдельного COUNT для пагинации. Ниже — разбор воспроизводимого сценария для Django 5.x и PostgreSQL 16: что измерять, какие изменения проверять под нагрузкой и где оптимизация перестаёт помогать.
Сценарий инцидента: один View, три узких места
Представим страницу менеджера с последними заказами. Она показывает 50 записей, имя клиента, товары в заказе и фильтр по статусу. На тестовой базе с несколькими тысячами строк страница выглядит быстрой. После роста таблицы до миллионов заказов время ответа становится нестабильным: часть запросов укладывается в секунды, часть упирается в тайм-аут прокси.
Проблема обычно не в одном «медленном Django». View запускает цепочку операций:
- читает заказы и сортирует их по времени создания;
- для каждого заказа отдельно запрашивает клиента;
- для каждого заказа отдельно загружает позиции и товары;
- считает общее число строк для пагинации;
- рендерит шаблон, пока соединение с базой остаётся занятым.
Если на странице 50 заказов, ленивые обращения к связанным объектам могут превратить один запрос в десятки или сотни. Точное число зависит от шаблона и структуры моделей, поэтому его нужно смотреть в журнале SQL, а не угадывать.
Оптимизация View начинается не с кеша. Сначала нужно увидеть SQL, количество запросов и план выполнения, иначе быстрый ответ может оказаться случайностью на маленькой базе.
Что измерить до изменений
Сначала зафиксируйте базовую линию. Иначе после правки будет непонятно, уменьшилось ли время запроса или просто изменился набор данных.
- Время ответа. Запишите p50, p95 и p99 для одного и того же URL, фильтра и размера страницы. Один удачный запрос ничего не говорит о хвосте распределения.
- Число SQL-запросов. В локальной среде подойдёт Django Debug Toolbar. В рабочей среде используйте сборщик медленных запросов и метрики драйвера базы, а не подключайте отладочную панель к публичному сайту.
- Текст и план главного запроса. Для подозрительного QuerySet вызовите
explain(). Для PostgreSQL смотрите, использовался ли индекс, сколько строк прочитано и не появилась ли дорогая сортировка. - Ресурсы. Сопоставьте задержку с загрузкой CPU, диска, количеством соединений PostgreSQL и ожиданием в пуле. Иногда View ждёт свободное соединение, а не выполняет SQL.
- Повторяемость. Проверьте несколько страниц и фильтров. Первая страница с популярным статусом и глубокая страница пагинации могут иметь совершенно разные планы.
Запрос с EXPLAIN ANALYZE в PostgreSQL выполняется, а не только планируется. Не запускайте его бездумно для UPDATE, DELETE или запроса с побочными эффектами. Для чтения на копии базы риск ниже, но план всё равно нужно сопоставлять с рабочими данными.
Как найти N+1
Откройте SQL-журнал на один запрос страницы. Если после основного SELECT идут повторяющиеся запросы к таблице клиентов или позиций с разными идентификаторами, это N+1. Django загружает связанные объекты лениво: обращение к order.customer в шаблоне может обратиться к базе, если связь заранее не выбрана.
N+1 — паттерн, при котором один запрос получает список объектов, а затем ещё по запросу выполняется для каждого объекта списка. При 50 строках это уже не «пара лишних обращений», а отдельная нагрузка на базу и сеть.
Исправление первого класса: загрузить связи явно
Для связи «заказ — клиент» подходит select_related(): Django делает SQL JOIN и получает объект в рамках основного запроса. Для связи «заказ — позиции» нужен prefetch_related(): Django выполняет отдельный запрос по всем найденным заказам и собирает связи в памяти приложения.
from django.views.generic import ListView
from django.db.models import Prefetch
from shop.models import Order, OrderItem
class OrderListView(ListView):
model = Order
template_name = "shop/order_list.html"
context_object_name = "orders"
paginate_by = 50
def get_queryset(self):
items = OrderItem.objects.select_related("product")
return (
Order.objects
.select_related("customer")
.prefetch_related(Prefetch("items", queryset=items))
.filter(status=self.request.GET.get("status", "paid"))
.order_by("-created_at", "-id")
)
Этот фрагмент рассчитан на Django 5.x и модели, где у Order есть связи customer и items, а у OrderItem — product. Если обратное имя связи в вашей модели другое, его нужно заменить на фактическое значение related_name.
Проверка результата простая: для страницы с 50 заказами число запросов к клиентам и товарам не должно расти пропорционально числу заказов. Но общий результат проверяйте по SQL и времени ответа: prefetch уменьшает N+1, зато увеличивает объём данных в памяти процесса.
Второй класс проблем: сортировка и пагинация
Сортировка по created_at без подходящего индекса заставляет PostgreSQL читать много строк и сортировать их перед выдачей первых 50. При фильтре по статусу индекс должен учитывать и условие, и порядок выдачи. На практике точный индекс зависит от распределения статусов, доли «живых» заказов и других условий запроса.
Сначала посмотрите план, затем проверьте индекс на копии или в окне, где его создание допустимо. Для типичного фильтра и сортировки направление может выглядеть так:
CREATE INDEX CONCURRENTLY orders_status_created_id_idx
ON shop_order (status, created_at DESC, id DESC);
Команда предназначена для PostgreSQL 16. Имя таблицы, поля и необходимость индекса нужно сверить со схемой проекта. Индекс ускоряет чтение, но увеличивает размер базы и стоимость INSERT/UPDATE. Если статусы меняются часто, это особенно заметно.
У ListView с пагинацией есть ещё один источник задержки: Django обычно запрашивает количество строк для объекта paginator. Для первой страницы база может быстро найти 50 записей, но COUNT по сложному фильтру на большой таблице всё равно останется отдельной операцией.
Не отключайте COUNT вслепую. Сначала проверьте, что именно он занимает время. Если пользователю не нужна информация о количестве страниц, можно перейти на пагинацию «следующая страница есть или нет» либо на пагинацию по курсору. Это уже изменение интерфейса и контракта View, а не локальная настройка.
Сравнение подходов
| Подход | Что исправляет | Цена | Когда выбирать |
|---|---|---|---|
select_related() | Лишние запросы к одиночным связям | Более тяжёлый JOIN и широкий результат | ForeignKey или OneToOne нужны каждой строке |
prefetch_related() | N+1 для обратных и многозначных связей | Дополнительный запрос и память приложения | Нужно показать коллекцию связанных объектов |
| Индекс под фильтр и сортировку | Сканирование и дорогую сортировку | Место на диске и более дорогие записи | План показывает Seq Scan или большую Sort |
| Пагинация без общего COUNT | Долгий подсчёт всех совпадений | Нельзя сразу показать число страниц | Интерфейсу достаточно «дальше» |
| Кеширование HTML или данных | Повторные одинаковые чтения | Инвалидация и риск устаревших данных | Данные меняются редко, а запросы повторяются |
Где подход ломается
Большой prefetch
Если View загружает 50 заказов и все позиции каждого заказа, память процесса растёт вместе с объёмом связанных данных. Ограничивайте поля на уровне QuerySet только после проверки шаблона: агрессивный only() может вызвать дополнительные запросы при обращении к отложенному полю. Если на странице нужны только агрегаты, например количество позиций, не загружайте коллекцию целиком — вынесите расчёт в отдельный проверенный запрос или аннотацию.
Индекс не совпал с запросом
Индекс может не использоваться, если фильтр применяет функцию к полю, условие возвращает большую часть таблицы или порядок сортировки не соответствует плану. Наличие индекса в миграции не доказывает пользу. Смотрите фактический план и сравнивайте его на данных, близких к рабочим.
Глубокая OFFSET-пагинация
При переходе на далёкую страницу базе приходится пропустить большое число строк. Курсорная пагинация может снизить эту стоимость, но требует стабильного уникального порядка, например пары created_at и id. Такой переход нужно согласовать с URL, фильтрами и кнопками интерфейса.
Кеш скрывает проблему
Кеш может быстро убрать повторные чтения, но не исправляет медленный запрос при промахе и не решает N+1 для уникальных фильтров. Для заказов добавляется риск показать устаревший статус. Сначала измерьте базовый SQL, затем определите срок жизни и правила сброса кеша.
Что проверять на ревью
- Не обращается ли шаблон к связям, которые не загружены через
select_related()илиprefetch_related(). - Есть ли у QuerySet явный стабильный
order_by(). Сортировка только по времени может давать неоднозначный порядок при одинаковых значениях. - Не вызываются ли внутри цикла
count(),exists()или фильтрация связей, которые можно получить заранее. - Не выбирает ли View все поля и все связанные записи, хотя странице нужны несколько колонок.
- Есть ли метрики p95, количество SQL-запросов, время SQL и ожидание соединения.
Формулировка полезного замечания должна быть проверяемой: «В шаблоне обращение к order.customer создаёт запрос на каждый заказ; добавьте select_related("customer") и сравните число SQL до и после». Замечание «этот View можно ускорить» не помогает ни автору, ни ревьюеру.
Что делать дальше
Соберите один репрезентативный сценарий: размер таблиц, фильтр, размер страницы и долю связанных данных. Снимите SQL и план до изменений, затем по очереди проверьте устранение N+1, индекс и стоимость COUNT. После выката поставьте алерты на p95/p99, число запросов на ответ, время SQL и ошибки тайм-аутов. Инвалидацию кеша и курсорную пагинацию рассматривайте отдельно: это уже изменения поведения продукта, а не только оптимизация View.
IT-архитектор / SQL-оптимизатор: Поиск N+1, оптимизация запросов, code review, поиск багов.
Спросить за 20 ₽Источники: Django documentation: Database access optimization, PostgreSQL 16 documentation: Using EXPLAIN
Частые вопросы
Как понять, что проблема именно в N+1, а не в медленном PostgreSQL?
Можно ли всегда добавлять select_related и prefetch_related одновременно?
Почему PostgreSQL не использует созданный индекс?
Что будет, если не успеть оптимизировать View до роста базы?
Нужно ли отключать COUNT в пагинации Django?
Подойдёт ли этот разбор для Django 4.x или 5.1?
Что проверить в проде после выката оптимизации?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.