Как выжать максимум из «батареек» без боли
Django часто ругают за «медленность», но правда в том, что большинство проблем на высоких нагрузках — не в самом фреймворке, а в архитектуре вокруг него. Синхронный воркер, неоптимальные запросы к БД и отсутствие кэша убивают производительность быстрее, чем любой WSGI. Эта статья — практическая карта того, как построить Django-приложение, способное держать серьёзный трафик.
Разберём слои: от воркеров и ASGI до БД, кэша, фоновых задач и наблюдаемости.
1. Слой приложений: stateless, горизонтально, за балансировщиком
Главный принцип — приложение должно быть stateless. Если состояние живёт в памяти процесса (локальный кэш, сессии в файлах, загруженные файлы на диск), вы не сможете масштабироваться горизонтально без sticky-сессий и боли.
Сессии и состояние — во внешнее хранилище
# settings.py
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
SESSION_CACHE_ALIAS = "default"
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.redis.RedisCache",
"LOCATION": "redis://redis:6379/0",
}
}
Redis как единая точка состояния — сессии, блокировки, очереди, rate-limit.
WSGI или ASGI?
- WSGI + Gunicorn/uWSGI — предсказуемо и просто. Классика для CRUD-нагрузок.
- ASGI + Uvicorn/Gunicorn (
uvicorn.workers.UvicornWorker) — если есть async-вьюхи, WebSocket (Channels), SSE, много I/O-bound вызовов.
gunicorn config.wsgi:application \
--workers $((2 * $(nproc) + 1)) \
--worker-class uvicorn.workers.UvicornWorker \
--timeout 30 \
--max-requests 1000 \
--max-requests-jitter 100 \
--bind 0.0.0.0:8000
--max-requests с джиттером помогает бороться с утечками памяти в долгоживущих воркерах. Число воркеров подбирается эмпирически под нагрузку, а не «на глаз»: ориентир 2 * CPU + 1 — только стартовая точка.
Балансировка
Nginx/HAProxy/Traefik или облачный ALB. Приложение должно отвечать идемпотентно на health-проверки, а балансировщик — уметь выкидывать «уставшие» ноды.
2. База данных: 90% узких мест живёт здесь
Реляционная БД почти всегда становится бутылочным горлышком раньше, чем CPU приложения.
Пул соединений
Django не имеет встроенного пула. Два подхода:
-
CONN_MAX_AGE— переиспользование соединений на процесс:
python DATABASES["default"]["CONN_MAX_AGE"] = 60 DATABASES["default"]["CONN_HEALTH_CHECKS"] = True -
PgBouncer в режиме
transaction— обязателен, когда воркеров сотни, а Postgres не переваривает тысячи коннектов. Важно: в transaction-режиме нельзя использовать session-level features (например, prepared statements без настройки, advisory locks).
Реплики для чтения
Разделите трафик: запись — на primary, чтение — на реплики. Есть готовые решения: django-db-read-replica, django-multidb-router, либо собственный router.
class PrimaryReplicaRouter:
def db_for_read(self, model, **hints):
if hints.get("instance") and hints["instance"]._state.db == "default":
return "default" # read-after-write из primary
return "replica"
def db_for_write(self, model, **hints):
return "default"
Осторожно с read-after-write: если пользователь только что записал данные и сразу читает — читайте с primary, иначе увидит устаревшие данные.
Индексы и запросы
EXPLAIN ANALYZE— ваш лучший друг.- Составные индексы под реальные
WHERE+ORDER BY. - Partial indexes и покрывающие (covering) индексы в Postgres.
- Не забывайте про
db_index=TrueиMeta.indexes.
N+1 и работа с ORM
# Плохо: N+1
for order in Order.objects.all():
print(order.user.email)
# Хорошо
orders = Order.objects.select_related("user").all()
# Обратная сторона FK / M2M
orders = Order.objects.prefetch_related("items").all()
# Только нужные поля
emails = User.objects.values_list("email", flat=True)
# Стриминг больших выборок — итератор не тянет всё в память
for row in Order.objects.iterator(chunk_size=2000):
...
Для массовых вставок:
OrderItem.objects.bulk_create(items, batch_size=1000)
Model.objects.filter(...).update(status="done") # вместо save() в цикле
Полезно навесить проверки в CI: django-debug-toolbar, nplusone, django-perf-rec в тестах, чтобы N+1 ловился на ревью, а не в проде.
3. Кэширование: на каждом слое своя роль
Кэш — не «серебряная пуля», а система уровней, каждый со своими инвалидациями.
| Слой | Что кэшируем | Инвалидация |
|---|---|---|
| Browser / CDN | Статика, публичные ответы | Cache-Control, версии URL |
| Reverse proxy | Полные страницы, фрагменты | TTL / теги |
| Application (Redis) | Дорогие вычисления, объекты | Явная / по ключам |
| Database | Query plan, buffers | Автоматически |
Кэш представлений и фрагментов
from django.views.decorators.cache import cache_page
@cache_page(60, key_prefix="home_v2")
def home(request):
...
{% load cache %}
{% cache 300 sidebar request.user.id %}
...
{% endcache %}
Ключевые паттерны
- Cache-aside — читаем из кэша, при промахе идём в БД и записываем. Риск stampede.
- Локи для пересчёта — чтобы 1000 запросов не пересчитывали одно и то же:
```python
from django.core.cache import cache
def get_expensive(key, producer, ttl=300):
value = cache.get(key)
if value is None:
lock = cache.lock(f"lock:{key}", timeout=10)
with lock:
value = cache.get(key)
if value is None:
value = producer()
cache.set(key, value, ttl)
return value
``
- **Версионирование ключей** (key_prefix,VERSION`) — упрощает инвалидацию при деплое.
4. Всё тяжёлое — в фон
HTTP-запрос должен длиться миллисекунды. Всё, что дольше сотен миллисекунд и не нужно для ответа — в очередь.
from celery import shared_task
@shared_task(bind=True, max_retries=5, autoretry_for=(ConnectionError,))
def send_invoice(self, order_id):
order = Order.objects.get(pk=order_id)
render_and_send(order)
Что вынести в Celery/RQ/Dramatiq:
- Email/SMS/push-уведомления
- Генерация PDF, отчётов, экспортов
- Обработка загруженных файлов (thumbnails, antivirus, OCR)
- Синхронизации с внешними API
- Периодические задачи (Celery Beat)
Важные правила:
- Идемпотентность задач — брокеры доставляют «хотя бы раз».
- Ретраи с backoff и dead-letter queue.
- Не держите открытые транзакции и не передавайте ORM-объекты в задачи — передавайте
pk. - Тяжёлые задачи — в отдельные очереди и отдельные пулы воркеров, чтобы медленный отчёт не блокировал отправку писем.
5. Асинхронность там, где она реально помогает
Django поддерживает async-вьюхи, но ORM в синхронном режиме под капотом выполняется через threadpool. Async выигрывает, когда в запросе много параллельных внешних вызовов.
import asyncio, httpx
async def dashboard(request):
async with httpx.AsyncClient() as client:
a, b, c = await asyncio.gather(
client.get("https://api-a.example/data"),
client.get("https://api-b.example/data"),
client.get("https://api-c.example/data"),
)
return render(request, "dash.html", {"a": a.json(), "b": b.json(), "c": c.json()})
Правило простое: если вьюха ждёт сеть — async поможет; если жрёт CPU (шаблоны, PDF, шифрование) — async не поможет, поможет только вынос в фон или больше воркеров.
WebSocket/SSE — через Django Channels + Redis как channel layer.
6. Статика, медиа, CDN
- Статика — в S3/GCS + CloudFront/Cloudflare.
django-storages+ManifestStaticFilesStorageдля кэш-бастинга. - Медиа от пользователей — никогда не через Django-воркеры. Прямые presigned-загрузки в объектное хранилище, обработка — фоном.
- Формат — WebP/AVIF, lazy-loading, HTTP/2 или HTTP/3.
STORAGES = {
"default": {"BACKEND": "storages.backends.s3.S3Storage"},
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage"},
}
7. Защита и деградация под нагрузкой
На пике главное — не «пропустить всё», а не упасть целиком.
- Rate limiting на входе: Nginx
limit_req, илиdjango-ratelimit, или свой middleware поверх Redis (INCR+EXPIRE). - Таймауты на все внешние вызовы — иначе ваши воркеры утонут в ожидании.
- Circuit breaker (например,
pybreaker) на нестабильные зависимости. - Bulkhead — отдельные пулы/очереди для разных типов трафика.
- Graceful degradation — отдать частичный ответ или кэш, а не 500.
- Очереди с ограничением — не накапливать бесконечный бэклог, лучше отдать
429клиенту.
limit_req_zone $binary_remote_addr zone=api:10m rate=20r/s;
location /api/ {
limit_req zone=api burst=40 nodelay;
proxy_pass http://django_upstream;
}
8. Наблюдаемость: без неё оптимизация — гадание
Что должно быть в проде с первого дня:
- Метрики: p50/p95/p99 latency, RPS, ошибки, saturation БД, длина очередей Celery, hit-rate кэша. Prometheus + Grafana,
django-prometheus. - Трейсинг: OpenTelemetry — связать HTTP-запрос, SQL, вызовы в Celery и внешние API в один trace.
- Логи: структурированные (JSON), с request id, без чувствительных данных.
- Профайлинг:
py-spy,django-silk,cProfileпод нагрузкой.
Метрика, которую стоит держать на дашборде, — APDEX или бюджет по времени ответа. Всё остальное — следствие.
9. Деплой и масштабирование
- Контейнеры + Kubernetes (или ECS/Nomad) с HPA по RPS/CPU/латентности.
- Blue-green или canary деплои — БД-миграции должны быть обратно совместимы (add column → backfill → switch code → drop old).
- Долгие миграции — отдельными задачами,
ALTER TABLE ... CONCURRENTLY, без блокировок. - Readiness / liveness пробы отделены друг от друга.
- Ограничения ресурсов (requests/limits) — иначе один утёкший воркер утащит ноду.
Чек-лист для высоконагруженного Django
- Приложение stateless, состояние — в Redis/БД/объектном хранилище.
- БД: пул (PgBouncer), реплики, индексы, отсутствие N+1.
- Многоуровневый кэш с продуманной инвалидацией.
- Всё тяжёлое — в Celery с идемпотентностью и ретраями.
- Async — только под I/O-bound задачи.
- Статика/медиа — мимо приложения, через CDN.
- Rate limit, таймауты, circuit breaker, graceful degradation.
- Метрики, трейсинг, логи, профайлинг.
- Миграции и деплой без простоя.
- Регулярный load testing (k6, Locust, wrk) — иначе нельзя узнать, где предел.
Вывод
Высоконагруженный Django — это не про замену фреймворка, а про архитектурную дисциплину. Django даёт удобные абстракции, но под нагрузкой вы всё равно работаете с теми же законами: узкое место — база, память ограничена, сеть ненадёжна, а состояние нужно централизовать. Если слой приложения stateless, БД грамотно разведена и проиндексирована, тяжёлое ушло в фон, а кэш снимает 80% повторных чтений — Django спокойно держит продакшен любого масштаба.
Начинайте с наблюдаемости и профайлинга: оптимизировать то, чего вы не измеряете, — верный способ потратить квартал впустую.