Tá an t-alt seo i Rúisis faoi láthair — tá an t-aistriúchán Béarla ar siúl.
Индекс в базе данных похож на оглавление в книге.
Если вы ищете главу по названию, вы открываете оглавление и сразу переходите на нужную страницу. Быстро и удобно.
Но если вы сформулировали поиск так, что оглавление уже нельзя использовать, придётся листать книгу целиком.
С SQL происходит то же самое.
Можно создать хороший индекс:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
А потом написать условие так, что база всё равно не сможет нормально использовать этот индекс:
WHERE date(created_at) = '2026-03-15'
На первый взгляд условие понятное: найти заказы за 15 марта.
Но для индекса это неудобно. В индексе лежат значения created_at, а мы просим базу вычислить date(created_at) для каждой строки.
Такой предикат называют несаргабельным.
А хороший предикат, который позволяет базе использовать индекс для поиска, называют sargable.
Что значит sargable
Слово sargable происходит от английского выражения:
Search ARGument ABLE
Проще говоря, условие в WHERE называется sargable, если база может использовать индекс, чтобы быстро найти нужные строки.
Например, есть индекс:
CREATE INDEX idx_users_email
ON users (email);
Хорошее условие:
WHERE email = 'ann@example.com'
База может открыть индекс по email и быстро найти нужную строку.
Плохое условие:
WHERE lower(email) = 'ann@example.com'
Здесь мы уже ищем не само значение email, а результат функции lower(email).
Обычный индекс по email хранит исходные email, а не заранее посчитанные значения lower(email).
Поэтому базе сложнее использовать этот индекс напрямую.
Главная идея такая:
Чем проще условие по индексируемой колонке,
тем больше шансов, что индекс будет использован эффективно.
Базовая таблица для примеров
Будем использовать таблицу заказов:
CREATE TABLE orders (
id bigint PRIMARY KEY,
user_id bigint NOT NULL,
amount numeric(12,2) NOT NULL,
status text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
И таблицу пользователей:
CREATE TABLE users (
id bigint PRIMARY KEY,
email text NOT NULL,
name text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Создадим несколько индексов:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
CREATE INDEX idx_orders_amount
ON orders (amount);
CREATE INDEX idx_users_email
ON users (email);
CREATE INDEX idx_users_name
ON users (name);
Теперь разберём, какие условия помогают этим индексам, а какие мешают.
Простое сравнение — хороший вариант
Самый понятный sargable-предикат — обычное сравнение колонки с константой.
Например:
SELECT id, amount, status
FROM orders
WHERE created_at >= '2026-01-01';
Здесь колонка created_at стоит «голой», без функции, без вычислений и без преобразований.
Индекс:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
может помочь базе быстро найти строки начиная с нужной даты.
То же самое с числом:
SELECT id, amount
FROM orders
WHERE amount > 1000;
Индекс по amount может использоваться для поиска диапазона.
Хорошие условия обычно выглядят так:
WHERE column = value
WHERE column > value
WHERE column >= value
WHERE column < value
WHERE column BETWEEN value1 AND value2
Главное: колонка остаётся сама собой.
Что чаще всего ломает использование индекса
Индекс обычно строится по значениям колонки.
Например:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
В этом индексе лежат значения created_at.
Но если написать:
WHERE date(created_at) = '2026-03-15'
мы уже ищем не created_at, а результат функции date(created_at).
Базе нужно взять каждую строку, применить к ней функцию date(), получить дату без времени и сравнить с '2026-03-15'.
Индекс по исходному created_at в такой ситуации может не помочь так, как вы ожидаете.
Типичные примеры, которые мешают индексу:
WHERE lower(email) = 'ann@example.com'
WHERE date(created_at) = '2026-03-15'
WHERE created_at::date = '2026-03-15'
WHERE EXTRACT(YEAR FROM created_at) = 2026
WHERE amount + 10 > 1000
WHERE salary * 12 > 100000
WHERE coalesce(status, '') = 'paid'
Общий признак:
Над колонкой что-то сделали,
и только потом сравнили результат.
Правило: не трогайте колонку, переносите вычисления вправо
Хорошее практическое правило:
В условии WHERE индексируемая колонка должна оставаться «голой».
Плохо:
WHERE salary * 12 > 100000
Лучше:
WHERE salary > 100000 / 12
В первом варианте база должна вычислить salary * 12 для строк.
Во втором варианте вычисление находится на стороне константы, а колонка salary остаётся обычной колонкой.
Плохо:
WHERE amount + 10 > 1000
Лучше:
WHERE amount > 990
Плохо:
WHERE price * 1.2 <= 600
Лучше:
WHERE price <= 600 / 1.2
Смысл простой: пусть база использует индекс по исходному значению, а не пересчитывает выражение для каждой строки.
Самая частая ошибка с датами
Одна из самых популярных ошибок — искать день через функцию:
SELECT id, amount
FROM orders
WHERE date(created_at) = '2026-03-15';
Логически запрос понятный: найти заказы за 15 марта.
Но технически мы применили функцию date() к колонке created_at.
Если created_at хранит дату и время, например:
2026-03-15 10:30:00
2026-03-15 18:45:00
2026-03-16 01:20:00
то date(created_at) отрежет время и оставит только дату.
Проблема в том, что обычный индекс по created_at хранит полные значения даты и времени, а не результат date(created_at).
Поэтому лучше переписать запрос на диапазон.
Правильный поиск одного дня через диапазон
Вместо:
WHERE date(created_at) = '2026-03-15'
пишите:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Полный запрос:
SELECT id, amount
FROM orders
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16';
Это называется полуоткрытый интервал.
Левая граница включается:
created_at >= '2026-03-15'
Правая граница не включается:
created_at < '2026-03-16'
Так мы берём весь день 15 марта:
2026-03-15 00:00:00
...
2026-03-15 23:59:59.999999
но не захватываем 16 марта.
И главное: колонка created_at остаётся без функции, поэтому индекс по created_at может использоваться нормально.
Почему не стоит писать BETWEEN для дат с временем
Иногда пишут так:
WHERE created_at BETWEEN '2026-03-15' AND '2026-03-15 23:59:59'
Это выглядит удобно, но в датах с временем такой стиль часто опасен.
Почему?
Потому что точность времени может быть выше секунд.
Например, значение:
2026-03-15 23:59:59.500
больше, чем:
2026-03-15 23:59:59
и может не попасть в выборку.
Поэтому для дат и времени лучше использовать полуоткрытый интервал:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Так вы не зависите от точности хранения времени.
Ещё один частый пример:
SELECT id, amount
FROM orders
WHERE EXTRACT(YEAR FROM created_at) = 2026;
Логика понятна: хотим заказы за 2026 год.
Но снова проблема: функция над колонкой.
Лучше написать диапазон:
SELECT id, amount
FROM orders
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01';
Это и понятнее для индекса, и обычно быстрее.
То же самое для месяца:
Плохо:
WHERE date_trunc('month', created_at) = '2026-03-01'
Лучше:
WHERE created_at >= '2026-03-01'
AND created_at < '2026-04-01'
Для недели, квартала и любого периода идея та же: не применяйте функцию к колонке, а задавайте границы диапазона.
Преобразование типа тоже может мешать
Не только функции, но и приведение типа может испортить использование индекса.
Например:
WHERE created_at::date = '2026-03-15'
Это почти то же самое, что:
WHERE date(created_at) = '2026-03-15'
Мы преобразовали колонку created_at к дате без времени.
Значит, база должна выполнить преобразование для строк.
Лучше снова использовать диапазон:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Хороший принцип:
Приводите к нужному типу константу,
а не колонку.
То есть лучше так:
WHERE created_at >= '2026-03-15'::timestamptz
чем так:
WHERE created_at::date = '2026-03-15'
Регистронезависимый поиск email
Теперь разберём пример с email.
Есть индекс:
CREATE INDEX idx_users_email
ON users (email);
И есть запрос:
SELECT id, email
FROM users
WHERE lower(email) = 'ann@example.com';
Так часто пишут, чтобы найти email без учёта регистра.
Но обычный индекс по email не хранит lower(email). Он хранит исходное значение.
Например, в таблице может лежать:
Ann@Example.COM
А в запросе мы сравниваем:
ann@example.com
Если каждый раз применять lower(email), индекс по обычному email становится менее полезным.
Есть несколько нормальных решений.
Решение 1: хранить email уже нормализованным
Самый простой и часто лучший вариант — нормализовать email перед записью.
Например, всегда хранить email в нижнем регистре и без пробелов по краям:
ann@example.com
Тогда запрос остаётся простым:
SELECT id, email
FROM users
WHERE email = 'ann@example.com';
И обычный индекс по email работает хорошо:
CREATE INDEX idx_users_email
ON users (email);
Чтобы не забывать нормализацию, её можно делать:
- в приложении;
- через
BEFORE INSERT OR UPDATE триггер;
- через отдельную нормализованную колонку;
- через generated column.
Например, можно хранить отдельное поле:
email_norm text NOT NULL
и индексировать его:
CREATE INDEX idx_users_email_norm
ON users (email_norm);
Тогда поиск будет таким:
SELECT id, email
FROM users
WHERE email_norm = 'ann@example.com';
Решение 2: использовать citext
В PostgreSQL есть расширение citext, которое даёт регистронезависимый текстовый тип.
Подключается так:
CREATE EXTENSION IF NOT EXISTS citext;
Можно сделать колонку:
email citext NOT NULL
Тогда сравнение:
WHERE email = 'ann@example.com'
будет регистронезависимым.
При этом запрос остаётся обычным равенством по колонке, а это удобно для индексов.
Но citext — это уже осознанное решение на уровне схемы. Его нужно выбирать заранее и понимать последствия для сравнения строк.
Решение 3: индекс по выражению
Иногда функция в условии действительно нужна.
Например, вы не можете изменить хранение email прямо сейчас, но хотите ускорить запрос:
WHERE lower(email) = 'ann@example.com'
Тогда можно создать индекс не по колонке, а по выражению:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
Теперь PostgreSQL хранит в индексе результат выражения lower(email).
И запрос:
SELECT id, email
FROM users
WHERE lower(email) = 'ann@example.com';
сможет использовать этот индекс.
Такой индекс называют функциональным индексом или индексом по выражению.
Но важно: выражение в запросе должно совпадать с выражением в индексе.
Если индекс создан по:
lower(email)
а в запросе написано что-то другое, например:
lower(trim(email))
это уже другое выражение. Такой индекс может не подойти.
Не делайте функциональные индексы там, где достаточно диапазона
Функциональные индексы полезны, но не стоит использовать их без необходимости.
Например, можно создать индекс:
CREATE INDEX idx_orders_created_date
ON orders ((created_at::date));
и потом писать:
WHERE created_at::date = '2026-03-15'
Это может работать.
Но часто лучше и проще писать диапазон:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Почему?
Потому что обычный индекс по created_at более универсален.
Он подходит для:
WHERE created_at >= '2026-03-15'
WHERE created_at < '2026-04-01'
ORDER BY created_at DESC
WHERE created_at >= '2026-03-01'
AND created_at < '2026-04-01'
А индекс по created_at::date полезен только для конкретного выражения.
Каждый индекс занимает место и замедляет вставки и обновления, поэтому не плодите их там, где можно просто переписать условие.
LIKE: когда индекс помогает
Теперь поговорим про текстовый поиск.
Есть индекс:
CREATE INDEX idx_users_name
ON users (name);
Запрос:
SELECT id, name
FROM users
WHERE name LIKE 'Ann%';
ищет имена, которые начинаются с Ann.
Например:
Ann
Anna
Annabelle
Такой поиск называется префиксным.
B-tree индекс может быть полезен для поиска по началу строки, потому что значения в индексе отсортированы.
Условно база может найти место, где начинаются значения на Ann, и читать дальше, пока префикс совпадает.
LIKE с ведущим процентом
А вот такой запрос намного хуже для обычного B-tree индекса:
SELECT id, name
FROM users
WHERE name LIKE '%ann%';
Он ищет подстроку ann где угодно внутри имени.
Например:
Joanna
Brianna
Ann
Проблема в том, что строка может начинаться с чего угодно.
Индекс отсортирован по началу строки, а мы ищем кусок в середине.
Поэтому обычный B-tree индекс не может просто прыгнуть к нужному месту.
Такие условия часто приводят к полному сканированию или требуют других типов индексов.
Практическое правило:
| Условие |
Обычный B-tree индекс |
name LIKE 'Ann%' |
Может помочь |
name LIKE '%ann' |
Обычно не помогает |
name LIKE '%ann%' |
Обычно не помогает |
PostgreSQL и text_pattern_ops
В PostgreSQL есть важная деталь.
Для префиксного LIKE обычный B-tree индекс хорошо работает не во всех настройках локали.
Иногда для надёжного ускорения LIKE 'Ann%' создают специальный индекс с operator class text_pattern_ops:
CREATE INDEX idx_users_name_pattern
ON users (name text_pattern_ops);
Тогда запрос:
SELECT id, name
FROM users
WHERE name LIKE 'Ann%';
может использовать этот индекс для поиска по префиксу.
Для новичка главное запомнить:
LIKE 'abc%' — хороший кандидат для B-tree.
LIKE '%abc%' — нет, нужен другой подход.
Поиск подстроки: pg_trgm
Если вам нужен поиск по подстроке:
WHERE name LIKE '%ann%'
обычный B-tree индекс не подходит.
В PostgreSQL для таких задач часто используют расширение pg_trgm.
Подключаем расширение:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
Создаём GIN-индекс:
CREATE INDEX idx_users_name_trgm
ON users USING gin (name gin_trgm_ops);
Теперь запросы вроде:
SELECT id, name
FROM users
WHERE name LIKE '%ann%';
или:
SELECT id, name
FROM users
WHERE name ILIKE '%ann%';
могут выполняться намного быстрее.
Это уже не sargable в смысле обычного B-tree поиска, но это правильный индекс под такой тип задачи.
OR тоже может усложнить использование индекса
Иногда индексу мешают не функции, а сложная логика условия.
Например:
SELECT *
FROM orders
WHERE user_id = 42
OR status = 'paid';
Здесь две разные колонки и условие OR.
PostgreSQL иногда может использовать несколько индексов и объединить результат. Но не всегда это будет эффективно.
Если запрос важный и работает плохо, иногда его можно переписать через UNION.
Например:
SELECT *
FROM orders
WHERE user_id = 42
UNION
SELECT *
FROM orders
WHERE status = 'paid';
Это не универсальный совет, но полезный приём: иногда два простых sargable-запроса работают лучше, чем один сложный OR.
Проверять нужно через EXPLAIN.
COALESCE в WHERE
Ещё один популярный пример:
WHERE coalesce(status, '') = 'paid'
Так часто пишут, чтобы обработать NULL.
Но здесь снова функция над колонкой.
Если бизнес-логика позволяет, лучше сделать колонку status обязательной:
status text NOT NULL
и писать просто:
WHERE status = 'paid'
Если NULL всё-таки возможен, можно переписать условие явно:
WHERE status = 'paid'
В большинстве случаев строки с NULL всё равно не должны попасть в результат, потому что NULL = 'paid' не является истинным.
Если же вам нужна особая логика для NULL, лучше написать её явно:
WHERE status = 'paid'
OR status IS NULL
Но помните: OR тоже может усложнить план. Иногда для таких случаев нужен частичный индекс, отдельный индекс или изменение модели данных.
Неявные преобразования типов
Иногда индекс не используется хорошо из-за несоответствия типов.
Например, колонка user_id имеет тип bigint, а в запросе вы сравниваете её со строкой:
WHERE user_id = '42'
PostgreSQL часто сам приведёт константу к нужному типу, и всё будет нормально.
Но в более сложных случаях, особенно в разных СУБД, неявные преобразования могут привести к тому, что база начнёт преобразовывать колонку, а не константу.
Плохая идея:
WHERE user_id::text = '42'
Здесь мы явно превратили колонку в текст.
Лучше:
WHERE user_id = 42
Принцип тот же:
Не приводите колонку к другому типу в WHERE,
если хотите использовать обычный индекс по этой колонке.
IN обычно нормальный
Условие IN часто остаётся sargable.
Например:
SELECT *
FROM orders
WHERE status IN ('paid', 'shipped', 'cancelled');
Индекс по status может использоваться.
То же самое:
SELECT *
FROM orders
WHERE user_id IN (10, 20, 30);
Для индекса это похоже на несколько точечных поисков.
Но если список IN огромный, всё зависит от размера таблицы, статистики и плана. Как всегда, проверяем через EXPLAIN.
NOT и неравенства
Условия вида:
WHERE status <> 'paid'
или:
WHERE NOT status = 'paid'
часто менее полезны для индекса, чем точное равенство.
Почему?
Потому что такое условие может возвращать большую часть таблицы.
Если 90% заказов имеют статус не paid, то индекс не особо помогает: базе всё равно нужно прочитать очень много строк.
Это не столько проблема sargability, сколько проблема селективности.
Индекс особенно полезен, когда условие отбирает небольшую долю строк.
Например:
WHERE status = 'failed'
может быть хорошим условием, если ошибочных заказов мало.
А:
WHERE status <> 'paid'
может быть плохим, если таких строк большинство.
Sargable не значит «индекс точно будет использован»
Важный момент: sargable-предикат не гарантирует, что PostgreSQL обязательно выберет индекс.
Например:
WHERE status = 'paid'
Это нормальное условие.
Но если почти все строки имеют status = 'paid', индекс может быть бесполезен. Базе проще прочитать всю таблицу, чем прыгать по индексу и всё равно доставать почти каждую строку.
Поэтому sargable означает:
У базы есть возможность эффективно использовать индекс.
Но окончательное решение принимает оптимизатор.
Он смотрит на:
- размер таблицы;
- статистику;
- селективность условия;
- стоимость чтения индекса и таблицы;
- доступные индексы;
- актуальность статистики.
Поэтому после переписывания запроса нужно проверять план.
Как проверять через EXPLAIN
Допустим, у нас есть индекс:
CREATE INDEX idx_orders_created_at
ON orders (created_at);
Плохой вариант:
EXPLAIN
SELECT id, amount
FROM orders
WHERE date(created_at) = '2026-03-15';
Хороший вариант:
EXPLAIN
SELECT id, amount
FROM orders
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16';
В хорошем плане вы чаще ожидаете увидеть что-то вроде:
Index Scan
Bitmap Index Scan
Index Only Scan
А если видите:
Seq Scan
Filter: ...
значит, база читает таблицу последовательно и фильтрует строки после чтения.
Для реальной диагностики лучше использовать:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, amount
FROM orders
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16';
Так вы увидите не только план, но и фактическое выполнение.
Индекс по выражению: когда без функции никак
Иногда функция в WHERE действительно нужна и переписать запрос красиво нельзя.
Например:
WHERE lower(email) = 'ann@example.com'
Тогда можно создать индекс по выражению:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
После этого запрос:
SELECT id, email
FROM users
WHERE lower(email) = 'ann@example.com';
может стать быстрым.
Ещё пример: если вы часто ищете по дате без времени и сознательно хотите именно такой доступ:
CREATE INDEX idx_orders_created_day
ON orders ((created_at::date));
Тогда запрос:
SELECT id, amount
FROM orders
WHERE created_at::date = '2026-03-15';
может использовать этот индекс.
Но для дат чаще всё равно лучше диапазон по исходному created_at, потому что он универсальнее.
Generated column как альтернатива выражению
Иногда вместо функционального индекса удобно сделать отдельную вычисляемую колонку.
Например, нормализованный email:
ALTER TABLE users
ADD COLUMN email_norm text
GENERATED ALWAYS AS (lower(trim(email))) STORED;
И индекс по ней:
CREATE INDEX idx_users_email_norm
ON users (email_norm);
Теперь запрос простой:
SELECT id, email
FROM users
WHERE email_norm = 'ann@example.com';
Плюс такого подхода — в схеме явно видно, что у пользователя есть нормализованный email.
Минус — дополнительная колонка и дополнительный индекс.
Как обычно, выбор зависит от задачи.
MySQL: принцип тот же
В MySQL идея такая же.
Плохо:
WHERE DATE(created_at) = '2026-03-15'
Лучше:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Плохо:
WHERE LOWER(email) = 'ann@example.com'
Лучше хранить email в нормализованном виде, использовать подходящую collation или создать функциональный индекс, если версия MySQL это поддерживает.
В MySQL 8.0.13+ есть функциональные индексы, например:
CREATE INDEX idx_users_lower_email
ON users ((lower(email)));
В более старых версиях часто использовали generated column:
email_norm varchar(255)
GENERATED ALWAYS AS (lower(email)) STORED
и обычный индекс по ней.
ClickHouse: другой индекс, похожий принцип
В ClickHouse нет обычного B-tree индекса как в PostgreSQL.
Там важны:
PARTITION BY;
ORDER BY;
- разреженный первичный индекс;
- отсечение гранул данных.
Но общий принцип похож:
Если таблица отсортирована по created_at,
фильтр по голому created_at помогает читать меньше данных.
Хорошо:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Хуже:
WHERE toDate(created_at) = '2026-03-15'
ClickHouse часто умеет оптимизировать некоторые выражения, но лучше не полагаться на магию. Для ключевых запросов пишите условия так, чтобы они совпадали с тем, как данные отсортированы и партиционированы.
Частые переписывания
Дата через функцию
Плохо:
WHERE date(created_at) = '2026-03-15'
Лучше:
WHERE created_at >= '2026-03-15'
AND created_at < '2026-03-16'
Плохо:
WHERE EXTRACT(YEAR FROM created_at) = 2026
Лучше:
WHERE created_at >= '2026-01-01'
AND created_at < '2027-01-01'
Месяц через date_trunc
Плохо:
WHERE date_trunc('month', created_at) = '2026-03-01'
Лучше:
WHERE created_at >= '2026-03-01'
AND created_at < '2026-04-01'
Арифметика над колонкой
Плохо:
WHERE salary * 12 > 100000
Лучше:
WHERE salary > 100000 / 12
Приведение колонки к типу
Плохо:
WHERE user_id::text = '42'
Лучше:
WHERE user_id = 42
lower над email
Плохо для обычного индекса по email:
WHERE lower(email) = 'ann@example.com'
Лучше, если email хранится нормализованным:
WHERE email = 'ann@example.com'
Или создать индекс по выражению:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
Короткая шпаргалка
| Плохо |
Лучше |
date(created_at) = '2026-03-15' |
created_at >= '2026-03-15' AND created_at < '2026-03-16' |
created_at::date = '2026-03-15' |
Диапазон по created_at |
EXTRACT(YEAR FROM created_at) = 2026 |
created_at >= '2026-01-01' AND created_at < '2027-01-01' |
date_trunc('month', created_at) = ... |
Диапазон от начала месяца до начала следующего |
lower(email) = ... |
Нормализованная колонка, citext или индекс по lower(email) |
salary * 12 > 100000 |
salary > 100000 / 12 |
user_id::text = '42' |
user_id = 42 |
name LIKE '%ann%' |
pg_trgm / полнотекстовый поиск / другой индекс |
name LIKE 'Ann%' |
Может использовать B-tree, иногда нужен text_pattern_ops |
Главное, что нужно запомнить
Sargable-условие — это такое условие, которое помогает базе использовать индекс для поиска, а не заставляет её вычислять выражение для каждой строки.
Главное правило:
Оставляйте индексируемую колонку «голой».
Плохо:
WHERE function(column) = value
Лучше:
WHERE column = value
или:
WHERE column >= start_value
AND column < end_value
Для дат почти всегда думайте диапазонами:
WHERE created_at >= '2026-03-01'
AND created_at < '2026-04-01'
Для текста помните:
LIKE 'abc%' — хороший кандидат для обычного индекса.
LIKE '%abc%' — нужен другой подход.
Если функция действительно нужна, создавайте индекс по выражению:
CREATE INDEX idx_users_lower_email
ON users (lower(email));
Но не забывайте цену индексов: место на диске, более медленные вставки и обновления, обслуживание.
И самое важное — всегда проверяйте план:
EXPLAIN (ANALYZE, BUFFERS)
SELECT ...
Sargable-запрос ценен не потому, что выглядит красивее. Он ценен потому, что база читает меньше лишних строк и быстрее добирается до нужных данных.
Индекс в базе данных похож на оглавление в книге.
Если вы ищете главу по названию, вы открываете оглавление и сразу переходите на нужную страницу. Быстро и удобно.
Но если вы сформулировали поиск так, что оглавление уже нельзя использовать, придётся листать книгу целиком.
С SQL происходит то же самое.
Можно создать хороший индекс:
CREATE INDEX idx_orders_created_at ON orders (created_at);А потом написать условие так, что база всё равно не сможет нормально использовать этот индекс:
WHERE date(created_at) = '2026-03-15'На первый взгляд условие понятное: найти заказы за 15 марта.
Но для индекса это неудобно. В индексе лежат значения
created_at, а мы просим базу вычислитьdate(created_at)для каждой строки.Такой предикат называют несаргабельным.
А хороший предикат, который позволяет базе использовать индекс для поиска, называют sargable.
Что значит sargable
Слово
sargableпроисходит от английского выражения:Проще говоря, условие в
WHEREназывается sargable, если база может использовать индекс, чтобы быстро найти нужные строки.Например, есть индекс:
CREATE INDEX idx_users_email ON users (email);Хорошее условие:
WHERE email = 'ann@example.com'База может открыть индекс по
emailи быстро найти нужную строку.Плохое условие:
WHERE lower(email) = 'ann@example.com'Здесь мы уже ищем не само значение
email, а результат функцииlower(email).Обычный индекс по
emailхранит исходные email, а не заранее посчитанные значенияlower(email).Поэтому базе сложнее использовать этот индекс напрямую.
Главная идея такая:
Базовая таблица для примеров
Будем использовать таблицу заказов:
CREATE TABLE orders ( id bigint PRIMARY KEY, user_id bigint NOT NULL, amount numeric(12,2) NOT NULL, status text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );И таблицу пользователей:
CREATE TABLE users ( id bigint PRIMARY KEY, email text NOT NULL, name text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() );Создадим несколько индексов:
CREATE INDEX idx_orders_created_at ON orders (created_at); CREATE INDEX idx_orders_amount ON orders (amount); CREATE INDEX idx_users_email ON users (email); CREATE INDEX idx_users_name ON users (name);Теперь разберём, какие условия помогают этим индексам, а какие мешают.
Простое сравнение — хороший вариант
Самый понятный sargable-предикат — обычное сравнение колонки с константой.
Например:
SELECT id, amount, status FROM orders WHERE created_at >= '2026-01-01';Здесь колонка
created_atстоит «голой», без функции, без вычислений и без преобразований.Индекс:
CREATE INDEX idx_orders_created_at ON orders (created_at);может помочь базе быстро найти строки начиная с нужной даты.
То же самое с числом:
SELECT id, amount FROM orders WHERE amount > 1000;Индекс по
amountможет использоваться для поиска диапазона.Хорошие условия обычно выглядят так:
WHERE column = valueWHERE column > valueWHERE column >= valueWHERE column < valueWHERE column BETWEEN value1 AND value2Главное: колонка остаётся сама собой.
Что чаще всего ломает использование индекса
Индекс обычно строится по значениям колонки.
Например:
CREATE INDEX idx_orders_created_at ON orders (created_at);В этом индексе лежат значения
created_at.Но если написать:
WHERE date(created_at) = '2026-03-15'мы уже ищем не
created_at, а результат функцииdate(created_at).Базе нужно взять каждую строку, применить к ней функцию
date(), получить дату без времени и сравнить с'2026-03-15'.Индекс по исходному
created_atв такой ситуации может не помочь так, как вы ожидаете.Типичные примеры, которые мешают индексу:
WHERE lower(email) = 'ann@example.com'WHERE date(created_at) = '2026-03-15'WHERE created_at::date = '2026-03-15'WHERE EXTRACT(YEAR FROM created_at) = 2026WHERE amount + 10 > 1000WHERE salary * 12 > 100000WHERE coalesce(status, '') = 'paid'Общий признак:
Правило: не трогайте колонку, переносите вычисления вправо
Хорошее практическое правило:
Плохо:
WHERE salary * 12 > 100000Лучше:
WHERE salary > 100000 / 12В первом варианте база должна вычислить
salary * 12для строк.Во втором варианте вычисление находится на стороне константы, а колонка
salaryостаётся обычной колонкой.Плохо:
WHERE amount + 10 > 1000Лучше:
WHERE amount > 990Плохо:
WHERE price * 1.2 <= 600Лучше:
WHERE price <= 600 / 1.2Смысл простой: пусть база использует индекс по исходному значению, а не пересчитывает выражение для каждой строки.
Самая частая ошибка с датами
Одна из самых популярных ошибок — искать день через функцию:
SELECT id, amount FROM orders WHERE date(created_at) = '2026-03-15';Логически запрос понятный: найти заказы за 15 марта.
Но технически мы применили функцию
date()к колонкеcreated_at.Если
created_atхранит дату и время, например:то
date(created_at)отрежет время и оставит только дату.Проблема в том, что обычный индекс по
created_atхранит полные значения даты и времени, а не результатdate(created_at).Поэтому лучше переписать запрос на диапазон.
Правильный поиск одного дня через диапазон
Вместо:
WHERE date(created_at) = '2026-03-15'пишите:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Полный запрос:
SELECT id, amount FROM orders WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16';Это называется полуоткрытый интервал.
Левая граница включается:
Правая граница не включается:
Так мы берём весь день 15 марта:
но не захватываем 16 марта.
И главное: колонка
created_atостаётся без функции, поэтому индекс поcreated_atможет использоваться нормально.Почему не стоит писать BETWEEN для дат с временем
Иногда пишут так:
WHERE created_at BETWEEN '2026-03-15' AND '2026-03-15 23:59:59'Это выглядит удобно, но в датах с временем такой стиль часто опасен.
Почему?
Потому что точность времени может быть выше секунд.
Например, значение:
больше, чем:
и может не попасть в выборку.
Поэтому для дат и времени лучше использовать полуоткрытый интервал:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Так вы не зависите от точности хранения времени.
Поиск года без EXTRACT
Ещё один частый пример:
SELECT id, amount FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2026;Логика понятна: хотим заказы за 2026 год.
Но снова проблема: функция над колонкой.
Лучше написать диапазон:
SELECT id, amount FROM orders WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01';Это и понятнее для индекса, и обычно быстрее.
То же самое для месяца:
Плохо:
WHERE date_trunc('month', created_at) = '2026-03-01'Лучше:
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'Для недели, квартала и любого периода идея та же: не применяйте функцию к колонке, а задавайте границы диапазона.
Преобразование типа тоже может мешать
Не только функции, но и приведение типа может испортить использование индекса.
Например:
WHERE created_at::date = '2026-03-15'Это почти то же самое, что:
WHERE date(created_at) = '2026-03-15'Мы преобразовали колонку
created_atк дате без времени.Значит, база должна выполнить преобразование для строк.
Лучше снова использовать диапазон:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Хороший принцип:
То есть лучше так:
WHERE created_at >= '2026-03-15'::timestamptzчем так:
WHERE created_at::date = '2026-03-15'Регистронезависимый поиск email
Теперь разберём пример с email.
Есть индекс:
CREATE INDEX idx_users_email ON users (email);И есть запрос:
SELECT id, email FROM users WHERE lower(email) = 'ann@example.com';Так часто пишут, чтобы найти email без учёта регистра.
Но обычный индекс по
emailне хранитlower(email). Он хранит исходное значение.Например, в таблице может лежать:
А в запросе мы сравниваем:
Если каждый раз применять
lower(email), индекс по обычномуemailстановится менее полезным.Есть несколько нормальных решений.
Решение 1: хранить email уже нормализованным
Самый простой и часто лучший вариант — нормализовать email перед записью.
Например, всегда хранить email в нижнем регистре и без пробелов по краям:
Тогда запрос остаётся простым:
SELECT id, email FROM users WHERE email = 'ann@example.com';И обычный индекс по
emailработает хорошо:CREATE INDEX idx_users_email ON users (email);Чтобы не забывать нормализацию, её можно делать:
BEFORE INSERT OR UPDATEтриггер;Например, можно хранить отдельное поле:
email_norm text NOT NULLи индексировать его:
CREATE INDEX idx_users_email_norm ON users (email_norm);Тогда поиск будет таким:
SELECT id, email FROM users WHERE email_norm = 'ann@example.com';Решение 2: использовать citext
В PostgreSQL есть расширение
citext, которое даёт регистронезависимый текстовый тип.Подключается так:
CREATE EXTENSION IF NOT EXISTS citext;Можно сделать колонку:
email citext NOT NULLТогда сравнение:
WHERE email = 'ann@example.com'будет регистронезависимым.
При этом запрос остаётся обычным равенством по колонке, а это удобно для индексов.
Но
citext— это уже осознанное решение на уровне схемы. Его нужно выбирать заранее и понимать последствия для сравнения строк.Решение 3: индекс по выражению
Иногда функция в условии действительно нужна.
Например, вы не можете изменить хранение email прямо сейчас, но хотите ускорить запрос:
WHERE lower(email) = 'ann@example.com'Тогда можно создать индекс не по колонке, а по выражению:
CREATE INDEX idx_users_lower_email ON users (lower(email));Теперь PostgreSQL хранит в индексе результат выражения
lower(email).И запрос:
SELECT id, email FROM users WHERE lower(email) = 'ann@example.com';сможет использовать этот индекс.
Такой индекс называют функциональным индексом или индексом по выражению.
Но важно: выражение в запросе должно совпадать с выражением в индексе.
Если индекс создан по:
lower(email)а в запросе написано что-то другое, например:
lower(trim(email))это уже другое выражение. Такой индекс может не подойти.
Не делайте функциональные индексы там, где достаточно диапазона
Функциональные индексы полезны, но не стоит использовать их без необходимости.
Например, можно создать индекс:
CREATE INDEX idx_orders_created_date ON orders ((created_at::date));и потом писать:
WHERE created_at::date = '2026-03-15'Это может работать.
Но часто лучше и проще писать диапазон:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Почему?
Потому что обычный индекс по
created_atболее универсален.Он подходит для:
WHERE created_at >= '2026-03-15'WHERE created_at < '2026-04-01'ORDER BY created_at DESCWHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'А индекс по
created_at::dateполезен только для конкретного выражения.Каждый индекс занимает место и замедляет вставки и обновления, поэтому не плодите их там, где можно просто переписать условие.
LIKE: когда индекс помогает
Теперь поговорим про текстовый поиск.
Есть индекс:
CREATE INDEX idx_users_name ON users (name);Запрос:
SELECT id, name FROM users WHERE name LIKE 'Ann%';ищет имена, которые начинаются с
Ann.Например:
Такой поиск называется префиксным.
B-tree индекс может быть полезен для поиска по началу строки, потому что значения в индексе отсортированы.
Условно база может найти место, где начинаются значения на
Ann, и читать дальше, пока префикс совпадает.LIKE с ведущим процентом
А вот такой запрос намного хуже для обычного B-tree индекса:
SELECT id, name FROM users WHERE name LIKE '%ann%';Он ищет подстроку
annгде угодно внутри имени.Например:
Проблема в том, что строка может начинаться с чего угодно.
Индекс отсортирован по началу строки, а мы ищем кусок в середине.
Поэтому обычный B-tree индекс не может просто прыгнуть к нужному месту.
Такие условия часто приводят к полному сканированию или требуют других типов индексов.
Практическое правило:
name LIKE 'Ann%'name LIKE '%ann'name LIKE '%ann%'PostgreSQL и text_pattern_ops
В PostgreSQL есть важная деталь.
Для префиксного
LIKEобычный B-tree индекс хорошо работает не во всех настройках локали.Иногда для надёжного ускорения
LIKE 'Ann%'создают специальный индекс с operator classtext_pattern_ops:CREATE INDEX idx_users_name_pattern ON users (name text_pattern_ops);Тогда запрос:
SELECT id, name FROM users WHERE name LIKE 'Ann%';может использовать этот индекс для поиска по префиксу.
Для новичка главное запомнить:
Поиск подстроки: pg_trgm
Если вам нужен поиск по подстроке:
WHERE name LIKE '%ann%'обычный B-tree индекс не подходит.
В PostgreSQL для таких задач часто используют расширение
pg_trgm.Подключаем расширение:
CREATE EXTENSION IF NOT EXISTS pg_trgm;Создаём GIN-индекс:
CREATE INDEX idx_users_name_trgm ON users USING gin (name gin_trgm_ops);Теперь запросы вроде:
SELECT id, name FROM users WHERE name LIKE '%ann%';или:
SELECT id, name FROM users WHERE name ILIKE '%ann%';могут выполняться намного быстрее.
Это уже не sargable в смысле обычного B-tree поиска, но это правильный индекс под такой тип задачи.
OR тоже может усложнить использование индекса
Иногда индексу мешают не функции, а сложная логика условия.
Например:
SELECT * FROM orders WHERE user_id = 42 OR status = 'paid';Здесь две разные колонки и условие
OR.PostgreSQL иногда может использовать несколько индексов и объединить результат. Но не всегда это будет эффективно.
Если запрос важный и работает плохо, иногда его можно переписать через
UNION.Например:
SELECT * FROM orders WHERE user_id = 42 UNION SELECT * FROM orders WHERE status = 'paid';Это не универсальный совет, но полезный приём: иногда два простых sargable-запроса работают лучше, чем один сложный
OR.Проверять нужно через
EXPLAIN.COALESCE в WHERE
Ещё один популярный пример:
WHERE coalesce(status, '') = 'paid'Так часто пишут, чтобы обработать
NULL.Но здесь снова функция над колонкой.
Если бизнес-логика позволяет, лучше сделать колонку
statusобязательной:status text NOT NULLи писать просто:
WHERE status = 'paid'Если
NULLвсё-таки возможен, можно переписать условие явно:WHERE status = 'paid'В большинстве случаев строки с
NULLвсё равно не должны попасть в результат, потому чтоNULL = 'paid'не является истинным.Если же вам нужна особая логика для
NULL, лучше написать её явно:WHERE status = 'paid' OR status IS NULLНо помните:
ORтоже может усложнить план. Иногда для таких случаев нужен частичный индекс, отдельный индекс или изменение модели данных.Неявные преобразования типов
Иногда индекс не используется хорошо из-за несоответствия типов.
Например, колонка
user_idимеет типbigint, а в запросе вы сравниваете её со строкой:WHERE user_id = '42'PostgreSQL часто сам приведёт константу к нужному типу, и всё будет нормально.
Но в более сложных случаях, особенно в разных СУБД, неявные преобразования могут привести к тому, что база начнёт преобразовывать колонку, а не константу.
Плохая идея:
WHERE user_id::text = '42'Здесь мы явно превратили колонку в текст.
Лучше:
WHERE user_id = 42Принцип тот же:
IN обычно нормальный
Условие
INчасто остаётся sargable.Например:
SELECT * FROM orders WHERE status IN ('paid', 'shipped', 'cancelled');Индекс по
statusможет использоваться.То же самое:
SELECT * FROM orders WHERE user_id IN (10, 20, 30);Для индекса это похоже на несколько точечных поисков.
Но если список
INогромный, всё зависит от размера таблицы, статистики и плана. Как всегда, проверяем черезEXPLAIN.NOT и неравенства
Условия вида:
WHERE status <> 'paid'или:
WHERE NOT status = 'paid'часто менее полезны для индекса, чем точное равенство.
Почему?
Потому что такое условие может возвращать большую часть таблицы.
Если 90% заказов имеют статус не
paid, то индекс не особо помогает: базе всё равно нужно прочитать очень много строк.Это не столько проблема sargability, сколько проблема селективности.
Индекс особенно полезен, когда условие отбирает небольшую долю строк.
Например:
WHERE status = 'failed'может быть хорошим условием, если ошибочных заказов мало.
А:
WHERE status <> 'paid'может быть плохим, если таких строк большинство.
Sargable не значит «индекс точно будет использован»
Важный момент: sargable-предикат не гарантирует, что PostgreSQL обязательно выберет индекс.
Например:
WHERE status = 'paid'Это нормальное условие.
Но если почти все строки имеют
status = 'paid', индекс может быть бесполезен. Базе проще прочитать всю таблицу, чем прыгать по индексу и всё равно доставать почти каждую строку.Поэтому sargable означает:
Но окончательное решение принимает оптимизатор.
Он смотрит на:
Поэтому после переписывания запроса нужно проверять план.
Как проверять через EXPLAIN
Допустим, у нас есть индекс:
CREATE INDEX idx_orders_created_at ON orders (created_at);Плохой вариант:
EXPLAIN SELECT id, amount FROM orders WHERE date(created_at) = '2026-03-15';Хороший вариант:
EXPLAIN SELECT id, amount FROM orders WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16';В хорошем плане вы чаще ожидаете увидеть что-то вроде:
А если видите:
значит, база читает таблицу последовательно и фильтрует строки после чтения.
Для реальной диагностики лучше использовать:
EXPLAIN (ANALYZE, BUFFERS) SELECT id, amount FROM orders WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16';Так вы увидите не только план, но и фактическое выполнение.
Индекс по выражению: когда без функции никак
Иногда функция в
WHEREдействительно нужна и переписать запрос красиво нельзя.Например:
WHERE lower(email) = 'ann@example.com'Тогда можно создать индекс по выражению:
CREATE INDEX idx_users_lower_email ON users (lower(email));После этого запрос:
SELECT id, email FROM users WHERE lower(email) = 'ann@example.com';может стать быстрым.
Ещё пример: если вы часто ищете по дате без времени и сознательно хотите именно такой доступ:
CREATE INDEX idx_orders_created_day ON orders ((created_at::date));Тогда запрос:
SELECT id, amount FROM orders WHERE created_at::date = '2026-03-15';может использовать этот индекс.
Но для дат чаще всё равно лучше диапазон по исходному
created_at, потому что он универсальнее.Generated column как альтернатива выражению
Иногда вместо функционального индекса удобно сделать отдельную вычисляемую колонку.
Например, нормализованный email:
ALTER TABLE users ADD COLUMN email_norm text GENERATED ALWAYS AS (lower(trim(email))) STORED;И индекс по ней:
CREATE INDEX idx_users_email_norm ON users (email_norm);Теперь запрос простой:
SELECT id, email FROM users WHERE email_norm = 'ann@example.com';Плюс такого подхода — в схеме явно видно, что у пользователя есть нормализованный email.
Минус — дополнительная колонка и дополнительный индекс.
Как обычно, выбор зависит от задачи.
MySQL: принцип тот же
В MySQL идея такая же.
Плохо:
WHERE DATE(created_at) = '2026-03-15'Лучше:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Плохо:
WHERE LOWER(email) = 'ann@example.com'Лучше хранить email в нормализованном виде, использовать подходящую collation или создать функциональный индекс, если версия MySQL это поддерживает.
В MySQL 8.0.13+ есть функциональные индексы, например:
CREATE INDEX idx_users_lower_email ON users ((lower(email)));В более старых версиях часто использовали generated column:
email_norm varchar(255) GENERATED ALWAYS AS (lower(email)) STOREDи обычный индекс по ней.
ClickHouse: другой индекс, похожий принцип
В ClickHouse нет обычного B-tree индекса как в PostgreSQL.
Там важны:
PARTITION BY;ORDER BY;Но общий принцип похож:
Хорошо:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Хуже:
WHERE toDate(created_at) = '2026-03-15'ClickHouse часто умеет оптимизировать некоторые выражения, но лучше не полагаться на магию. Для ключевых запросов пишите условия так, чтобы они совпадали с тем, как данные отсортированы и партиционированы.
Частые переписывания
Дата через функцию
Плохо:
WHERE date(created_at) = '2026-03-15'Лучше:
WHERE created_at >= '2026-03-15' AND created_at < '2026-03-16'Год через EXTRACT
Плохо:
WHERE EXTRACT(YEAR FROM created_at) = 2026Лучше:
WHERE created_at >= '2026-01-01' AND created_at < '2027-01-01'Месяц через date_trunc
Плохо:
WHERE date_trunc('month', created_at) = '2026-03-01'Лучше:
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'Арифметика над колонкой
Плохо:
WHERE salary * 12 > 100000Лучше:
WHERE salary > 100000 / 12Приведение колонки к типу
Плохо:
WHERE user_id::text = '42'Лучше:
WHERE user_id = 42lower над email
Плохо для обычного индекса по
email:WHERE lower(email) = 'ann@example.com'Лучше, если email хранится нормализованным:
WHERE email = 'ann@example.com'Или создать индекс по выражению:
CREATE INDEX idx_users_lower_email ON users (lower(email));Короткая шпаргалка
date(created_at) = '2026-03-15'created_at >= '2026-03-15' AND created_at < '2026-03-16'created_at::date = '2026-03-15'created_atEXTRACT(YEAR FROM created_at) = 2026created_at >= '2026-01-01' AND created_at < '2027-01-01'date_trunc('month', created_at) = ...lower(email) = ...citextили индекс поlower(email)salary * 12 > 100000salary > 100000 / 12user_id::text = '42'user_id = 42name LIKE '%ann%'pg_trgm/ полнотекстовый поиск / другой индексname LIKE 'Ann%'text_pattern_opsГлавное, что нужно запомнить
Sargable-условие — это такое условие, которое помогает базе использовать индекс для поиска, а не заставляет её вычислять выражение для каждой строки.
Главное правило:
Плохо:
WHERE function(column) = valueЛучше:
WHERE column = valueили:
WHERE column >= start_value AND column < end_valueДля дат почти всегда думайте диапазонами:
WHERE created_at >= '2026-03-01' AND created_at < '2026-04-01'Для текста помните:
Если функция действительно нужна, создавайте индекс по выражению:
CREATE INDEX idx_users_lower_email ON users (lower(email));Но не забывайте цену индексов: место на диске, более медленные вставки и обновления, обслуживание.
И самое важное — всегда проверяйте план:
EXPLAIN (ANALYZE, BUFFERS) SELECT ...Sargable-запрос ценен не потому, что выглядит красивее. Он ценен потому, что база читает меньше лишних строк и быстрее добирается до нужных данных.