Profiling, bottleneck analysis, database optimization, CDN, scaling strategies
Статус: актуально для Django 5.2/6.0. Инструменты наблюдения меняются, но порядок работы стабилен: воспроизвести, измерить, найти ограничение, изменить одну вещь и проверить результат.
«Страница медленная» — не диагноз. Пользователь может ждать DNS, TLS, queue web worker, database lock, внешний API, serialization или browser rendering. Оптимизация без baseline легко ускоряет не тот участок и добавляет сложность без заметного эффекта.
Для важного endpoint заранее задают service-level цель, например p95 latency, error rate и минимальную throughput при ожидаемой concurrency. Среднее время скрывает длинный хвост, поэтому смотрят p50/p95/p99, а не одну среднюю цифру.
Зафиксируйте:
Локальная база с десятью строками не выявит плохой index или offset в миллион строк. Используйте синтетические данные боевого масштаба без персональной информации. Сначала снимите baseline, после изменения повторите тот же сценарий.
Полезно разложить latency:
queue + middleware + view/service + database + downstream + renderЕсли 700 из 800 ms уходит во внешний payment API, переписывание Django template ничего не даст. Distributed trace показывает путь между services, APM — длительность операций, profiler — CPU stack, database statistics — медленные запросы и waits.
django-debug-toolbar и Django test client удобны в development. connection.queries требует DEBUG=True и сам добавляет overhead, поэтому production диагностируют через APM и database telemetry. Profiler включают на контролируемой доле traffic: постоянный подробный profiling тоже имеет цену.
Самая частая Django-регрессия — N+1:
posts = Post.objects.order_by("-created_at")[:50]
for post in posts:
print(post.author.email) # дополнительный query на каждый postДля ForeignKey и OneToOne используется SQL JOIN через select_related(). Для many-to-many и reverse relation Django выполняет отдельную выборку и связывает результаты в Python через prefetch_related().
from django.db.models import Prefetch
posts = (
Post.objects
.select_related("author")
.prefetch_related(
Prefetch(
"comments",
queryset=Comment.objects.filter(is_public=True)
.select_related("author")
.order_by("id"),
to_attr="public_comments",
)
)
.order_by("-created_at")[:50]
)Не добавляйте prefetch «на всякий случай»: он расходует память и может загрузить коллекцию, которую response не использует. План зависит от фактически сериализуемых полей.
Закрепите бюджет тестом:
def test_post_list_query_budget(self):
with self.assertNumQueries(2):
response = self.client.get("/posts/")
self.assertEqual(response.status_code, 200)Число 2 не универсально, но осознанная граница предотвращает незаметное превращение двух queries в двести.
QuerySet обычно не обращается к БД до iteration, list(), len(), bool(), slicing с шагом или serialization. После вычисления результат кэшируется внутри этого экземпляра QuerySet. Повторное создание QuerySet выполняет новый query.
Практические правила:
exists() отвечает на вопрос о наличии, но если строки сразу понадобятся, отдельный exists() добавит лишний round trip;count() считает в БД, а не загружает все objects;values()/values_list() полезны для projection, когда model methods не нужны;only()/defer() могут вызвать дополнительный query при обращении к отложенному полю;iterator(chunk_size=...) снижает память для stream processing, но не делает тяжёлую работу коротким HTTP request;update()/bulk_create() уменьшают round trips, но обходят save() и часть signal lifecycle.Оптимизация должна сохранять семантику. Переход с model instance на dict или bulk operation способен пропустить validation, audit и domain method.
queryset = (
Order.objects
.filter(account_id=account_id, status="paid")
.order_by("-created_at", "-id")
)
print(queryset.explain())Обычный EXPLAIN показывает предполагаемый план без выполнения SELECT. EXPLAIN ANALYZE даёт фактические строки и время, но действительно запускает запрос; применяйте его осторожно и не копируйте production data-changing SQL.
Смотрите не только на Seq Scan — для маленькой таблицы он может быть оптимален. Важны оценка и фактическое число rows, loops, sort, heap fetches, disk reads и lock waits.
from django.db import models
class Order(models.Model):
account = models.ForeignKey("accounts.Account", on_delete=models.PROTECT)
status = models.CharField(max_length=30)
created_at = models.DateTimeField()
class Meta:
indexes = [
models.Index(
fields=["account", "status", "-created_at", "-id"],
name="order_feed_idx",
),
]Порядок полей, selectivity, сортировка и условия важнее самого факта наличия index. Каждый index занимает место и удорожает insert/update. Проверяйте реальный plan после migration. На большой PostgreSQL table index часто создают concurrent migration из django.contrib.postgres.operations, чтобы не держать длительную блокировку записи.
OFFSET 1000000 заставляет database найти и отбросить множество строк. Для бесконечной ленты лучше keyset/cursor pagination с устойчивым уникальным порядком:
page = Order.objects.order_by("-id")
if before_id is not None:
page = page.filter(id__lt=before_id)
page = list(page[:51])51-я строка показывает наличие следующей страницы; клиент получает первые 50 и cursor последней. Для сортировки по времени используйте пару (created_at, id), потому что timestamp может совпасть.
Offset остаётся нормальным для небольших admin lists и перехода на произвольный номер страницы. Выбор зависит от продукта, а не от догмы.
Медленный query иногда лишь ждёт другую transaction. Держите atomic() коротким, не вызывайте внешний HTTP внутри database transaction и обновляйте строки в детерминированном порядке. Отслеживайте lock wait, deadlock и время transaction отдельно от чистого execution time.
ATOMIC_REQUESTS=True удобен не всегда: он расширяет transaction на каждый view и может увеличить contention. Transaction boundary лучше привязывать к конкретному use case.
Cache ускоряет повторяемое дорогое чтение, но добавляет ещё одно состояние. До реализации ответьте:
Персональный response нельзя кэшировать только по URL. Инвалидацию после записи выполняют через transaction.on_commit(), чтобы cache не опередил database commit. LocMemCache изолирован по process; для общего production cache нужен сетевой backend, например Redis или Memcached.
Подробные cache-aside, sentinel для cached None, sessions и race conditions разобраны в отдельной главе о cache.
В Django 5.2/6.0 cached template loader автоматически используется, если TEMPLATES[...]["OPTIONS"]["loaders"] не задан явно. Старый совет обязательно настраивать его вручную больше не является общей оптимизацией.
Обычно template медленнее не из-за синтаксиса, а из-за hidden queries в properties и template tags. Подготовьте данные во view/service, проверьте query count и не вызывайте дорогую работу в цикле.
Для JSON измеряйте:
Выбор более быстрого JSON encoder имеет смысл только после профиля. Удаление ненужных полей и pagination часто дают больший выигрыш.
CONN_MAX_AGE переиспользует connection в thread/process; это не общий pool.OPTIONS["pool"] при установленном psycopg[pool].При ASGI Django рекомендует отключать persistent connections (CONN_MAX_AGE=0) и при необходимости использовать backend pool. Размер pool согласуют с суммой web/Celery processes и лимитом database. Слишком большой pool не ускоряет БД, а увеличивает contention.
Async view повышает concurrency для ожидания async-compatible I/O под ASGI. Он не ускоряет CPU-bound код и не делает один SQL query быстрее. Смешение sync middleware или sync ORM adapters может съесть выигрыш переключениями контекста.
Фоновая очередь нужна, когда пользователь не обязан ждать завершения: генерация report, обработка image, отправка notification. Это меняет контракт — нужны job status, idempotency, retry policy и наблюдаемость. Нельзя просто перенести критичную ошибку в Celery и считать latency исправленной.
Static/media обычно отдаёт object storage/CDN или reverse proxy, а не Django worker. Cache-Control, hashed filenames, image dimensions и compression уменьшают network cost. Dynamic compression чаще выполняют на edge/proxy; обязательно измеряют CPU и размер, а чувствительные responses проверяют на compression side-channel risk.
HTTP caching через ETag, Last-Modified, Cache-Control может вернуть 304 или обслужить public response на edge без обращения к приложению. Но Vary и приватность должны соответствовать identity и content negotiation.
Нагрузочный тест должен моделировать traffic mix, think time, authentication, cache state и downstream limits. Короткий тест показывает burst, длительный — memory leak, pool exhaustion и queue growth.
Увеличивайте нагрузку постепенно и наблюдайте saturation:
Горизонтальное масштабирование помогает только до следующего общего bottleneck. Добавление web workers к уже перегруженной database может ухудшить p99.
Оптимизация завершена не тогда, когда код «выглядит быстрее», а когда измерение улучшилось, поведение сохранилось, а operational cost понятен.
CONN_MAX_AGE отделён от настоящего connection pool.master/slave заменён на primary/replica.iterator() не выдаётся за способ сократить время тяжёлого request.post_save заменена явным commit-aware подходом.Далее: Финальный проект: SaaS