37.1 Индекс - это отсортированный список
Держи в голове одну картинку: B-tree-индекс - это данные, отсортированные по ключу, плюс ссылки на строки. Из этой картинки следует всё остальное.
Отсортированный список даёт три вещи бесплатно: быстрый поиск по
значению (бинарный), быстрый поиск по диапазону (соседние записи
рядом) и готовый порядок для ORDER BY (данные уже упорядочены).
Чего он не даёт: искать по тому, что не в начале ключа. Если список
отсортирован по фамилии, найти человека по имени он не помогает -
имена разбросаны по всему списку.
Эта модель объясняет и составные индексы, и ORDER BY-оптимизацию,
и sargability. Дальше просто применяем её к случаям.
37.2 Составной индекс и правило префикса
Индекс (a, b, c) сортирует записи сначала по a, при равенстве -
по b, при равенстве - по c. Как телефонный справочник: сначала
фамилия, потом имя, потом отчество.
Отсюда правило префикса: индекс помогает запросам, которые фиксируют ключ слева направо без пропусков:
WHERE a = 1- да (префикс длины 1);WHERE a = 1 AND b = 2- да (префикс длины 2);WHERE a = 1 AND b = 2 AND c = 3- да (полный ключ);WHERE b = 2- нет:bне в начале, записи сb = 2разбросаны по всему индексу;WHERE a = 1 AND c = 3- частично: поможет поa,cотфильтрует уже прочитанное.
Поэтому порядок колонок - главное решение в составном индексе. Колонки с равенством ставят раньше, колонку с диапазоном или сортировкой - в конец. Подробнее - в index-design.
37.3 ORDER BY без сортировки
Раз индекс отсортирован, он может отдать строки сразу в нужном
порядке - без отдельного шага Sort. Это особенно ценно с LIMIT:
-- частый паттерн: последние записи в ветке
SELECT * FROM repos
WHERE forked_from_id = 42
ORDER BY updated_at DESC
LIMIT 10;
Индекс (forked_from_id, updated_at DESC) обслуживает это идеально:
по forked_from_id = 42 он находит нужный участок, а внутри него
записи уже отсортированы по updated_at DESC - бери первые десять
и стоп. Без такого индекса план - Seq Scan плюс Sort всей выборки,
и его стоимость на порядки выше. Это типичный кейс «было 2.8M cost,
стало 80»: добавили правильный составной индекс - и сортировка
исчезла.
37.4 Покрывающие и частичные индексы
Два приёма, сокращающих работу ещё сильнее.
Покрывающий индекс кладёт в лист недостающие колонки через
INCLUDE, чтобы запрос обслуживался Index-Only Scan без обращения
к таблице (см. главу 32):
CREATE INDEX ON repos (forked_from_id, updated_at DESC) INCLUDE (name);
Частичный индекс индексирует не всю таблицу, а только строки, проходящие условие:
CREATE INDEX ON tasks (created_at) WHERE status = 'active';
Если 99% задач завершены, а запросы всегда про активные, частичный индекс в разы меньше полного и быстрее - в нём только нужные строки. Он же снижает цену записи: строки вне условия индекс не трогают.
37.5 Индексы по выражению
Когда запрос фильтрует по результату функции, обычный индекс по колонке не подходит (условие не sargable). Решение - индекс по тому же выражению:
-- запрос: WHERE lower(email) = 'a@b.c'
CREATE INDEX ON users (lower(email));
Теперь выражение lower(email) проиндексировано, и запрос его
использует. Индекс по выражению вычисляет функцию при вставке и
хранит результат - поэтому функция должна быть IMMUTABLE (давать
один и тот же результат для одних входов). Тот же приём - для
(created_at::date), json-извлечений, конкатенаций. Связь с
sargability - в sargability.
37.6 Цена записи и неиспользуемые индексы
У индексов есть обратная сторона: каждый из них обновляется на каждый INSERT, и на UPDATE неиндексируемых колонок тоже (если это не HOT-update). Пять индексов на таблице - это пятикратная работа на запись. Индекс, который не используется ни одним запросом, - чистый убыток: занимает место, тормозит запись, ничего не ускоряет.
Найти такие легко:
SELECT relname, indexrelname, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
idx_scan = 0 за разумный период работы значит «индексом ни разу не
воспользовались». Такие индексы - кандидаты на удаление. То же с
дублирующими: два индекса с одинаковым набором колонок в одном
порядке - один лишний.
37.6.1 Подводный камень: индексов тоже бывает слишком много
Соблазн «навешать индексов на всякий случай» наказывается дважды. Первый раз - записью: больше индексов, медленнее INSERT/UPDATE. Второй раз - планированием. Планировщик рассматривает все применимые индексы, и на таблице с десятками индексов (особенно частичных) время планирования заметно растёт - он перебирает больше вариантов на каждый запрос.
Крайний случай - «тысяча частичных индексов» под каждый частный запрос: запись еле ползёт, а планирование само по себе становится узким местом. Правило простое: индекс заводят под доказанную потребность (запрос в плане берёт Seq Scan там, где должен брать индекс), а не впрок. Меньше индексов, но точных, почти всегда лучше, чем много на всякий случай.
37.7 Чеклист проектирования
Собираем часть VII в порядок решений при создании индекса.
- Какой оператор в условии? Это задаёт тип индекса через operator
class:
=/диапазон/ORDER BY- B-tree;@>/полнотекст - GIN; пересечение/гео - GiST; огромный append-only по диапазону - BRIN. - Условие sargable? Колонка не должна быть под функцией; иначе индекс по выражению.
- Несколько колонок? Порядок: равенство раньше, диапазон/сортировка в конец; правило префикса.
- Можно покрыть запрос?
INCLUDEпод Index-Only Scan. - Запрос всегда по подмножеству? Частичный индекс с
WHERE. - Не во вред ли записи? Проверь, что индекс реально используется
(
pg_stat_user_indexes), удали неиспользуемые и дубли.
Этот чеклист - прикладной итог всей части. В капстоуне он соберётся с диагностикой планов и статистики в единый алгоритм оптимизации.
Уроки в sandbox
lab-37.1. Составной индекс против Seq Scan плюс Sort
Возьмём частый паттерн «последние записи в ветке» и уберём сортировку правильным составным индексом. Перед каждым EXPLAIN предскажи, будет ли в плане узел Sort.
Создай таблицу:
CREATE TABLE repos AS SELECT g id, (g % 5000) AS forked_from_id, '2026-01-01'::timestamptz + (g||' min')::interval AS updated_at, 'repo'||g AS name FROM generate_series(1, 500000) g; ANALYZE repos;.Запрос без индекса:
EXPLAIN SELECT * FROM repos WHERE forked_from_id = 42 ORDER BY updated_at DESC LIMIT 10;- предскажи (Seq Scan + Sort, большой cost).Создай составной индекс:
CREATE INDEX ON repos (forked_from_id, updated_at DESC); ANALYZE repos;.Повтори EXPLAIN - предскажи и проверь: Index Scan, сортировка исчезла, cost упал на порядки.
Проверь правило префикса:
EXPLAIN SELECT * FROM repos WHERE updated_at > '2026-01-02';- индекс по (forked_from_id, updated_at) не подходит для условия только по второй колонке (Seq Scan).Найди неиспользуемые индексы:
SELECT indexrelname, idx_scan FROM pg_stat_user_indexes WHERE relname='repos';- какие idx_scan ещё нулевые.
sandbox с автопроверкой - открыть в песочнице
Резюме
- Ментальная модель: индекс - отсортированный список ключей со ссылками на строки; из неё следуют поиск, диапазоны, порядок и правило префикса.
- Составной индекс (a,b,c) сортирует по a, затем b, затем c; помогает запросам по префиксу слева направо, но не по одной средней колонке.
- Порядок колонок - главное решение: равенство ставят раньше, диапазон и сортировку - в конец; (forked_from_id, updated_at DESC) убирает Sort для ORDER BY+LIMIT.
- Покрывающий индекс (INCLUDE) даёт Index-Only Scan; частичный (WHERE) индексирует только нужные строки - меньше размер, быстрее, дешевле запись.
- Индекс по выражению нужен, когда условие по функции (lower(email)); функция должна быть IMMUTABLE.
- Каждый индекс - налог на запись; неиспользуемые (idx_scan=0) и дублирующие индексы удаляют через pg_stat_user_indexes.
- Слишком много индексов вредит и записи, и планированию (перебор вариантов); индекс заводят под доказанную потребность, а не впрок.
Контрольные вопросы
Почему индекс `(a, b, c)` помогает запросу `WHERE a = 1`, но не `WHERE b = 2`?
Показать ответ
Потому что индекс - отсортированный список: записи упорядочены сначала по a, при равенстве по b, потом по c. Условие по a фиксирует начало ключа, и все подходящие записи лежат непрерывным участком - их легко найти. А записи с b = 2 разбросаны по всему индексу: внутри каждого значения a свой блок b, и нет одного непрерывного участка с b = 2. Найти их по индексу не проще, чем пройти всю таблицу. Это правило префикса: индекс работает для ключа слева направо без пропусков.
Как составной индекс убирает шаг Sort в `ORDER BY ... LIMIT`?
Показать ответ
Индекс хранит записи уже отсортированными по ключу. Для запроса
WHERE forked_from_id = 42 ORDER BY updated_at DESC LIMIT 10индекс(forked_from_id, updated_at DESC)по равенству находит участок строк с forked_from_id = 42, а внутри него строки уже идут в порядке updated_at DESC. Планировщику достаточно взять первые десять и остановиться - отдельная сортировка не нужна. Без такого индекса пришлось бы прочитать все подходящие строки и отсортировать (Seq Scan + Sort), что на порядки дороже.Когда частичный индекс лучше полного?
Показать ответ
Когда запросы всегда обращаются к подмножеству строк, а это подмножество - малая доля таблицы. Например, при
status = 'active', когда активных задач 1%, а завершённых 99%. Частичный индексWHERE status = 'active'содержит только активные строки: он в разы меньше полного, быстрее в поиске и дешевле в обслуживании, потому что строки вне условия его не трогают при записи. Если же запросы обращаются ко всем строкам, частичный индекс не покроет часть из них и не подойдёт.Зачем нужен индекс по выражению и какое требование к функции?
Показать ответ
Он нужен, когда запрос фильтрует по результату функции, например
WHERE lower(email) = 'a@b.c'. Обычный индекс по email к такому условию не подходит - оно не sargable (колонка под функцией). Индекс по выражениюlower(email)хранит уже вычисленный результат, и запрос его использует. Требование: функция должна быть IMMUTABLE - давать один и тот же результат для одних и тех же входов, иначе индекс мог бы рассогласоваться с данными.Чем вредит лишний индекс, если он «просто на всякий случай»?
Показать ответ
Дважды. Во-первых, записью: каждый индекс обновляется на INSERT и на UPDATE индексируемых колонок, поэтому лишние индексы напрямую замедляют запись и занимают место. Во-вторых, планированием: планировщик рассматривает все применимые индексы, и на таблице с десятками индексов время планирования растёт. Неиспользуемый индекс (idx_scan = 0) - чистый убыток. Поэтому индексы заводят под доказанную потребность, а неиспользуемые и дублирующие находят через pg_stat_user_indexes и удаляют.