Самая частая ошибка, которую я видел в чужих схемах — «давайте проиндексируем всё». Индекс стоит места и замедляет запись, а половина из созданных не используется вообще. Ниже то, что на самом деле влияет на скорость.
Прежде чем создавать новые, полезно посмотреть, что уже есть и что реально задействовано:
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
Индексы с нулевым idx_scan — кандидаты на удаление. Единственная поправка: счётчик сбрасывается при перезапуске сервера, поэтому смотреть имеет смысл через недели работы, а не через час.
Индекс по колонке со значениями status (пять вариантов) почти бесполезен: планировщик обходит половину таблицы по любому, и дешевле сделать Seq Scan. Индекс по user_id на 10 миллионах строк, где на пользователя приходится десяток записей, — напротив, работает отлично.
Грубое правило: если условие отсекает больше 15–20% таблицы, планировщик с высокой вероятностью выберет последовательное сканирование. Это не баг — так действительно быстрее.
Составной индекс (a, b, c) работает для условий вида a = ?, a = ? AND b = ?, a = ? AND b = ? AND c = ?. Он не работает для b = ? без a — это как искать в телефонной книге по имени, если она отсортирована по фамилии.
Практический порядок — от наиболее селективного и наиболее частого в запросах:
-- часто: выборка заказов пользователя за период
CREATE INDEX ON orders (user_id, created_at DESC);
-- запрос: WHERE user_id = ? ORDER BY created_at DESC LIMIT 50
-- сортировка уже внутри индекса, отдельного Sort шага нет
Обратите внимание на DESC: для запросов с ORDER BY ... DESC направление в индексе избавляет от переворота. Составной индекс вида (user_id, created_at DESC) особенно хорош для «последние N записей пользователя».
Если запросы почти всегда смотрят только активные записи, индекс можно сузить:
CREATE INDEX CONCURRENTLY idx_tasks_open
ON tasks (created_at)
WHERE status = 'open';
Он раз в пять меньше полного, а WHERE status = 'open' планировщик понимает и подставляет его точно.
Покрывающий индекс (INCLUDE) прячет данные прямо в индексе — тогда обращения к самой таблице не происходит вовсе:
CREATE INDEX idx_orders_cover ON orders (user_id) INCLUDE (total, created_at);
CREATE INDEX ставит блокировку на запись. На живой таблице используйте CONCURRENTLY — дольше, но не кладёт продакшен. Если создание упало, остаётся недостроенный индекс, который надо удалить перед повторной попыткой.
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE user_id = 42 ORDER BY created_at DESC LIMIT 50;
На что смотреть в выводе:
| Признак | Что означает |
|---|---|
Seq Scan on orders (rows=10000000) | Индекс не выбран — либо его нет, либо он не селективен |
Index Scan vs Index Only Scan | Второй — данные целиком из индекса, быстрее |
Sort Method: external merge | Сортировка не влезла в память — ушла на диск, почти наверняка нужен индекс с нужным порядком |
rows=50 против actual rows=1 | Плохая статистика — пора ANALYZE или кастомную селективность |
shared read блоки в BUFFERS | Реальное число обращений к страницам — главный показатель стоимости |
Важная привычка: EXPLAIN без ANALYZE показывает только план, а не факт. Иногда план красивый, а фактическое время — три секунды.
Каждый индекс — это дополнительная запись при INSERT/UPDATE и место на диске. Практический чек-лист перед созданием:
Полезная команда для поиска дублей:pg_index+ сравнениеindkey— но быстрее обычно глазами по\diв psql при десятке индексов.