Составные и частичные индексы в Django: разбор на замерах
Запрос с фильтром по статусу и дате начал отдавать по 800 мс на таблице в 4 млн строк. Разбираем, как составные и частичные индексы в Django меняют план запроса и когда они реально работают.

Таблица заказов выросла до 4 млн строк, и запрос списка активных заказов за последние 30 дней стал отдавать по 700–900 мс. В коде ничего не менялось — просто данных стало больше. EXPLAIN ANALYZE показал последовательное сканирование всей таблицы. Ниже — как составной индекс сократил время до единиц миллисекунд, где частичный индекс уместнее составного и почему очерёдность полей в индексе решает всё. Django 5.x, PostgreSQL 16.
Разбираем только индексы под конкретный запрос. Автовакуум, партиционирование и покрывающие индексы (INCLUDE) — отдельные темы, здесь их не трогаем.
Что за запрос и почему он медленный
Модель упрощённо выглядит так:
from django.db import models
class Order(models.Model):
STATUS_CHOICES = [
("active", "Активен"),
("done", "Завершён"),
("cancelled", "Отменён"),
]
status = models.CharField(max_length=16, choices=STATUS_CHOICES)
created_at = models.DateTimeField()
customer_id = models.BigIntegerField()
total = models.DecimalField(max_digits=12, decimal_places=2)
Проблемный запрос — список активных заказов за месяц, отсортированный по дате:
from datetime import timedelta
from django.utils import timezone
since = timezone.now() - timedelta(days=30)
qs = (
Order.objects
.filter(status="active", created_at__gte=since)
.order_by("-created_at")
)
Из 4 млн строк active — примерно 60 тысяч. Но без подходящего индекса PostgreSQL читает всю таблицу, отбрасывает лишнее и сортирует результат. Отсюда и секунда.
Смотрим план до индекса
Django умеет показать план прямо из ORM — метод .explain() у QuerySet:
# В Django shell
print(qs.explain(analyze=True))
В выводе видно Seq Scan on order и строку Rows Removed by Filter с числом около 3,9 млн. Это и есть цена отсутствия индекса: база трогает каждую строку.
Составной индекс: порядок полей — не косметика
Первое, что просится, — индекс по двум полям. В Django это models.Index в Meta.indexes:
class Order(models.Model):
# ... поля те же ...
class Meta:
indexes = [
models.Index(
fields=["status", "-created_at"],
name="order_status_created_idx",
),
]
Порядок полей здесь принципиален. Правило простое: сначала поля с равенством (status=), потом поле с диапазоном и сортировкой (created_at). Если поменять местами — сделать ["-created_at", "status"] — база сможет использовать индекс для диапазона по дате, но фильтр по статусу придётся применять поверх, и на выборке в 60 тысяч из миллионов это заметно медленнее.
Знак минуса перед created_at задаёт направление сортировки в самом индексе. Для запроса с order_by("-created_at") это позволяет базе взять строки уже в нужном порядке и пропустить шаг сортировки.
Индекс — это не «ускоритель таблицы вообще». Это структура под конкретную форму запроса. Порядок полей в составном индексе повторяет логику вашегоWHEREиORDER BY, а не алфавит.
Создаём миграцию
python manage.py makemigrations
python manage.py migrate
На большой таблице обычная миграция заблокирует запись на время построения индекса. В PostgreSQL для этого есть CREATE INDEX CONCURRENTLY, и Django умеет его генерировать через операцию AddIndexConcurrently из django.contrib.postgres.operations. Её нельзя запускать внутри транзакции — миграцию нужно пометить atomic = False. Точный синтаксис смотрите в документации Django по PostgreSQL-специфичным операциям, чтобы не заблокировать прод при выкатке.
План после составного индекса
После миграции qs.explain(analyze=True) показывает Index Scan using order_status_created_idx вместо Seq Scan. На нашей таблице время упало примерно с 800 мс до 4–7 мс. Условия замера: PostgreSQL 16, 4 млн строк, ~60 тыс. активных, локальный SSD, запрос без параллелизма. Цифры у вас будут другими — важен порядок изменения, а не абсолютное значение.
Частичный индекс: когда 98% строк в индексе не нужны
Составной индекс по status хранит записи всех статусов, включая миллионы завершённых заказов, которые запрос никогда не читает. Индекс занимает место и обновляется при каждой вставке. Если вы всегда фильтруете только по active, логичнее проиндексировать именно эту часть таблицы — это частичный индекс.
В Django частичный индекс задаётся аргументом condition с объектом Q:
from django.db.models import Q
class Order(models.Model):
# ... поля те же ...
class Meta:
indexes = [
models.Index(
fields=["-created_at"],
name="order_active_created_idx",
condition=Q(status="active"),
),
]
Теперь индекс содержит только активные заказы — те же ~60 тысяч строк вместо 4 млн. Он меньше по объёму, быстрее обновляется и точнее ложится на запрос. Но есть условие: планировщик применит его, только если WHERE запроса содержит ту же константу. Запрос с status="active" его подхватит, а запрос с параметром статуса, который вычисляется в рантайме, — уже нет.
Составной или частичный — таблица выбора
| Критерий | Составной индекс | Частичный индекс |
|---|---|---|
| Фильтр по статусу | Любое значение status | Только фиксированное (active) |
| Размер на диске | Все строки таблицы | Только подходящие под condition |
| Стоимость записи (INSERT/UPDATE) | Обновляется на каждой строке | Обновляется только для строк из condition |
| Когда брать | Разные значения фильтра в запросах | Один горячий статус, остальное не читаете |
Практический вывод: если у вас в приложении есть отдельный экран «активные заказы» и он один, частичный индекс выигрывает. Если тот же список запрашивается по разным статусам (админка с фильтром), берите составной.
Где это ломается
- Функция над полем убивает индекс. Запрос вроде
filter(created_at__date=...)оборачивает поле в приведение типа, и индекс поcreated_atне сработает. Для таких случаев нужен функциональный индекс (expressionsвmodels.Index) — это отдельный разговор. - Условие частичного индекса должно совпадать буквально. Если в
conditionвы пишетеQ(status="active"), а в запросе передаёте статус переменной, планировщик не докажет, что значение то же, и уйдёт в Seq Scan. - Индекс не помог, а таблица маленькая. На нескольких тысячах строк Seq Scan часто дешевле Index Scan, и планировщик сознательно игнорирует индекс. Это нормально — проверять индексы имеет смысл на объёме, близком к боевому.
- Устаревшая статистика. После массовой заливки данных запустите
ANALYZEпо таблице, иначе планировщик работает по старой оценке селективности и может выбрать неоптимальный план.
Что делать дальше
Не гадайте по коду — смотрите план. Порядок действий на боевой базе:
- Снимите
qs.explain(analyze=True)на копии продакшн-данных, а не на пустой dev-базе. - Найдите
Seq Scanи строкуRows Removed by Filter— это кандидаты на индекс. - Постройте индекс, повторяющий форму
WHEREиORDER BY: равенства слева, диапазон и сортировка справа. - Выкатывайте на большой таблице через
AddIndexConcurrently, чтобы не блокировать запись. - После деплоя следите за размером индексов (
pg_indexes_size) и долей неиспользуемых через системное представлениеpg_stat_user_indexes: индекс, который никто не читает, только замедляет запись.
Дальше стоит посмотреть в сторону покрывающих индексов с INCLUDE, если запросу нужно всего несколько колонок, и функциональных индексов для запросов с преобразованием поля.
Разбор научной статьи: Сложный текст — понятным языком, с терминами
Спросить за 12 ₽Источники: Django documentation — Model index reference, PostgreSQL 16 documentation — Partial Indexes
Частые вопросы
У меня MySQL, а не PostgreSQL — эти индексы будут работать?
Я добавил индекс, а запрос всё равно медленный — что смотреть?
Порядок полей в составном индексе действительно так важен?
Не заблокирует ли создание индекса запись на боевой таблице?
Сколько индексов можно навесить на таблицу без вреда?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.