Быстрый старт в SQL: как писать WHERE так, чтобы индексы действительно использовались
Поймём, почему запрос может быть медленным даже с индексом: не sargable условия, функции на колонках, неправильный порядок фильтров. Разберём типовые паттерны и дадим рецепты.
Содержание
Быстрый старт в SQL: как писать WHERE так, чтобы индексы действительно использовались
Индекс — это одно из самых эффективных средств ускорения выборки в реляционных базах. Но на практике часто случается обратное: вы видите подходящий индекс в схеме, а запрос всё равно выполняется медленно. Причина обычно не в “плохих настройках сервера” и не в “мало данных”, а в том, что условия в WHERE написаны так, что оптимизатор не может использовать индекс.
В этой статье разберём, почему запрос может быть медленным даже при наличии индекса, и как писать WHERE, чтобы условия были sargable (Search ARGument ABLE) — то есть такими, которые позволяют СУБД превратить ваш фильтр в эффективный индексный поиск. Покажем типовые анти-паттерны: функции на колонках, приведение типов, “неявные” преобразования, неправильные шаблоны сравнения и условия, которые ломают план выполнения. Затем дадим практические рецепты и короткие примеры кода.
Как оптимизатор “думает” про индексы: sargable против non-sargable
Чтобы индекс реально ускорял запрос, оптимизатор должен иметь возможность:
- Применить условие к колонке в неизменном виде (или хотя бы так, чтобы выражение можно было разложить на диапазоны/равенства).
- Использовать индекс для ограничения набора строк до выполнения остальной части запроса.
Когда условие в WHERE содержит выражение, результат которого зависит от преобразования самой колонки (например, WHERE LOWER(name) = 'john'), оптимизатор обычно не может “обратным ходом” определить, какие значения в индексе дадут нужный результат. В этом случае часто выбирается полный перебор или фильтрация после чтения данных из таблицы — индекс теряет смысл.
Термин sargable не стандартен везде одинаково, но смысл одинаков: условие должно быть таким, чтобы поиск по индексу можно было сформулировать как поиск по значениям или диапазонам.
Минимальный тест: есть ли индексный поиск?
Даже без глубокого понимания планов полезно знать, как выглядит “правильный” план: вы увидите Index Seek/Index Scan (в зависимости от СУБД и ситуации), а не Table Scan на десятки миллионов строк. В SQL это диагностируется через EXPLAIN/EXPLAIN ANALYZE.
Антипаттерн №1: функции на колонках в WHERE
Плохой паттерн: вы меняете значение колонки
Самый распространённый пример:
SELECT *
FROM users
WHERE LOWER(email) = 'test@example.com';
Идея понятная: сравниваем адрес без учёта регистра. Проблема в том, что выражение применяет функцию к колонке email. Для индекса по email оптимизатор не сможет использовать его напрямую: индекс хранит исходные значения, а вы сравниваете преобразованные.
Чем это заканчивается
Часто СУБД вынуждена прочитать много строк и применить LOWER уже на них.
Исправление: привести константу, а не колонку
Вместо функции на колонке попробуйте нормализовать константу:
SELECT *
FROM users
WHERE email = 'test@example.com';
Звучит банально, но вопрос в реальности: у вас действительно хранится email в одном регистре? Если да — это лучший вариант.
Если нужны “функциональные” индексы
Если требуется именно сравнение без регистра и данные могут храниться по-разному, корректный путь зависит от СУБД:
- В PostgreSQL есть функциональные индексы:
CREATE INDEX ... ON users (lower(email)); - В SQL Server — индекс по вычисляемому (computed) столбцу или колlation/case-insensitive настройки.
- В MySQL — варианты зависят от collations: иногда правильная сортировка/сравнение решает задачу без функций.
В PostgreSQL типичный подход:
CREATE INDEX users_email_lower_idx ON users (lower(email));
SELECT *
FROM users
WHERE lower(email) = lower('test@example.com');
Теперь условие стало sargable относительно функционального индекса.
Антипаттерн №2: приведение типов (implicit/explicit casts)
Если тип константы и тип колонки различаются, СУБД может выполнить преобразование. Иногда преобразование применится к колонке — и индекс снова “ломается”.
Пример: строка вместо числа
SELECT *
FROM orders
WHERE customer_id = '42';
Если customer_id — INT, то '42' будет преобразован, и иногда это безопасно. Но на практике важно проверять: в некоторых случаях оптимизатор может предпочесть план без индекса.
Рекомендация: приводите константу к типу колонки
SELECT *
FROM orders
WHERE customer_id = 42;
Более тонкий случай: даты и строки
SELECT *
FROM events
WHERE event_date >= '2026-01-01';
Если event_date — DATE или TIMESTAMP, то строка должна корректно парситься. Но если формат строк “неочевидный”, или СУБД вынуждена применять преобразование, индекс может перестать работать.
Лучший стиль — использовать литералы подходящего типа:
-- PostgreSQL:
SELECT *
FROM events
WHERE event_date >= DATE '2026-01-01';
Антипаттерн №3: выражения в обеих сторонах сравнения
Когда и колонка, и константа преобразуются, оптимизатору часто сложнее.
Плохой паттерн
SELECT *
FROM products
WHERE (price * 1.2) > 1000;
Если у вас есть индекс на price, он не поможет: вы ищете по выражению от колонки.
Рецепт: переносите вычисления в константу
SELECT *
FROM products
WHERE price > 1000 / 1.2;
Да, это арифметически то же самое (если нет нюансов с округлением/типами), но SQL становится sargable относительно price.
Антипаттерн №4: диапазоны “ломающие” условия и неаккуратные сравнения
Индекс особенно полезен на диапазонах (>=, <=) и на точных равенствах. Проблемы возникают, когда сравнение не позволяет оптимизатору выделить простой диапазон.
Плохой паттерн: функция по дате/времени
Например, поиск по “дню”:
SELECT *
FROM logs
WHERE DATE(created_at) = '2026-07-01';
Если created_at — TIMESTAMP, то DATE(created_at) применяет функцию к колонке — индекс обычно не используется.
Правильный паттерн: диапазон по исходной колонке
SELECT *
FROM logs
WHERE created_at >= TIMESTAMP '2026-07-01 00:00:00'
AND created_at < TIMESTAMP '2026-07-02 00:00:00';
Это классический рецепт. Он:
- сохраняет возможность применить индекс по
created_at; - корректно работает для таймзон и временной составляющей (при правильном типе литералов);
- не требует вычисления
DATE()на каждой строке.
Антипаттерн №5: OR-условия без понимания плана
OR сам по себе не запрещён, но он часто усложняет оптимизацию. Если OR “широкий” или условия на разных колонках, оптимизатор может выбрать неиндексный план.
Проблемный запрос
SELECT *
FROM users
WHERE status = 'active' OR email = 'test@example.com';
Если есть индексы и на status, и на email, теоретически возможно использование “bitmap/index union” (в зависимости от СУБД). Но на практике многое зависит от кардинальности, статистики, распределения данных.
Рецепт: иногда помогает разнести OR на UNION ALL
SELECT *
FROM users
WHERE status = 'active'
UNION ALL
SELECT *
FROM users
WHERE email = 'test@example.com';
Это может дать индексный путь по каждой ветке. Но важно помнить:
- если данные могут пересекаться,
UNION ALLдаст дубликаты. Иногда нуженUNION(но он дороже); - план может измениться в зависимости от размеров выборки.
Поэтому правило такое: сначала проверьте EXPLAIN, затем принимайте решение.
Антипаттерн №6: неправильный порядок условий — миф и реальность
В SQL логика WHERE декларативная: оптимизатор сам решает, что и как исполнять. “Порядок фильтров” в тексте запроса обычно не влияет на то, какой индекс используется.
Но есть нюанс: порядок может влиять косвенно, если выражения содержат:
- недетерминированные функции;
- ошибки вычисления;
- дорогие предикаты, которые могут оцениваться до/после (в зависимости от СУБД и оптимизации);
- коррелированные подзапросы.
Однако в большинстве современных движков при чистых предикатах “переставьте условия — и всё станет быстро” не работает.
Практически полезный вывод: думайте не про порядок, а про форму предикатов (sargable vs non-sargable). Именно она определяет, сможет ли оптимизатор построить индексный план.
Как писать WHERE, чтобы условия были sargable: базовые шаблоны
Ниже набор рецептов, которые в реальной разработке чаще всего спасают от “почему индекс не используется”.
1) Равенство по колонке
Ок:
WHERE customer_id = 42
Плохо:
WHERE CAST(customer_id AS VARCHAR) = '42'
2) Диапазон по “сырой” колонке
Ок:
WHERE created_at >= :start
AND created_at < :end
Плохо:
WHERE DATE(created_at) = :day
3) Предикаты с вычислениями — перенос в константы
Плохо:
WHERE (price - discount) > 100
Ок (если корректно):
WHERE price > 100 + discount
(или вынести вычисление в параметр/подготовить заранее в приложении)
4) Избегайте функций на колонках
Плохо:
WHERE LOWER(name) LIKE 'ann%'
Ок (если возможно): либо функциональный индекс, либо хранение в нормализованном виде, либо подходящая collation.
5) LIKE: индекс может работать, но зависит от префикса
Индекс по строке обычно используется для LIKE 'prefix%', но часто не для LIKE '%suffix' или LIKE '%middle%'.
Пример:
WHERE email LIKE 'test@domain.com%'
Если у вас шаблоны с ведущим %, рассмотрите альтернативы: full-text/триграммы/отдельные индексы под pattern.
NULL и тройная логика: как не потерять индексный поиск
NULL — отдельная ловушка. В SQL нельзя писать “как в математике”: col = NULL не сработает.
Правильно для NULL
WHERE deleted_at IS NULL
Это часто sargable и обычно использует индекс (если он есть). А вот:
Плохо
WHERE deleted_at = NULL
В результате оптимизатор может выбрать неверные стратегии или вы получите пустую выборку.
LIKE, ILIKE и регистр: что реально с индексами
Если вы используете сравнение без регистра, то “функции на колонках” часто всплывают снова:
LOWER(col) = 'x'ILIKEв PostgreSQL- case-insensitive сравнения в других СУБД
Решение опять же в функциональных индексах или в правильной схеме хранения/сравнения:
- привести данные к единому регистру при записи;
- использовать collation, где сравнение case-insensitive;
- создать индекс по выражению
lower(col).
Без одного из этих шагов запрос почти всегда превращается в сканирование.
Проверяем на практике: EXPLAIN как инструмент, а не формальность
Чтобы понять, что именно “ломает” индекс, смотрите планы выполнения. Подход:
- Возьмите запрос-минимум (один
WHERE+ простыеSELECT). - Смотрите план без лишних JOIN’ов.
- Поменяйте одно условие за раз и повторите
EXPLAIN. - Сверьте, исчез ли
Index Seek/Bitmap Index Scan.
Ниже универсальная идея на примере PostgreSQL:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM logs
WHERE DATE(created_at) = DATE '2026-07-01';
Потом сравните с диапазоном:
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM logs
WHERE created_at >= TIMESTAMP '2026-07-01 00:00:00'
AND created_at < TIMESTAMP '2026-07-02 00:00:00';
Разница часто видна сразу по типу сканирования.
Индексы ≠ гарантия: кардинальность, статистика и “не тот” индекс
Важно честно признать: даже sargable условие не всегда даст выигрыш.
Причины:
- индекс есть, но он не подходит под предикат (например, вы фильтруете по
B, а индекс поA); - индекс частично подходит, но оценка оптимизатора считает сканирование дешевле (неверная статистика);
- условия слишком “широкие”, и почти все строки попадают под фильтр — индекс становится менее выгодным;
- вы фильтруете по неравномерной колонке без актуальной статистики.
Поэтому дисциплина такая: сначала приведите WHERE к sargable форме, затем убедитесь, что индекс действительно соответствует фильтру (включая порядок колонок в составных индексах), и только потом обсуждайте настройки/статистику.
Составные индексы и “порядок” колонок: почему иногда индекс не используется
В составных индексах ((a, b, c)) критичен префикс. Часто кажется: “я фильтрую по b, значит индекс должен помочь”. Но если a в предикате отсутствует, индекс может работать хуже или не использоваться.
Типовой принцип B-tree индексов:
- Если запрос имеет
WHERE a = ...и далееAND b = ..., индекс по(a, b)используется эффективно. - Если запрос имеет только
WHERE b = ...без условия наa, СУБД обычно не сможет нормально воспользоваться индексом (зависит от реализации, но чаще — нет).
Решение — менять индекс под запросы или переписывать запрос так, чтобы условия соответствовали индексной структуре.
Практические рецепты: “переписать WHERE” без магии
Соберём всё в короткий чеклист, который полезно держать рядом при ревью запросов.
Чеклист sargability
- Нет ли функции на колонке в
WHERE(LOWER,DATE, арифметика, каст)? - Нет ли явного/неявного приведения типа колонки или значения?
- Диапазоны по датам заданы как
[>= start, < end)? - Для
LIKEиспользуется префиксprefix%, а не'%suffix'? -
ORне превращает предикаты в “всё сразу” без альтернативы (иногдаUNION ALLлучше)? - Предикаты соответствуют составным индексам (есть ли условие по префиксным колонкам)?
- Есть ли корректные индексы, включая функциональные там, где без функций не обойтись?
Как подойти к теме системно: от типичных ошибок к устойчивым навыкам
Многие разработчики учатся индексации “методом проб”: добавили индекс — запрос стал быстрее/не стал. Это полезно, но не даёт устойчивого понимания, почему именно.
Гораздо надёжнее — сначала освоить форму предикатов и принципы sargability. Когда вы понимаете, какие конструкции ломают поиск по индексу, вы быстрее находите причину медленности и меньше зависите от “угадывания”.
Если вы новичок и вам нужно привести базу по SQL в порядок (включая конструкции WHERE, логические операторы, сравнение с NULL, базовые типы и выражения), стартовать можно с курса формата «SQL – для начинающих!» — он как раз помогает закрыть многие фундаментальные пробелы, которые потом проявляются в производительности запросов. Например, через него удобнее пройти правила построения условий и избежать типичных ошибок, которые мы разобрали выше.
Выводы
Когда запрос медленный “несмотря на индекс”, почти всегда виновата форма WHERE, а не наличие самого индекса. Ключевая идея — sargable условия: предикаты должны позволять оптимизатору превратить фильтр в индексный поиск или индексный диапазон.
Главные источники проблем:
- функции на колонках (
LOWER(col),DATE(created_at)) — превращают запрос в сканирование; - приведение типов и неявные преобразования — ломают соответствие типам индекса;
- вычисления по колонке в предикате — лучше переносить в константы/параметры;
- неаккуратная фильтрация по датам и времени — заменяйте
DATE(col)=...на диапазон; ORи шаблоныLIKE— требуют внимательного рассмотрения, а иногда переписы
Комментарии
Пока нет комментариев