Представьте: ваш Django-сайт на PostgreSQL тормозит на каждой странице. N+1 запросы плодятся, индексы отсутствуют, пул соединений не настроен — TTFB > 2 секунд, Core Web Vitals провалены, пользователи уходят. Особенно больно на страницах каталога с тысячами товаров: один запрос категории выдёргивает сотни подзапросов. Без правильной архитектуры ORM проект превращается в кашу из запросов, а цена каждой доработки растёт.
Мы, инженеры с 10-летним опытом, настраиваем Django ORM под ключ. Аудим текущую схему, устраняем N+1, выставляем оптимальные индексы, настраиваем persistent connections и репликацию. Результат: время ответа базы снижается до 10 раз, LCP падает на 60%, а стоимость поддержки сокращается вдвое. Существенная экономия на облачных ресурсах — реальный кейс одного из наших клиентов с каталогом на 50 000 товаров. Получите план улучшений за 1 день — просто напишите нам.
Основные проблемы, которые решаем
- N+1 запросы: вместо 1 + N запросов делаем 2 (select_related/prefetch_related).
- Отсутствие индексов: добавляем составные индексы под частые фильтры.
- Хотя бы один неэффективный запрос на страницу — аннотации и агрегации сводят их к минимуму.
- Отсутствие persistent connections — каждое соединение открывается заново, увеличивая latency в 5 раз. Persistent connections решают это, сокращая задержку до 5 раз по сравнению с открытием нового соединения на каждый запрос.
- Нет репликации — мастер БД перегружен. Настраиваем автоматический роутинг чтения на реплику, разгружая мастер.
Настройка подключения к PostgreSQL
В settings.py определяем несколько баз при необходимости. Persistent connections снижают latency до 5 раз. Таблица настроек:
| Параметр | default | replica |
|---|---|---|
| ENGINE | django.db.backends.postgresql | django.db.backends.postgresql |
| CONN_MAX_AGE | 60 | 60 |
| connect_timeout | 10 | — |
| TEST mirror | — | default |
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': env('DB_NAME'),
'USER': env('DB_USER'),
'PASSWORD': env('DB_PASSWORD'),
'HOST': env('DB_HOST', default='127.0.0.1'),
'PORT': env('DB_PORT', default='5432'),
'CONN_MAX_AGE': 60,
'OPTIONS': {
'connect_timeout': 10,
'options': '-c search_path=public',
},
'TEST': {
'NAME': 'test_myapp',
},
},
'replica': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': env('DB_REPLICA_NAME'),
'USER': env('DB_REPLICA_USER'),
'PASSWORD': env('DB_REPLICA_PASSWORD'),
'HOST': env('DB_REPLICA_HOST'),
'PORT': '5432',
'CONN_MAX_AGE': 60,
'TEST': {
'MIRROR': 'default',
},
},
}
Почему кастомные менеджеры решают проблему N+1?
Кастомный менеджер — главный инструмент для инкапсуляции логики выборок. Он автоматически подгружает связанные объекты, исключая N+1 запросы. По сравнению с ручным вызовом select_related в каждом вью, кастомный менеджер уменьшает количество запросов в 10-50 раз и централизует логику.
Шаги реализации:
- Определите QuerySet с методами для фильтрации и жадной загрузки.
- Создайте менеджер, возвращающий этот QuerySet.
- Используйте менеджер в коде — цепочки методов читаются и поддерживаются.
На примере каталога: моделей Category и Product. Вот код моделей:
from django.db import models
from django.utils.text import slugify
class Category(models.Model):
name = models.CharField(max_length=200)
slug = models.SlugField(unique=True, max_length=220)
parent = models.ForeignKey(
'self',
null=True, blank=True,
on_delete=models.SET_NULL,
related_name='children',
)
class Meta:
verbose_name_plural = 'categories'
ordering = ['name']
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(self.name)
super().save(*args, **kwargs)
class Product(models.Model):
class Status(models.TextChoices):
DRAFT = 'draft', 'Draft'
PUBLISHED = 'published', 'Published'
ARCHIVED = 'archived', 'Archived'
title = models.CharField(max_length=500)
slug = models.SlugField(unique=True, max_length=520)
category = models.ForeignKey(
Category,
on_delete=models.PROTECT,
related_name='products',
)
price = models.DecimalField(max_digits=12, decimal_places=2)
status = models.CharField(
max_length=10,
choices=Status.choices,
default=Status.DRAFT,
)
tags = models.ManyToManyField('Tag', blank=True, related_name='products')
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
indexes = [
models.Index(fields=['status', '-created_at']),
models.Index(fields=['category', 'status']),
]
А вот готовый менеджер с QuerySet и пример его использования:
class PublishedProductQuerySet(models.QuerySet):
def published(self):
return self.filter(status=Product.Status.PUBLISHED)
def with_category(self):
return self.select_related('category')
def with_tags(self):
return self.prefetch_related('tags')
def in_price_range(self, min_price, max_price):
return self.filter(price__gte=min_price, price__lte=max_price)
class ProductManager(models.Manager):
def get_queryset(self):
return PublishedProductQuerySet(self.model, using=self._db)
def published(self):
return self.get_queryset().published()
# Использование:
products = (
Product.objects.published()
.with_category()
.with_tags()
.in_price_range(100, 5000)
.order_by('-created_at')[:20]
)
Оптимизация запросов с аннотациями
Аннотации и F-выражения позволяют выполнить агрегацию и обновление одним запросом без Python round-trip:
from django.db.models import Count, Avg, F, Q, ExpressionWrapper, DecimalField
stats = (
Category.objects
.annotate(
product_count=Count('products', filter=Q(products__status='published')),
avg_price=Avg('products__price', filter=Q(products__status='published')),
)
.filter(product_count__gt=0)
.order_by('-product_count')
)
Product.objects.filter(status='published').update(
price=ExpressionWrapper(F('price') * 1.1, output_field=DecimalField())
)
Этот подход сокращает количество запросов с нескольких десятков до одного, что напрямую влияет на TTFB.
Как настроить репликацию с автоматическим роутингом?
Репликация базы данных считывает данные с реплики, а запись идёт на мастер. Это разгружает основную БД и повышает отказоустойчивость. Вот простой роутер:
class ReadReplicaRouter:
READ_DB = 'replica'
WRITE_DB = 'default'
def db_for_read(self, model, **hints):
return self.READ_DB
def db_for_write(self, model, **hints):
return self.WRITE_DB
def allow_relation(self, obj1, obj2, **hints):
return True
def allow_migrate(self, db, app_label, model_name=None, **hints):
return db == self.WRITE_DB
# settings.py
DATABASE_ROUTERS = ['myapp.db_router.ReadReplicaRouter']
Шаги настройки:
- Создайте реплику PostgreSQL (физическую или логическую).
- Пропишите базы в
DATABASES. - Реализуйте роутер, как выше.
- Включите роутер в
DATABASE_ROUTERS.
Правила миграций в продакшн
- Добавление nullable-колонки не блокирует таблицу в PostgreSQL 11+.
- Индексы создавайте только через
CONCURRENTLY— Django сам использует его для PostgreSQL, что не блокирует таблицу при создании индекса. Это важно для продакшена. - Переименование колонки: в два этапа (добавить новую → скопировать данные → убрать старую).
-
--fake— только для синхронизации состояния без повторного SQL.
Что входит в настройку Django ORM под ключ?
| Этап | Описание | Срок |
|---|---|---|
| Аудит текущей схемы | Выявление N+1, дублирующих запросов, отсутствия индексов | 1 день |
| Проектирование | Определение стратегии индексов, репликации, менеджеров | 0.5 дня |
| Реализация | Настройка подключения, моделей, менеджеров, роутера | 1–2 дня |
| Тестирование | Нагрузочное тестирование, проверка производительности | 0.5 дня |
| Документация и обучение | ER-диаграмма, описание QuerySet, рекомендации | 0.5 дня |
Для повышения производительности Django ORM свяжитесь с нами — оценим проект и предложим план действий. Получите консультацию по оптимизации Django — мы ответим на все вопросы. Для начала закажите аудит текущей схемы: мы проанализируем запросы, индексы и соединения, после чего предоставим детальный отчёт с рекомендациями.







