В первой статье я объяснял кэш через холодильник. Продолжу тем же способом. Сейчас будет про индексы, а потом про то, почему индекс есть, а база его игнорирует.

Статья для тех, кто индексы ставил, но EXPLAIN читал по диагонали.

Указатель

Открываешь книгу на 800 страниц. Нужно одно слово.

Можно листать всё подряд, страницу за страницей. А можно открыть алфавитный указатель в конце: слово, номер страницы, готово.

В первом приближении база работает похоже. Пишешь:

SELECT * FROM users WHERE email = 'vasya@example.com';

Если индекса на email нет, база честно читает всю таблицу от начала до конца. Все строки. Каждый раз.

На миллионе строк это 25,7 мс и Rows Removed by Filter: 333333 в плане. Число стоит понимать правильно: полный проход шёл параллельно, и EXPLAIN показывает здесь среднее на один loop, а не итог по таблице. С индексом тот же запрос отрабатывает за 0,23 мс. Разница в сто с лишним раз, и она растёт вместе с таблицей.

Индекс здесь играет роль того самого указателя. Это отдельная структура, обычно B-дерево, где ключи хранятся отсортированными, а рядом с ключом лежит ссылка на строку в таблице: TID, то есть номер страницы и позиция внутри неё.

CREATE INDEX idx_users_email ON users (email);
Индекс хранит ключи в отсортированной структуре, рядом с ключом лежит ссылка на строку
Индекс хранит ключи в отсортированной структуре, рядом с ключом лежит ссылка на строку

Как посмотреть, что реально делает база

Проще всего спросить у неё саму:

EXPLAIN ANALYZE SELECT * FROM users WHERE email = 'vasya@example.com';

EXPLAIN покажет план, ANALYZE ещё и выполнит запрос с замерами. Что искать в выводе:

  • Seq Scan. Последовательное чтение всей таблицы. На большой таблице это тот самый вечер с перелистыванием 800 страниц. Вместо голого Seq Scan можно увидеть Gather и Parallel Seq Scan с Workers Planned: 2, тогда Postgres разделит полный проход между воркерами. Суть та же, таблица читается целиком.

  • Index Scan. Сначала обращение к индексу, затем к таблице за самими строками.

  • Index Only Scan. Все нужные колонки есть в индексе. Если visibility map позволяет подтвердить видимость строк, в таблицу идти не придётся вообще.

  • Bitmap Index Scan. База собрала из индекса битовую карту подходящих страниц и читает их пачкой. Промежуточный вариант, когда строк много, но не вся таблица.

  • Rows Removed by Filter. Сколько строк узел отбросил после проверки фильтра. Большое значение рядом с Seq Scan это повод проверить, нельзя ли сократить чтение индексом.

Полезно добавить BUFFERS:

EXPLAIN (ANALYZE, BUFFERS) SELECT ...

Тогда видно работу с буферами. Сколько блоков уже лежало в shared buffers, а сколько Postgres пришлось прочитать. Это честнее времени выполнения, которое гуляет от прогрева. Слово «прочитать» здесь не равно «поднять с диска»: между Postgres и диском есть ещё страничный кэш операционной системы.

Два пути к одной строке: Seq Scan и Index Scan
Два пути к одной строке: Seq Scan и Index Scan

Индекс есть, а база его не использует

Самая частая жалоба. Дальше причины в том порядке, в каком я их встречал.

Функция над колонкой

-- индекс на email не поможет
SELECT * FROM users WHERE lower(email) = 'vasya@example.com';

Индекс построен по значению email, а условие спрашивает про lower(email), и для базы это другое выражение. Лечится индексом по выражению:

CREATE INDEX idx_users_email_lower ON users (lower(email));

Эвристика такая. Условие должно совпадать с тем выражением, по которому построен индекс. Перестановка сторон сравнения тут ничего не меняет, а вот обёртка вокруг колонки меняет всё. С приведением типов сложнее: часть преобразований планировщику не мешает, часть закрывает индекс так же, как функция. Проверять EXPLAIN, а не держать список в голове.

Низкая селективность

SELECT * FROM orders WHERE status = 'active';

Если active занимает 80% таблицы, планировщик посмотрит на статистику и может решить, что последовательное чтение дешевле. По индексу пришлось бы прыгать почти по всем страницам, да ещё вразнобой.

В моём прогоне так и произошло. Индекс на status есть, но запрос по status = 'active' (800 тыс. строк из миллиона) уходит в Seq Scan и занимает 63 мс. Тот же индекс на status = 'refunded' (3 тыс. строк, 0,3%) даёт Index Scan за 12 мс. Индекс не «сломался» — планировщик выбрал дешёвый путь.

Индексы хороши там, где условие отсекает малую часть данных. Для «активных» из 80% таблицы в этом запросе индекс не помог, а для status = 'refunded' из 0,3% оказался как раз тем, что нужно.

Когда нагрузка спрашивает именно про редкое значение, например дашборд по возвратам, помогает частичный индекс:

CREATE INDEX idx_orders_refunded ON orders (created_at) WHERE status = 'refunded';

Он занимает место только под нужные строки и потому маленький.

Обратите внимание на состав ключа. status в него не входит вовсе, хотя запрос спрашивает именно про status. Это не опечатка. Условие вынесено в предикат самого индекса, и попадание записи внутрь уже доказывает, что статус нужный, перепроверять нечего. Ключом остаётся created_at, потому что дашборд по возвратам всё равно сортирует по дате.

В плане это выглядит как Bitmap Index Scan по частичному индексу внутри Bitmap Heap Scan. Postgres собирает битовую карту подходящих страниц и читает их пачкой. Нормальная картина, когда строк несколько десятков и они разбросаны по таблице.

