Индекс (a, b, c) работает как телефонная книга: сначала a, потом b. Без ведущей колонки классический B-tree почти бесполезен для поиска.
PostgreSQL postgres собеседование
Составной индекс хранит несколько колонок как один ключ. Сравнение идёт слева направо: сначала первая колонка, при равенстве — вторая. Это leftmost prefix: как фамилия в телефонной книге. Сначала ищете Ивановых, потом внутри — Сергея. Без фамилии книгу не откроете на всех «Сергеях».
Правило следует из устройства дерева, а не из доброй воли планировщика. Индекс (a, b, c) полезен для условий по a, по a+b, по a+b+c. Только по b или только по c классический B-tree как prefix не работает.
Пример. Индекс (фамилия, имя). Запрос «все Ивановы» работает. «Ивановы Сергей» — тоже. Запрос «все Сергеи» по этому индексу как prefix не ищется.
На бэкенде типичный горячий запрос — равенство по сущности плюс диапазон по времени: заказы пользователя за период, события сессии, лента. Один правильно упорядоченный составной индекс закрывает и фильтр, и ORDER BY … LIMIT. Два отдельных индекса по каждой колонке этого часто не дают. Postgres может сделать BitmapAnd (пересечение двух индексных битмапов), но сортировку «бесплатно» вы не получите.
Порядок колонок в индексе — решение под конкретный WHERE. Не «все фильтруемые поля подряд». Ошиблись порядком — индекс есть, в EXPLAIN его нет.
Leftmost prefix
Равенство по ведущей колонке сужает поиск до непрерывного диапазона листьев. Диапазон по ведущей колонке размазывает всё, что правее: внутри этого диапазона вторая колонка уже не отсортирована глобально.
В новых версиях Postgres появляются элементы skip scan (пропуск по неведущей колонке). На практике всё ещё считают: без ведущей колонки классический B-tree не берётся, пока не доказали обратное EXPLAIN.
Равенство левее, диапазон правее
Колонки равенства ставят левее, диапазон — правее. Индекс (user_id, created_at) закрывает WHERE user_id = $1 AND created_at > $2. И сортировку по created_at для одного пользователя: внутри одного user_id время уже лежит по порядку. LIMIT 20 может остановиться рано.
Пример. Лента пользователя: WHERE user_id = 42 ORDER BY created_at DESC LIMIT 20. Индекс (user_id, created_at) — сначала нашли пользователя, потом пошли по времени. Индекс (created_at, user_id) для того же запроса слабее: диапазон по времени широкий, user_id внутри него перемешан.
Наоборот, (created_at, user_id) для фильтра «один пользователь + период» почти не помогает. Планировщик либо просканирует много листьев с Filter, либо уйдёт в Seq Scan.
CREATE INDEX idx_events_user_ts ON events (user_id, created_at);
-- берёт индекс как prefix
SELECT *
FROM events
WHERE user_id = 42
AND created_at >= '2024-01-01';
SELECT *
FROM events
WHERE user_id = 42
ORDER BY created_at DESC
LIMIT 20;
-- не берёт как leftmost prefix
SELECT *
FROM events
WHERE created_at >= '2024-01-01';
Индекс (user_id, created_at) закрывает фильтр по пользователю, диапазон по времени и сортировку ленты. Запрос только по created_at этот prefix не использует.
Избыточные индексы и направление сортировки
Два индекса (a) и (a, b) часто дублируют работу: составной уже покрывает leftmost по a. Узкий индекс имеет смысл, если он заметно меньше, иначе устроен INCLUDE, или если fillfactor и паттерн записи другие. Не удаляйте «лишний» индекс вслепую — сначала pg_stat_user_indexes и реальные запросы.
Индекс (a, b ASC) умеет backward scan по b. Смесь ORDER BY a, b DESC при возрастающем a уже может потребовать сортировки. Направление колонок в CREATE INDEX задают под реальный ORDER BY.
Типичная ошибка
Создать индекс «по всем колонкам из WHERE» в том порядке, как они написаны в SQL, без мысли, какая колонка равенство, а какая диапазон. Второй частый промах — ждать, что индекс (a, b) ускорит WHERE b = $1. В классическом B-tree без ведущей колонки поиск не начинается.
На собеседовании
Метафора телефонной книги закрывает leftmost prefix. Дальше — порядок колонок под конкретный WHERE + ORDER BY + LIMIT: равенство левее диапазона. Если дойдут до деталей — когда узкий индекс (a) всё же нужен рядом с (a, b).