Индексы в PostgreSQL: когда помогают, когда мешают и как это проверить
Совет «добавьте индекс» звучит так же часто, как «попробуйте перезагрузить». Иногда помогает. Иногда запрос остаётся медленным, а вставка становится заметно дороже — и непонятно, стало ли лучше в целом.
Разберёмся, что индекс делает на самом деле и как убедиться, что он действительно используется.
Чем вы платите
Индекс — это дополнительная структура, которую база обязана обновлять
при каждой записи. Пять индексов на таблице означают, что один
INSERT превращается в шесть операций записи: сама строка плюс
пять обновлений индексов.
Отсюда правило, которое экономит много времени: индексы добавляют не «на всякий случай», а под конкретный запрос, который действительно выполняется и действительно медленный.
Как узнать, что происходит
Единственный надёжный способ — спросить у планировщика:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title
FROM articles
WHERE category_id = 3
ORDER BY published_at DESC
LIMIT 20;
EXPLAIN покажет план, ANALYZE — реально
выполнит запрос и добавит фактическое время, BUFFERS — объём
прочитанных страниц.
Главное, что нужно увидеть в выводе: Seq Scan означает
последовательное чтение всей таблицы, Index Scan — обращение
через индекс. И ещё одна строка, которую часто пропускают: расхождение
между rows= в оценке и в факте. Если планировщик ожидал 10
строк, а получил 100 000, он выбрал стратегию под неверную гипотезу —
и проблема, скорее всего, в устаревшей статистике, а не в индексе.
Почему индекс не используется
Несколько типичных причин.
Функция поверх колонки. Индекс по created_at
не поможет запросу с DATE(created_at) = '2026-01-01': для базы
это выражение, а не колонка. Решения два — переписать условие диапазоном
или создать индекс по выражению:
-- диапазон вместо функции: индекс по created_at снова работает
WHERE created_at >= '2026-01-01' AND created_at < '2026-01-02';
-- либо индекс по тому же выражению
CREATE INDEX idx_articles_created_date ON articles ((DATE(created_at)));
Порядок колонок в составном индексе. Индекс
(category_id, published_at) годится для фильтра по категории
и для комбинации «категория + сортировка по дате», но не для запроса,
где есть только published_at. Работает правило префикса:
использовать можно любое начало списка колонок, но не середину.
Низкая селективность. Если условию удовлетворяет
половина таблицы, последовательное чтение дешевле похода в индекс и
обратно к строкам. Планировщик выбирает Seq Scan осознанно,
и это правильное решение.
Устаревшая статистика. После массовой загрузки данных
имеет смысл выполнить ANALYZE tablename; — иначе планировщик
рассуждает о таблице, которой уже нет.
Частичный индекс
Когда запросы всегда касаются небольшого подмножества строк, индексировать всю таблицу незачем:
CREATE INDEX idx_articles_published
ON articles (published_at DESC)
WHERE is_published = true;
Такой индекс меньше, быстрее обновляется и точнее отражает реальную
нагрузку — при условии, что запрос содержит то же условие
WHERE is_published = true, иначе планировщик им не воспользуется.
Поиск по тексту
Для LIKE '%подстрока%' обычный B-tree бесполезен: он
упорядочен по началу строки. Здесь нужен GIN с триграммами:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_articles_title_trgm
ON articles USING gin (title gin_trgm_ops);
Для полноценного полнотекстового поиска с учётом морфологии
используется tsvector и GIN по нему — это отдельная тема,
но начинать стоит с вопроса, нужен ли вам поиск по подстроке или
всё-таки поиск по словам.
Порядок действий
Практическая последовательность выглядит так:
- найти медленные запросы (расширение
pg_stat_statements), - посмотреть их план через
EXPLAIN (ANALYZE, BUFFERS), - убедиться, что статистика свежая,
- добавить индекс под конкретный запрос,
- перепроверить план и измерить влияние на запись.
В продакшне индексы создают через CREATE INDEX CONCURRENTLY:
обычный вариант держит блокировку на запись всё время построения.
Что в итоге
Индекс — это сделка: вы платите скоростью записи и местом на диске за скорость чтения. Сделка выгодна, когда заключена под конкретный запрос и проверена планом. «Добавить индекс на всякий случай» — это плата без покупки.