Порядок колонок в составном индексе

Вот на этом запросе я ждал, что планировщик пойдёт в индекс.

SELECT * FROM events WHERE created_at > now() - interval '7 days';

Индекс на месте:

CREATE INDEX idx_events_user_created ON events (user_id, created_at);

Планировщик решил иначе. На миллионе строк он даже не пошёл в индекс, выбрал Parallel Seq Scan и прочитал таблицу целиком.

Причина в порядке колонок. Индекс отсортирован сначала по user_id, а внутри каждого user_id по created_at. Как телефонная книга: сначала фамилия, внутри фамилии имя. Найти всех с именем Иван, не зная фамилии, по такому порядку уже неудобно, Иваны разбросаны по всей книге.

Поэтому индекс хорошо работает для запросов, где первая колонка известна:

WHERE user_id = 42
WHERE user_id = 42 AND created_at > now() - interval '7 days'
WHERE user_id = 42 ORDER BY created_at DESC   -- сортировать не придётся
Порядок колонок в составном индексе
Порядок колонок в составном индексе

Ещё деталь из тех же прогонов. Запросы с user_id = 42 идут через Bitmap Heap Scan, а не через чистый Index Scan, потому что строк два десятка и они раскиданы по разным страницам. А вот WHERE user_id = 42 ORDER BY created_at DESC LIMIT 10 даёт Index Scan Backward без узла Sort. Сортировать не приходится вовсе, порядок уже оплачен при построении индекса. Это и есть главный бонус правильного порядка колонок.

Для типичного запроса вида a = ... AND b > ... разумная отправная точка такая: колонки под точное сравнение впереди, за ними диапазон или сортировка. Окончательно решает EXPLAIN на реальной нагрузке, потому что на выбор влияет ещё и набор запросов, селективность и возможность index-only scan.

Скрипт с запросами и планами из этой статьи я оставил на backendstart.ru, там можно прогнать те же примеры у себя.

Нужны только пара колонок, а база идёт в таблицу

Если в индексе есть всё, что запрашивают, база может не ходить в таблицу вообще:

CREATE INDEX idx_users_email_name ON users (email) INCLUDE (name);

SELECT name FROM users WHERE email = 'vasya@example.com';
-- в плане: Index Only Scan

INCLUDE добавляет колонку в индекс прицепом. Искать по ней нельзя, а отдать её можно без обращения к таблице. В плане появляется Index Only Scan, и в моём прогоне рядом стоит Heap Fetches: 0, то есть в heap Postgres действительно не ходил. Приём заметно помогает на горячих запросах.

Цена индексов

Указатель в книге тоже занимает страницы, и при каждой правке текста его надо переделывать. В базе так же, и об этой стороне вспоминают реже.

Место. Индекс — отдельная структура, и сумма индексов легко перевешивает сами данные. В моём тестовом прогоне таблица на миллион строк занимает 86 МБ, а пять индексов на ней 161 МБ. Почти вдвое больше самих данных.

Запись. Каждый INSERT и DELETE обновляет все индексы таблицы. С UPDATE тоньше: PostgreSQL умеет HOT-обновление, когда изменённые колонки не входят ни в один индекс, и тогда индексы не трогаются. Индекс на часто меняющейся колонке этот механизм отключает.

Планировщику тоже думать. Чем больше индексов, тем больше вариантов он перебирает.

Значит, индексы ставят под конкретные запросы, а не «на всякий случай». Найти лишние можно так:

SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY relname;

Ноль обращений с момента сброса статистики — кандидат на удаление.

На этом я сам чуть не попался. Сделал запрос, тут же заглянул в статистику и увидел idx_scan = 0 у индекса, которым только что воспользовался. Статистика собирается асинхронно и доезжает с задержкой. Ещё она должна копиться достаточно долго, и это не должен быть индекс под уникальность или под отчёт раз в квартал.

Как добавлять индекс на живом проде

Обычный CREATE INDEX берёт блокировку на запись. Пока индекс строится, таблица недоступна для вставок и обновлений. На большой таблице это минуты, и это заметят все.

CREATE INDEX CONCURRENTLY idx_users_email ON users (email);

CONCURRENTLY не блокирует обычные INSERT, UPDATE и DELETE на всё время построения. Плата двойная: строится дольше и не работает внутри транзакции, то есть в миграцию его надо заводить аккуратно. Если построение упадёт, останется индекс в состоянии INVALID, его придётся удалить (тоже CONCURRENTLY) и повторить.

При этом CONCURRENTLY не делает операцию дешёвой для прода, он снимает только длительную блокировку записей. Фаз он выполняет больше, ждёт незакрытые транзакции, и CPU с диском всё равно съест. Перед созданием индекса на проде стоит посмотреть его ожидаемый размер и текущую нагрузку. Индекс на терабайтной таблице в час пик — плохая идея даже с CONCURRENTLY.

Напоследок

Если из статьи останется что-то одно, пусть будет это. Индекс не делает запрос быстрым сам по себе. Он даёт планировщику ещё один путь, а пойдёт Postgres по нему или нет, зависит от того, дешевле ли этот путь остальных.

Поэтому EXPLAIN встречается здесь чаще, чем CREATE INDEX. Пока не посмотрел план, у тебя есть предположение, а не факт. У меня в этих прогонах предположение не сошлось дважды: на составном индексе, куда планировщик не пошёл, и в pg_stat_user_indexes, где свежий idx_scan ещё не доехал.

Все планы и цифры из статьи сняты на PostgreSQL 17.2, таблица на миллион строк.