В предыдущих уроках мы видели одну и ту же сцену: «индекс есть, но PostgreSQL делает Seq Scan». Это бывает по двум причинам: либо оптимизатор посчитал, что Seq Scan дешевле (на маленьких таблицах — часто), либо запрос написан так, что индекс физически не может быть использован. Второе называется
Этот урок — про обе категории. Сначала пройдём, где B-tree точно помогает. Потом — где он молча отключается, и как это чинить.
Важная оговорка: всё, что мы говорим про «sargable vs non-sargable», — это про B-tree. Для GIN/GiST/BRIN свои правила. Но 90% случаев в проде — это именно B-tree, поэтому полезно сначала освоить эти правила, а потом обобщать.
Где B-tree работает
1. Равенство
Самый очевидный случай. WHERE col = value. Индекс по col — почти всегда используется (если таблица больше пары сотен строк).
SELECT * FROM customers WHERE email = '[email protected]';
Спустились по дереву, нашли ключ, прочитали строку. Index Scan или Index Only Scan (если все нужные колонки уже в индексе — об этом в уроке 5).
2. Диапазон
WHERE col BETWEEN a AND b, WHERE col > x, WHERE col < y, и сочетания. B-tree упорядочен — после спуска к началу диапазона дальше идёт линейный проход по листьям.
SELECT * FROM orders WHERE placed_at >= '2025-04-01';
Эффективно, если диапазон выбирает небольшую долю таблицы — обычно до 10%. Если выбирается половина — оптимизатор скорее предпочтёт Seq Scan.
3. Prefix LIKE
WHERE col LIKE 'prefix%' — индекс работает, потому что префиксное сравнение эквивалентно диапазону: «все строки от ‘prefix�’ до ‘prefix’». B-tree это умеет.
SELECT * FROM products WHERE sku LIKE 'LP-%';
А вот WHERE col LIKE '%suffix' или WHERE col LIKE '%middle%' — не работает, потому что нет начального префикса, по которому можно спуститься в дерево. PostgreSQL сделает Seq Scan. Для таких случаев существуют специальные индексы (pg_trgm через GIN), но это уже за рамками базового B-tree.
B-tree поддерживает только префиксное сравнение. Шаблоны с лидирующим % требуют полнотекстовых индексов.
4. ORDER BY по индексированной колонке
Если запрос сортируется по той же колонке, по которой есть индекс, и порядок совпадает (или строго обратный) — PostgreSQL может прочитать данные сразу в нужном порядке, не делая отдельной сортировки. Особенно ценно для ORDER BY ... LIMIT N: первые N строк находятся за пару чтений листьев.
SELECT * FROM orders ORDER BY placed_at DESC LIMIT 10;
С индексом по placed_at — спустились к последнему листу, прочитали 10 ключей, отдали 10 строк из heap. Без индекса — пришлось бы прочитать все заказы, отсортировать их, отдать 10.
ORDER BY на индексированной колонке. Создадим индекс и сравним планы:
Где индекс молча игнорируется
Теперь самое интересное — case’ы, которые ловят даже опытных разработчиков.
Функция на колонке в WHERE
Если в WHERE ты вызываешь функцию от колонки — LOWER(email), DATE(created_at), email || '!', — то индекс не работает. Причина проста: индекс хранит исходные значения колонки, а не результат функции. Чтобы спуститься по дереву, надо знать значение ключа; функция меняет ключ.
-- Индекс по email НЕ используется:
SELECT * FROM customers WHERE LOWER(email) = '[email protected]';
-- А этот — использует:
SELECT * FROM customers WHERE email = '[email protected]';
Решение —
CREATE INDEX idx_customers_email_lower ON customers(LOWER(email));
После этого запрос WHERE LOWER(email) = ... будет использовать индекс. Это очень частый паттерн для case-insensitive поиска.
Сравним планы с обычным индексом и expression index:
Type mismatch (несовпадение типов)
Колонка customer_id INT, ищешь по customer_id = '5' (строка). PostgreSQL обычно приведёт типы, но в некоторых случаях — особенно с BIGINT против TEXT или JSONB — приведение может сломать использование индекса.
-- Если customer_id INT — это работает, индекс используется:
SELECT * FROM orders WHERE customer_id = 5;
-- А если в коде попало '5' как TEXT — может быть Seq Scan,
-- особенно с явным cast: WHERE customer_id::TEXT = '5'.
Решение — всегда передавать в запрос правильный тип, и не делать CAST на колонке. Если нужно сравнить с текстом — приводи именно литерал, не колонку: customer_id = CAST('5' AS INT).
Маленькая таблица
Это не «игнорируется неправильно», это «игнорируется правильно». Если в таблице 100 строк — Seq Scan стоит копейки. Спуск по дереву + чтение из heap — тоже копейки, но чуть больше. Оптимизатор честно выбирает дешевле.
Для нас, как для авторов запросов, это значит: не делайте выводов о работе индексов на тестовых данных в 50 строк. Прогоняйте на реалистичных объёмах, или хотя бы на копии prod-данных. Иначе все ваши «индекс не работает!» окажутся ложной тревогой.
Слишком большая выборка
Уже упоминали в прошлом уроке: если фильтр оставляет больше ~10% таблицы, Seq Scan обычно выигрывает у Index Scan. Потому что для каждой найденной строки Index Scan делает случайное чтение в heap — а Seq Scan читает heap последовательно, что в разы быстрее на диске.
Это не повод убирать индекс — на других запросах он работает. Это повод не удивляться, когда на конкретном запросе индекс игнорируется.
NULL в индексе
WHERE col IS NULL — индекс по col может использоваться или не использоваться, в зависимости от версии PG и pg_planner_settings. Это специфика, на которой не стоит застревать в базовом курсе. Если очень нужно — есть
Чек-лист sargable / non-sargable
Перед тем как «индекс не работает», проверь, не написал ли ты non-sargable запрос:
Левая колонка — формы, которые B-tree поддерживает. Правая — формы, которые ломают индекс. Чинятся либо переписыванием, либо expression index.
OR и IN: что с ними делать
Ещё две частые конструкции, которые ведут себя по-разному.
WHERE col IN (a, b, c) — это синтаксический сахар для col = a OR col = b OR col = c. PostgreSQL обрабатывает IN как набор равенств и обычно использует индекс эффективно: для каждого значения спускается в дерево и собирает результаты. На небольших IN-списках (до тысячи значений) — отличный план.
WHERE col1 = a OR col2 = b — другая история. Тут нужны два разных индекса (или один composite, но он не поможет — leftmost prefix rule). PostgreSQL умеет это через
-- Если есть индексы и по country, и по status:
SELECT * FROM orders WHERE country = 'RU' OR status = 'cancelled';
-- Может стать BitmapOr из двух Bitmap Index Scan.
Без двух индексов — Seq Scan, потому что один индекс не покрывает оба условия. Это важный паттерн: если у тебя есть частые OR-запросы, ставь индексы на оба условия. Composite не поможет.
Когда индекс физически нельзя создать
Иногда нужен индекс по сложной структуре — JSONB, массиву, полнотекстовому. B-tree таких типов «не понимает» — он умеет только сравнивать ключи целиком. Для них существуют:
- GIN — для JSONB, массивов, полнотекстового поиска.
- GiST — для геоданных, range types.
- BRIN — для огромных таблиц с упорядоченными данными.
В нашем курсе мы их не разбираем — но запомни, что они есть. Когда обычный B-tree не помогает, ответ редко «забей и пиши Seq Scan», чаще — «возьми правильный тип индекса».
Partial index: индекс с условием
Это полезная конструкция, которая много раз встречалась нам в обсуждении антипаттернов. Создать индекс только на подмножество строк:
CREATE INDEX idx_users_active
ON users(id)
WHERE deleted_at IS NULL;
Этот индекс хранит ключи только для активных пользователей. Если в таблице 1М строк, из которых 50К удалённых — partial index в 20 раз меньше обычного.
Когда полезно:
- Soft-delete схема:
WHERE deleted_at IS NULL— почти все запросы фильтруют по этому условию. - Status-based фильтры:
WHERE status = 'pending', если pending — редкое состояние. - Дорогостоящие колонки: если индексируешь длинный текст, но запросы фильтруют только по строкам определённого типа — partial может радикально уменьшить индекс.
Условие в WHERE индекса должно дословно совпадать с условием в запросе (либо быть его «надмножеством»), иначе оптимизатор не сматчит индекс. Это редко работает на «общих» индексах — но для известного, узкого запроса даёт огромный выигрыш.
Создадим partial index только для непродельванных заказов и сравним:
Покажем, что prefix-LIKE работает, а суффикс-LIKE — нет:
Чек-лист
- B-tree помогает: равенство, диапазон, prefix-LIKE,
ORDER BYпо индексированной колонке. - B-tree игнорируется: функция на колонке, суффикс-LIKE, cast на колонке, неравенство.
- Функция на колонке чинится expression index:
CREATE INDEX ... ((expr)). - Type mismatch — приводи литерал к типу колонки, не колонку к типу литерала.
- Маленькая таблица —
Seq Scanнормален, индекс игнорируется правильно. - Большая выборка (больше 10%) —
Seq Scanобычно выигрывает уIndex Scan. IN (...)— sargable, индекс используется.WHERE a = ? OR b = ?— нужны два индекса + BitmapOr.- Partial index — индекс с условием WHERE; экономит место и ускоряет на узких подмножествах.
- JSONB, массивы, fulltext — это уже GIN/GiST, не B-tree.
- Правила «sargable» — про B-tree. Для других типов индексов своя терминология и свои правила.