
Оптимизация Django-приложения — это системный подход, а не набор хаков. Ключевые принципы:
Результат: снижение времени ответа API с 2-3 сек до 200-400 мс, сокращение нагрузки на БД на 70-80%.
Django — мощный фреймворк с отличной документацией, но "из коробки" он не оптимизирован для высоких нагрузок. Проблема не в Django, а в том, что большинство разработчиков не знают, как правильно его использовать.
Реальный кейс из практики:
Проект с 50K активных пользователей в месяц тормозил даже на 4 CPU / 8GB RAM. После оптимизации тот же проект работал на 2 CPU / 4GB с вдвое меньшим временем отклика.
В этой статье — только проверенные техники с реальными метриками и примерами кода.
"Преждевременная оптимизация — корень всех зол" — Дональд Кнут
Правило: Не оптимизируйте то, что не измерили. Профилирование показывает узкие места.
Лучший инструмент для разработки. Показывает SQL-запросы, время выполнения, кеш-хиты.
# settings.py
INSTALLED_APPS = [
# ...
'debug_toolbar',
]
MIDDLEWARE = [
'debug_toolbar.middleware.DebugToolbarMiddleware',
# ...
]
INTERNAL_IPS = ['127.0.0.1']
# Для Docker
import socket
hostname, _, ips = socket.gethostbyname_ex(socket.gethostname())
INTERNAL_IPS += [ip[: ip.rfind(".")] + ".1" for ip in ips]
Более продвинутый профайлер для production-like окружений:
# settings.py
INSTALLED_APPS = [
'silk',
]
MIDDLEWARE = [
'silk.middleware.SilkyMiddleware',
]
# urls.py
from django.urls import path, include
urlpatterns = [
path('silk/', include('silk.urls', namespace='silk')),
]
Что смотреть:
| Метрика | Норма | Тревожно |
|---|---|---|
| Кол-во SQL-запросов на страницу | 5-15 | >30 |
| Время SQL-запросов | <50ms | >200ms |
| Duplicate queries | 0 | >3 |
| Время рендеринга шаблона | <20ms | >100ms |
Автоматически детектирует N+1 проблемы:
# settings.py
INSTALLED_APPS = [
'nplusone.ext.django',
]
MIDDLEWARE = [
'nplusone.ext.django.NPlusOneMiddleware',
]
# Raise exception при N+1
NPLUSONE_RAISE = True # В dev окружении
Результат: Выявление всех N+1 проблем за 10 минут тестирования.
N+1 проблема — самая частая причина медленных Django-приложений.
# ❌ ПЛОХО: N+1 запросов
# 1 запрос на получение постов + N запросов на получение авторов
posts = Post.objects.all() # 1 запрос
for post in posts:
print(post.author.name) # +N запросов (по одному на каждый пост!)
Результат: Для 100 постов = 101 SQL-запрос!
# ✅ ХОРОШО: 1 запрос с JOIN
posts = Post.objects.select_related('author').all() # 1 запрос с JOIN
for post in posts:
print(post.author.name) # Данные уже в памяти
SQL под капотом:
SELECT post.*, author.*
FROM blog_post AS post
INNER JOIN auth_user AS author ON post.author_id = author.id
Результат: 100 постов = 1 SQL-запрос. Ускорение в 100 раз!
# ❌ ПЛОХО: 1 + N запросов
posts = Post.objects.all()
for post in posts:
tags = post.tags.all() # Новый запрос для каждого поста!
# ✅ ХОРОШО: 2 запроса (posts + все tags)
posts = Post.objects.prefetch_related('tags').all()
for post in posts:
tags = post.tags.all() # Данные уже в памяти
from django.db.models import Prefetch
# Посты -> Комментарии -> Авторы комментариев
posts = Post.objects.prefetch_related(
Prefetch(
'comments',
queryset=Comment.objects.select_related('author').order_by('-created_at')
)
).all()
# Или через строку
posts = Post.objects.prefetch_related('comments__author').all()
# Только активные комментарии
active_comments = Prefetch(
'comments',
queryset=Comment.objects.filter(is_active=True).select_related('author'),
to_attr='active_comments' # Сохраняет в отдельный атрибут
)
posts = Post.objects.prefetch_related(active_comments).all()
for post in posts:
print(post.active_comments) # Уже отфильтрованные и загруженные
1. Не используйте filter() после prefetch_related()
# ❌ ПЛОХО: prefetch_related не сработает!
posts = Post.objects.prefetch_related('tags').filter(category='tech')
# filter() после prefetch_related сбрасывает prefetch
# ✅ ХОРОШО
posts = Post.objects.filter(category='tech').prefetch_related('tags')
2. Осторожно с кешированием
posts = Post.objects.select_related('author').all()
post = posts[0]
post.author.email # OK, данные есть
# Но если обновим author...
post.author = Author.objects.get(id=999)
post.author.email # select_related перезапишется
Результат: Сокращение SQL-запросов с 100+ до 2-5 на страницу.
Кеширование — самый эффективный способ ускорения. Redis — де-факто стандарт для Django.
pip install django-redis
# settings.py
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': 'redis://127.0.0.1:6379/1',
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
'CONNECTION_POOL_KWARGS': {
'max_connections': 50,
'retry_on_timeout': True,
},
'SOCKET_CONNECT_TIMEOUT': 5,
'SOCKET_TIMEOUT': 5,
},
'KEY_PREFIX': 'myapp', # Префикс для всех ключей
'TIMEOUT': 300, # Дефолтный TTL: 5 минут
}
}
# Сессии в Redis (быстрее БД)
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
from django.core.cache import cache
# Установить значение
cache.set('my_key', 'my_value', timeout=300) # TTL 5 минут
# Получить значение
value = cache.get('my_key') # None если не найдено
value = cache.get('my_key', 'default_value') # С дефолтом
# Удалить
cache.delete('my_key')
# Множественные операции (атомарные)
cache.set_many({'key1': 'val1', 'key2': 'val2'}, timeout=300)
values = cache.get_many(['key1', 'key2']) # {'key1': 'val1', ...}
cache.delete_many(['key1', 'key2'])
# Инкремент/декремент (атомарные)
cache.incr('counter') # +1
cache.decr('counter') # -1
cache.incr('counter', delta=10) # +10
def get_popular_posts():
cache_key = 'popular_posts'
posts = cache.get(cache_key)
if posts is None:
# Cache miss — запрос в БД
posts = list(
Post.objects
.filter(views__gt=1000)
.select_related('author')
.prefetch_related('tags')
.order_by('-views')[:10]
)
cache.set(cache_key, posts, timeout=300) # 5 минут
return posts
from django.views.decorators.cache import cache_page
# Кешировать на 5 минут
@cache_page(60 * 5)
def my_view(request):
# Тяжёлые вычисления...
return render(request, 'template.html', context)
# С учётом query параметров
@cache_page(60 * 5, key_prefix='special')
def filtered_view(request):
category = request.GET.get('category', 'all')
# ...
{% load cache %}
{% cache 500 sidebar request.user.username %}
<!-- Эта часть кешируется на 500 секунд -->
<div class="sidebar">
{% for item in expensive_query %}
...
{% endfor %}
</div>
{% endcache %}
from django.db.models.signals import post_save, post_delete
from django.dispatch import receiver
from django.core.cache import cache
@receiver(post_save, sender=Post)
@receiver(post_delete, sender=Post)
def invalidate_post_cache(sender, instance, **kwargs):
# Инвалидировать связанные ключи
cache.delete('popular_posts')
cache.delete(f'post_{instance.id}')
cache.delete(f'category_{instance.category}_posts')
| Стратегия | Когда использовать | TTL |
|---|---|---|
| Cache-aside | Чтение >> Запись | 5-15 мин |
| Write-through | Запись ~= Чтение | 1-5 мин |
| Write-behind | Запись > Чтение | 30 сек - 2 мин |
Результат: Снижение нагрузки на БД на 70-80%, уменьшение времени ответа в 5-10 раз.
Индексы — это самый недооценённый инструмент оптимизации.
class Post(models.Model):
# Автоматический индекс на PRIMARY KEY
id = models.AutoField(primary_key=True)
# db_index=True для часто используемых полей
slug = models.SlugField(unique=True) # unique создаёт индекс
category = models.CharField(max_length=50, db_index=True)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
is_published = models.BooleanField(default=False, db_index=True)
# ForeignKey автоматически создаёт индекс
author = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
# Составные индексы для частых запросов
indexes = [
# Для ORDER BY -created_at с фильтром по category
models.Index(fields=['-created_at', 'category']),
# Для поиска по is_published + created_at
models.Index(fields=['is_published', '-created_at']),
# Partial index (PostgreSQL only) - экономия места
models.Index(
fields=['created_at'],
name='published_posts_idx',
condition=models.Q(is_published=True)
),
]
✅ Создавайте индекс если:
WHERE (filter, exclude)ORDER BY (order_by)JOIN (foreign key)❌ НЕ создавайте индекс если:
# PostgreSQL: проверка использования индекса
from django.db import connection
with connection.cursor() as cursor:
cursor.execute("EXPLAIN ANALYZE SELECT * FROM blog_post WHERE category = 'tech'")
print(cursor.fetchall())
Ищите в выводе:
Seq Scan (плохо) → Full table scanIndex Scan (хорошо) → Использует индексBitmap Index Scan (отлично) → Использует несколько индексовclass Meta:
indexes = [
# INCLUDE добавляет поля в индекс без индексирования
models.Index(
fields=['category'],
name='category_title_idx',
include=['title', 'excerpt'], # Только PostgreSQL 11+
),
]
Результат: Запрос может быть выполнен только по индексу, без обращения к таблице (Index-Only Scan).
-- PostgreSQL: найти неиспользуемые индексы
SELECT
schemaname,
tablename,
indexname,
idx_scan,
idx_tup_read,
idx_tup_fetch,
pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND indexrelname NOT LIKE 'pg_%'
ORDER BY pg_relation_size(indexrelid) DESC;
Результат: Ускорение поисковых запросов в 5-100 раз, но замедление INSERT/UPDATE на 10-30%.
# only() — загрузить ТОЛЬКО указанные поля
posts = Post.objects.only('id', 'title', 'excerpt')
# SELECT id, title, excerpt FROM blog_post
# defer() — загрузить ВСЁ КРОМЕ указанных полей
posts = Post.objects.defer('content', 'raw_html')
# SELECT id, title, excerpt, created_at, ... FROM blog_post
# (всё кроме content и raw_html)
⚠️ Подводный камень:
posts = Post.objects.only('title')
for post in posts:
print(post.title) # OK, данные есть
print(post.content) # Дополнительный SQL-запрос!
# values() → список словарей
posts = Post.objects.values('id', 'title')
# [{'id': 1, 'title': 'Post 1'}, {'id': 2, 'title': 'Post 2'}]
# values_list() → список кортежей
posts = Post.objects.values_list('id', 'title')
# [(1, 'Post 1'), (2, 'Post 2')]
# flat=True для одного поля → список значений
post_ids = Post.objects.values_list('id', flat=True)
# [1, 2, 3, 4, 5]
# named=True → named tuples
posts = Post.objects.values_list('id', 'title', named=True)
for post in posts:
print(post.id, post.title) # Удобный доступ
Когда использовать:
only()/defer() → когда нужны model instancesvalues()/values_list() → для чтения данных (быстрее, меньше памяти)# ❌ ПЛОХО: загружает все 1M записей в память
for post in Post.objects.all():
process(post) # OOM (Out of Memory) при большом кол-ве
# ✅ ХОРОШО: загружает порциями по 1000
for post in Post.objects.all().iterator(chunk_size=1000):
process(post) # Экономия памяти
⚠️ Осторожно: iterator() обходит кеш Django, не используйте если нужен повторный доступ.
# ❌ ПЛОХО: N SQL-запросов
for user_data in users_data:
User.objects.create(**user_data) # 1000 запросов!
# ✅ ХОРОШО: 1 SQL-запрос
User.objects.bulk_create([
User(**user_data) for user_data in users_data
], batch_size=500) # Вставка по 500 за раз
# Bulk update
posts = Post.objects.all()
for post in posts:
post.views += 1
# ❌ ПЛОХО
for post in posts:
post.save() # N запросов
# ✅ ХОРОШО
Post.objects.bulk_update(posts, ['views'], batch_size=500)
# Или ещё лучше (1 запрос)
from django.db.models import F
Post.objects.all().update(views=F('views') + 1)
Результат: Ускорение массовых операций в 100-1000 раз.
pip install celery redis
# myproject/celery.py
import os
from celery import Celery
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'myproject.settings')
app = Celery('myproject')
app.config_from_object('django.conf:settings', namespace='CELERY')
app.autodiscover_tasks()
# myproject/__init__.py
from .celery import app as celery_app
__all__ = ('celery_app',)
# settings.py
CELERY_BROKER_URL = 'redis://localhost:6379/0'
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
CELERY_TASK_SERIALIZER = 'json'
CELERY_RESULT_SERIALIZER = 'json'
CELERY_ACCEPT_CONTENT = ['json']
CELERY_TIMEZONE = 'Europe/Moscow'
CELERY_TASK_TRACK_STARTED = True
CELERY_TASK_TIME_LIMIT = 30 * 60 # 30 минут hard limit
# app/tasks.py
from celery import shared_task
from django.core.mail import send_mail
@shared_task(bind=True, max_retries=3)
def send_newsletter(self, user_ids):
try:
users = User.objects.filter(id__in=user_ids)
for user in users:
send_mail(
'Newsletter',
'Content...',
'from@example.com',
[user.email],
)
except Exception as exc:
# Retry через 60 секунд
raise self.retry(exc=exc, countdown=60)
# Задача с расписанием
from celery.schedules import crontab
@shared_task
def cleanup_old_sessions():
Session.objects.filter(expire_date__lt=timezone.now()).delete()
# settings.py
CELERY_BEAT_SCHEDULE = {
'cleanup-sessions': {
'task': 'app.tasks.cleanup_old_sessions',
'schedule': crontab(hour=2, minute=0), # Каждый день в 2:00
},
}
# views.py
from .tasks import send_newsletter
def trigger_newsletter(request):
user_ids = list(User.objects.values_list('id', flat=True))
# Асинхронно
send_newsletter.delay(user_ids)
# Или с таймаутом
send_newsletter.apply_async(args=[user_ids], countdown=300) # Через 5 минут
return JsonResponse({'status': 'queued'})
# Запуск worker
celery -A myproject worker -l info
# Запуск beat (scheduler)
celery -A myproject beat -l info
# Мониторинг Flower
pip install flower
celery -A myproject flower
# http://localhost:5555
Результат: Мгновенный ответ пользователю вместо ожидания 30+ секунд.
pip install django-storages boto3
# settings.py
AWS_ACCESS_KEY_ID = 'your-access-key'
AWS_SECRET_ACCESS_KEY = 'your-secret-key'
AWS_STORAGE_BUCKET_NAME = 'your-bucket'
AWS_S3_REGION_NAME = 'us-east-1'
AWS_S3_CUSTOM_DOMAIN = f'{AWS_STORAGE_BUCKET_NAME}.s3.amazonaws.com'
AWS_S3_OBJECT_PARAMETERS = {
'CacheControl': 'max-age=86400', # 1 день
}
# Статика
STATICFILES_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
STATIC_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/static/'
# Медиа
DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage'
MEDIA_URL = f'https://{AWS_S3_CUSTOM_DOMAIN}/media/'
# Собрать статику
python manage.py collectstatic --noinput
# С минификацией (django-compressor)
pip install django-compressor
Результат: Снижение времени загрузки страницы на 40-60%, разгрузка основного сервера.
# settings.py
MIDDLEWARE = [
'django.middleware.gzip.GZipMiddleware', # В начале!
'django.middleware.http.ConditionalGetMiddleware',
# ...
]
# Минимальный размер для сжатия
GZIP_MIN_SIZE = 1024 # 1KB
pip install django-brotli
MIDDLEWARE = [
'django_brotli.middleware.BrotliMiddleware', # Перед GZipMiddleware
'django.middleware.gzip.GZipMiddleware',
# ...
]
Результат: Уменьшение размера передаваемых данных на 70-80%.
# settings.py (PostgreSQL)
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': 'mydb',
'USER': 'myuser',
'PASSWORD': 'mypassword',
'HOST': 'localhost',
'PORT': '5432',
'CONN_MAX_AGE': 600, # Переиспользование соединений 10 минут
'OPTIONS': {
'connect_timeout': 10,
'options': '-c statement_timeout=30000', # 30 сек таймаут
},
}
}
# Для production: pgBouncer
# https://www.pgbouncer.org/
[databases]
mydb = host=localhost port=5432 dbname=mydb
[pgbouncer]
listen_addr = 127.0.0.1
listen_port = 6432
auth_type = md5
auth_file = /etc/pgbouncer/userlist.txt
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 25
# Django settings с pgBouncer
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'HOST': '127.0.0.1',
'PORT': '6432', # PgBouncer порт
'CONN_MAX_AGE': None, # Persistent connections через pgBouncer
}
}
Результат: Снижение времени соединения с БД с 50ms до <1ms.
pip install sentry-sdk
import sentry_sdk
from sentry_sdk.integrations.django import DjangoIntegration
sentry_sdk.init(
dsn="your-sentry-dsn",
integrations=[
DjangoIntegration(),
],
traces_sample_rate=0.1, # 10% запросов для performance monitoring
profiles_sample_rate=0.1, # Profiling
send_default_pii=False, # Не отправлять PII
environment="production",
)
pip install django-prometheus
# settings.py
INSTALLED_APPS = [
'django_prometheus',
# ...
]
MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
# ... остальные middleware
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
# urls.py
urlpatterns = [
path('', include('django_prometheus.urls')),
]
Метрики доступны на /metrics:
django_http_requests_total - количество запросовdjango_http_requests_latency_seconds - latencydjango_db_query_duration_seconds - время SQL-запросовРезультат: Видимость всех узких мест в режиме реального времени.
Проект: E-commerce с каталогом 10K товаров
ROI: Экономия $200/месяц на инфраструктуре + лучший UX.
⚠️ Преждевременная оптимизация — зло
Не оптимизируйте если:
Фокусируйтесь на продукте, а не на микрооптимизациях!
Оптимизация Django — это не одноразовая задача, а непрерывный процесс. Ключевые принципы:
Главное правило: Делайте приложение быстрым там, где это важно для пользователя. Остальное — vanity metrics.
Начните с профилирования — и вы удивитесь, сколько низко висящих фруктов найдёте!
Берём проекты на поддержку с чётким SLA. Стабилизируем за 2 недели, даём план развития на 90 дней. 15+ лет опыта с Django.