Learning Platform
Урок 14.03 · 17 мин
Начальный
Index usageLIKE patternsExpression indexType mismatchSargability

В предыдущих уроках мы видели одну и ту же сцену: «индекс есть, но PostgreSQL делает Seq Scan». Это бывает по двум причинам: либо оптимизатор посчитал, что Seq Scan дешевле (на маленьких таблицах — часто), либо запрос написан так, что индекс физически не может быть использован. Второе называется

non-sargable query
, и это самая частая причина мистических «индекс не работает».

Этот урок — про обе категории. Сначала пройдём, где 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.

Какие LIKE-паттерны индексируются

B-tree поддерживает только префиксное сравнение. Шаблоны с лидирующим % требуют полнотекстовых индексов.

LIKE 'abc%'индекс работаетЭквивалентно диапазону 'abc' ≤ x < 'abd'. B-tree это умеет.
LIKE 'abc'индекс работает
= 'abc'индекс работает
LIKE '%abc'индекс игнорируетсяНет начального префикса — спуститься по дереву некуда. Будет Seq Scan.
LIKE '%abc%'индекс игнорируется
LIKE '_bc'индекс игнорируется_ заменяет один символ — но начальный символ всё равно неизвестен.

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 на индексированной колонке. Создадим индекс и сравним планы:

PostgreSQL

Где индекс молча игнорируется

Теперь самое интересное — 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]';

Решение —

expression index
:

CREATE INDEX idx_customers_email_lower ON customers(LOWER(email));

После этого запрос WHERE LOWER(email) = ... будет использовать индекс. Это очень частый паттерн для case-insensitive поиска.

Сравним планы с обычным индексом и expression index:

PostgreSQL

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. Это специфика, на которой не стоит застревать в базовом курсе. Если очень нужно — есть

partial index
с явным условием.

Чек-лист sargable / non-sargable

Перед тем как «индекс не работает», проверь, не написал ли ты non-sargable запрос:

Sargable: можно использовать индекс. Non-sargable: нельзя.

Левая колонка — формы, которые B-tree поддерживает. Правая — формы, которые ломают индекс. Чинятся либо переписыванием, либо expression index.

sargableиндекс используется
col = valueравенство
col > valueдиапазон
col BETWEEN a AND bдиапазон
col LIKE 'abc%'prefix
col IN (a, b, c)несколько равенств
ORDER BY colчтение по индексу
non-sargableиндекс игнорируется
LOWER(col) = ...функция на колонкеЧини expression index: CREATE INDEX ... (LOWER(col))
col + 1 = 6вычисление на колонкеПерепиши: col = 5
col LIKE '%abc'суффиксЧини GIN + pg_trgm для произвольных подстрок
col::TEXT = ...cast на колонке
col != valueнеравенство — обычно Seq ScanНеравенство почти всегда оставляет большую долю строк.

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 умеет это через

BitmapOr
: строит битовые карты страниц по каждому условию отдельно, потом объединяет их через OR.

-- Если есть индексы и по 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 только для непродельванных заказов и сравним:

PostgreSQL

Покажем, что prefix-LIKE работает, а суффикс-LIKE — нет:

PostgreSQL
Проверка знанийKnowledge check
У тебя таблица users (id, email, created_at) с индексом по email. В коде ты ищешь так: ```sql SELECT * FROM users WHERE LOWER(email) = LOWER($1); ``` Почему индекс не используется и какие у тебя есть варианты починить?
ОтветAnswer
Индекс по email хранит исходные значения "[email protected]" и "[email protected]". А WHERE применяет LOWER к колонке — спуститься по дереву нельзя, потому что для каждой строки сначала надо вычислить LOWER. Варианты починки: (1) Сделать expression index: CREATE INDEX idx_users_email_lower ON users(LOWER(email)). После этого тот же запрос будет использовать индекс — потому что предикат в WHERE точно совпадёт с выражением в индексе. (2) Привести данные к нижнему регистру при вставке (CONSTRAINT CHECK email = LOWER(email) или citext тип) — тогда LOWER не нужен вообще. (3) Использовать citext (case-insensitive text) — специальный тип в PG, у которого = регистронезависимое. Самый практичный — вариант 1, не нужно менять схему.
Partial и expression indexes — глубокий разбор Как оптимизатор оценивает selectivity предиката

Чек-лист

  • 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. Для других типов индексов своя терминология и свои правила.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Какой из этих запросов НЕ сможет использовать обычный B-tree индекс по колонке `email`?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6