Админский дашборд логистического SaaS имел панель «задания, завершённые на этой неделе». Простой запрос: подсчитать строки, где status = 'completed' и completed_at попадает в текущую неделю. 50K строк в таблице на тот момент.
Запрос занимал 6 секунд. На дашборде было 8 похожих панелей. Общее время загрузки: 30+ секунд. Клиент сказал «дашборд невозможно использовать», и он был прав.
Фикс — одна команда CREATE INDEX. Найти правильную заняло три дня.
День 1: очевидная догадка оказалась неправильной
Первый инстинкт: добавить индекс на completed_at.
CREATE INDEX idx_jobs_completed_at ON jobs (completed_at);
Запустил EXPLAIN ANALYZE. Время запроса: всё ещё 5.8 секунд. Планировщик не использовал индекс.
Почему? Потому что запрос также фильтровал по status:
SELECT COUNT(*)
FROM jobs
WHERE status = 'completed'
AND completed_at >= '2026-05-19'
AND completed_at < '2026-05-26';
Postgres оценил, что фильтр status = 'completed' отсечёт 80% строк, и выбрал последовательный скан всей таблицы, а потом фильтрацию по completed_at. Индекс на одну колонку completed_at был бесполезен — планировщик решил, что быстрее просканировать всё, чем обращаться к индексу и потом фильтровать.
Попробовал индекс на status:
CREATE INDEX idx_jobs_status ON jobs (status);
Время запроса: 2.1 секунды. Лучше, но всё ещё ужасно для COUNT-запроса по 50K строк.
День 2: ловушка составного индекса
Прочитал три треда на Stack Overflow и один пост. Все говорили «используй составной индекс». Ну и использовал:
CREATE INDEX idx_jobs_status_completed_at ON jobs (status, completed_at);
Время запроса: 180ms. Огромное улучшение. Задеплоил.
Через два часа клиент написал: «вьюха диспетчера стала тормозить».
Вьюха диспетчера делала другой запрос:
SELECT *
FROM jobs
WHERE status IN ('assigned', 'accepted', 'in_progress')
AND zone_id = 'zone_42'
ORDER BY created_at DESC
LIMIT 20;
Этот запрос стал медленнее. До моих изменений — 300ms. Теперь — 1.2 секунды. Мой новый составной индекс запутал планировщик — он пытался сделать index scan по (status, completed_at) для запроса, который не фильтрует по completed_at, а потом фоллбэкался на сортировку по created_at.
Откатил индекс. Дашборд вернулся к 6 секундам. Вьюха диспетчера — к 300ms.
День 3: понять, что Postgres реально нужно
Перестал угадывать и реально прочитал вывод EXPLAIN ANALYZE строка за строкой.
План дашбордного запроса выглядел так (упрощённо):
Seq Scan on jobs (cost=0.00..1847.00 rows=312 width=0)
Filter: ((status = 'completed') AND (completed_at >= '2026-05-19') AND (completed_at < '2026-05-26'))
Rows Removed by Filter: 49688
312 ожидаемых строк, 49,688 отброшено фильтром. Postgres читал каждую строку и выбрасывал 99.4% из них.
План запроса диспетчера:
Index Scan using idx_jobs_zone_id on jobs (cost=0.29..847.00 rows=120 width=384)
Filter: (status = ANY ('{assigned,accepted,in_progress}'))
Sort Key: created_at DESC
Уже использовал индекс по zone_id. Фильтр status был пост-индексным фильтром. Это было нормально — 120 строк из индекса, отфильтровать до ~20, отсортировать, лимит. Достаточно быстро.
Инсайт: дашборд и диспетчерский запрос имели разные паттерны доступа. Дашборду нужно было прыгнуть прямо к status + диапазон дат. Диспетчеру нужен был zone_id первым, а статус — как фильтр. Один составной индекс не мог обслуживать оба хорошо.
Фикс: частичный индекс.
CREATE INDEX idx_jobs_completed_week
ON jobs (completed_at)
WHERE status = 'completed';
Этот индекс содержит только строки, где status = 'completed'. Для дашбордного запроса Postgres прыгает прямо в маленький индекс (только ~15K из 50K строк), сканирует диапазон дат, готово. Размер индекса: 40% от полнотабличного.
Время дашбордного запроса: 12ms. Запрос диспетчера: без изменений — 300ms (частичный индекс невидим для запросов, не соответствующих WHERE-клаузе).
Остальные панели дашборда получили аналогичные частичные индексы:
CREATE INDEX idx_jobs_created_week
ON jobs (created_at)
WHERE status = 'created';
CREATE INDEX idx_jobs_assigned_zone
ON jobs (zone_id, created_at DESC)
WHERE status IN ('assigned', 'accepted');
Общее время загрузки дашборда: с 30+ секунд до менее 400ms. Все восемь панелей.
Что я вынес
EXPLAIN ANALYZE — не опционально. Я потратил День 1 на угадывание вместо чтения. План говорит тебе ровно то, что Postgres делает и почему. Если ты добавляешь индекс без запуска EXPLAIN ANALYZE — ты делаешь перфоманс-работу с закрытыми глазами.
Составные индексы — не магия. Порядок колонок важен. Составной индекс (status, completed_at) бесполезен для запросов, которые не фильтруют сначала по status. И он может активно путать планировщик для запросов, которые фильтруют по status, но сортируют по другой колонке.
Частичные индексы недоиспользуются. Если в таблице есть колонка status и большинство запросов фильтруют по конкретному значению статуса — частичный индекс с WHERE status = 'value' меньше, быстрее и не мешает другим запросам. Это правильный инструмент, когда разные запросы нуждаются в разных паттернах доступа к одной таблице.
Тестируй запрос, который ты не оптимизируешь. Мой фикс Дня 2 ускорил дашборд и сломал вьюху диспетчера. Каждое изменение индекса нужно тестировать против всех критических запросов, а не только того, на котором ты сфокусирован. Теперь у меня в каждом проекте есть файл critical_queries.sql — 5–10 запросов, которые важнее всего, с ожидаемыми базовыми показателями.
50K строк — это ничто. В таблице было 50K строк. В зрелом продукте было бы 5M. Последовательный скан, который занимает 6 секунд при 50K, занял бы 10 минут при 5M. Проблемы с индексами не стареют — они накапливаются. Правильное время для починки — когда впервые замечаешь замедление, а не когда таблица в 100 раз больше.
Мета-урок
Три дня на однострочный фикс — звучит стыдно. Но фикс был не в строке — он был в понимании, почему именно эта строка работает, а три другие нет. Если бы я задеплоил составной индекс со Дня 2, это была бы регрессия, замаскированная под оптимизацию.
Дорогая часть перфоманс-работы — никогда не CREATE INDEX. Это чтение плана, понимание паттернов доступа и проверка, что твой фикс не ломает запросы, на которые ты не смотришь.
Если твой дашборд тормозит и ты не понимаешь почему — напиши. Иногда это один индекс. Иногда — запрос. Иногда — схема. Но почти никогда — «надо переписать всё».