Начальный ~45 мин чтения

SELECT и WHERE: фильтрация данных

Урок 4 из 10 в курсе SQL с нуля: полный курс

SELECT и WHERE: фильтрация данных

SELECT — оператор выборки данных из таблицы, WHERE — оператор фильтрации строк до их возврата клиенту. В реальных приложениях WHERE — самая важная часть запроса с точки зрения производительности: правильно написанное условие с индексом возвращает результат за миллисекунды, неправильное — перебирает миллионы строк секундами. Операторы фильтрации: сравнения (=, <>, <, >), логические (AND, OR, NOT), проверка списков (IN, NOT IN), диапазоны (BETWEEN), шаблоны строк (LIKE, ILIKE), проверка NULL (IS NULL, IS NOT NULL), подзапросы (EXISTS). Каждый оператор имеет свою семантику с NULL и свои особенности использования индексов, которые критически важно понимать.

Почему это важно: Неправильно написанный WHERE — самая частая причина медленных запросов в backend-приложениях. Три типичных антипаттерна: WHERE LOWER(email) = 'test@test.com' (функция на индексированном столбце → seq scan вместо index scan), WHERE phone = NULL (всегда возвращает 0 строк — нужно IS NULL), NOT IN с подзапросом возвращающим NULL (всегда пустой результат). Понимание того, какие операторы используют индексы, а какие нет — разница между приложением которое работает при 1000 пользователях и тем, которое работает при 10 миллионах. EXPLAIN показывает план: Index Scan = быстро, Seq Scan на большой таблице = проблема.

Главная идея

Логический порядок выполнения SQL-запроса: FROM (определяем таблицы) → WHERE (фильтруем строки) → GROUP BY (группируем) → HAVING (фильтруем группы) → SELECT (выбираем столбцы и вычисляем выражения) → ORDER BY (сортируем) → LIMIT/OFFSET (ограничиваем). WHERE применяется до SELECT, поэтому нельзя использовать псевдонимы из SELECT в WHERE. И это объясняет, почему SELECT * медленнее чем SELECT id, name — данные полей читаются и передаются клиенту, WHERE это не меняет, но лишние данные — лишний трафик и время. Каждое условие WHERE PostgreSQL оценивает для каждой строки таблицы (или использует индекс чтобы не делать этого). Стоимость операции прямо зависит от того, использует ли условие индекс.

Как это выглядит на практике

  1. Базовый SELECT с фильтром: SELECT id, email, full_name FROM users WHERE is_active = true AND created_at >= '2024-01-01'. Запрос возвращает только нужные столбцы (не *) и фильтрует по двум условиям. При наличии индекса на (is_active, created_at) или (created_at) PostgreSQL использует Index Scan.
  2. Оператор IN — проверка списка значений: SELECT * FROM orders WHERE status IN ('pending', 'confirmed'). Эквивалентно status = 'pending' OR status = 'confirmed', но читабельнее. IN с индексированным столбцом эффективен. Максимальный список для IN: тысячи элементов работают нормально, но при очень больших списках используй JOIN с временной таблицей.
  3. NOT IN с ловушкой NULL: SELECT * FROM users WHERE id NOT IN (SELECT user_id FROM banned_users). Если в banned_users.user_id есть хотя бы одна NULL строка — запрос вернёт НОЛЬ пользователей! Потому что NOT IN это AND (id <> 1 AND id <> 2 AND id <> NULL) — последнее условие NULL даёт unknown, весь результат NULL. Безопасная альтернатива: NOT EXISTS.
  4. BETWEEN для диапазонов: SELECT * FROM orders WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31 23:59:59'. BETWEEN включает оба края (>=, <=). Для дат удобнее: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01' — исключает последний день явно.
  5. LIKE для поиска по шаблону: WHERE name LIKE 'Иван%' (начинается с 'Иван') использует B-tree индекс при условии locale с правильным collation. WHERE name LIKE '%Иван%' (содержит 'Иван') — индекс не используется, seq scan. Для поиска подстроки нужен pg_trgm + GIN индекс.
  6. ILIKE — регистронезависимый LIKE: WHERE email ILIKE '%@gmail.com'. Не использует обычный индекс на email. Для регистронезависимого поиска: CREATE INDEX ON users(LOWER(email)), WHERE LOWER(email) LIKE '%@gmail.com'.
  7. IS NULL и IS NOT NULL: SELECT * FROM orders WHERE deleted_at IS NULL (soft delete паттерн). WHERE deleted_at = NULL — всегда 0 строк! NULL никогда не равен NULL через =. Partial index для soft delete: CREATE INDEX ON orders(id) WHERE deleted_at IS NULL — индекс только по активным записям.
  8. Функции в WHERE убивают индекс: WHERE EXTRACT(YEAR FROM created_at) = 2024 — seq scan. Правильно: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'. WHERE LOWER(email) = 'test@test.com' — seq scan. Правильно: функциональный индекс CREATE INDEX ON users(LOWER(email)) и тот же запрос.
  9. EXISTS для проверки наличия связанных записей: SELECT * FROM users u WHERE EXISTS (SELECT 1 FROM orders o WHERE o.user_id = u.id AND o.status = 'completed'). EXISTS останавливается при первом совпадении (short-circuit) — эффективнее IN для больших подзапросов. SELECT 1 — стандартная практика, конкретный столбец не важен.
  10. Логические операторы и приоритет: AND имеет больший приоритет чем OR. WHERE status = 'pending' OR status = 'confirmed' AND user_id = 42 — это WHERE status = 'pending' OR (status = 'confirmed' AND user_id = 42)! Используй скобки: WHERE (status = 'pending' OR status = 'confirmed') AND user_id = 42.
  11. Составные условия с индексами: индекс на (user_id, created_at) эффективен для WHERE user_id = 42 AND created_at > '2024-01-01'. Но не для WHERE created_at > '2024-01-01' без user_id — составной индекс используется слева направо по leading columns.
  12. COALESCE в WHERE: WHERE COALESCE(discount_price, price) < 100. PostgreSQL не использует индекс на price при COALESCE. Альтернатива: WHERE (discount_price IS NOT NULL AND discount_price < 100) OR (discount_price IS NULL AND price < 100) — позволяет использовать индексы.

Примеры кода

Базовая фильтрация — правильно и неправильно

-- НЕПРАВИЛЬНО: SELECT * тянет все столбцы, включая JSONB metadata
SELECT * FROM users WHERE is_active = true;

-- ПРАВИЛЬНО: только нужные столбцы
SELECT id, email, full_name FROM users WHERE is_active = true;

-- НЕПРАВИЛЬНО: функция на индексированном столбце → Seq Scan
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';

-- ПРАВИЛЬНО: функциональный индекс + условие без функции
CREATE INDEX CONCURRENTLY idx_users_email_lower ON users(LOWER(email));
SELECT * FROM users WHERE LOWER(email) = 'alice@example.com';
-- Теперь используется Index Scan on idx_users_email_lower

-- НЕПРАВИЛЬНО: диапазон через функцию
SELECT * FROM orders WHERE EXTRACT(YEAR FROM created_at) = 2024;

-- ПРАВИЛЬНО: диапазон напрямую (использует индекс на created_at)
SELECT * FROM orders
WHERE created_at >= '2024-01-01'
  AND created_at < '2025-01-01';

Ключевое правило: никогда не применяй функцию к индексированному столбцу в WHERE. Создавай функциональный индекс (CREATE INDEX ON table(func(column))) или переформулируй условие без функции.

Ловушка NOT IN с NULL

-- Создаём тестовые данные
INSERT INTO users (email, full_name) VALUES
  ('alice@example.com', 'Alice'),
  ('bob@example.com', 'Bob'),
  ('charlie@example.com', 'Charlie');

CREATE TEMP TABLE excluded_users (user_id BIGINT);
INSERT INTO excluded_users VALUES (1), (NULL);  -- NULL здесь!

-- ОПАСНО: NOT IN с NULL в подзапросе → пустой результат!
SELECT * FROM users
WHERE id NOT IN (SELECT user_id FROM excluded_users);
-- Результат: 0 строк! NOT IN (1, NULL) → NOT (id=1 OR id=NULL)
-- → id<>1 AND id<>NULL → id<>1 AND NULL → NULL

-- ПРАВИЛЬНО: NOT EXISTS безопасен с NULL
SELECT * FROM users u
WHERE NOT EXISTS (
  SELECT 1 FROM excluded_users e
  WHERE e.user_id = u.id
);
-- Результат: Bob, Charlie (Alice с id=1 исключена)

-- Альтернатива: LEFT JOIN + IS NULL (антисоединение)
SELECT u.*
FROM users u
LEFT JOIN excluded_users e ON u.id = e.user_id
WHERE e.user_id IS NULL;

NOT IN с NULL — один из самых коварных SQL-багов. Если подзапрос может вернуть NULL (а user_id обычно может), используй NOT EXISTS или LEFT JOIN + IS NULL.

Оптимизация поиска по строкам — LIKE и pg_trgm

-- Создаём расширение для триграммного поиска
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- GIN индекс для поиска подстрок
CREATE INDEX CONCURRENTLY idx_products_name_trgm
  ON products USING GIN (name gin_trgm_ops);

-- Теперь WHERE name LIKE '%ноутбук%' использует GIN индекс
SELECT id, name, price
FROM products
WHERE name ILIKE '%ноутбук%'
ORDER BY similarity(name, 'ноутбук') DESC
LIMIT 20;

-- similarity() возвращает число от 0 до 1
SELECT name, similarity(name, 'ноутбук') AS sim
FROM products
WHERE name % 'ноутбук'  -- оператор % : similarity > 0.3
ORDER BY sim DESC;

pg_trgm разбивает строки на триграммы (тройки символов) и строит GIN-индекс. Это позволяет эффективно искать подстроки. Работает с LIKE, ILIKE, % оператором и similarity(). Поиск по 5 миллионам строк: без индекса ~4с, с GIN индексом ~20мс.

EXISTS vs IN — производительность на реальных данных

-- Найти пользователей с хотя бы одним выполненным заказом

-- Вариант 1: IN с подзапросом
-- Материализует весь список user_id из orders
SELECT id, email FROM users
WHERE id IN (
  SELECT DISTINCT user_id FROM orders WHERE status = 'completed'
);

-- Вариант 2: EXISTS (обычно быстрее для больших таблиц)
-- Останавливается при первом совпадении
SELECT id, email FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id
    AND o.status = 'completed'
);

-- Вариант 3: JOIN (для агрегации вместе с данными)
SELECT DISTINCT u.id, u.email
FROM users u
INNER JOIN orders o ON o.user_id = u.id
WHERE o.status = 'completed';

-- EXPLAIN ANALYZE покажет какой план выбрал PostgreSQL
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, email FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.status = 'completed'
);

EXISTS эффективен когда нужно просто проверить факт наличия. IN материализует весь список в памяти. JOIN + DISTINCT нужен когда планируешь добавить агрегатные данные из orders. Проверяй реальные планы через EXPLAIN ANALYZE — планировщик PostgreSQL умный и может оптимизировать по-разному.

Что происходит под капотом

  • Логический порядок выполнения SQL: FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → DISTINCT → ORDER BY → LIMIT/OFFSET. WHERE применяется ДО SELECT, поэтому псевдоним из SELECT недоступен в WHERE той же команды. Это объясняет, почему нужно писать WHERE created_at > X, а не WHERE year > X где year — псевдоним из SELECT.
  • B-tree индекс работает для операторов =, <, >, <=, >= и BETWEEN. Для LIKE 'prefix%' (без ведущего %) B-tree тоже работает, но только если collation поддерживает это — в PostgreSQL нужно C locale или специальный оператор класс. LIKE '%suffix' и LIKE '%middle%' — только seq scan или GIN-индекс (pg_trgm).
  • Функциональные индексы: CREATE INDEX ON users(LOWER(email)) создаёт индекс не на значение столбца, а на результат функции. PostgreSQL использует этот индекс когда в WHERE встречается точно такое же выражение: WHERE LOWER(email) = X. Функциональный индекс также может быть partial: CREATE INDEX ON orders(status) WHERE status != 'completed' — только для незакрытых заказов.
  • Short-circuit evaluation в AND: если первое условие AND ложно — второе не вычисляется. PostgreSQL использует это при наличии нескольких условий: более селективное (убирающее больше строк) ставится первым в плане выполнения. Планировщик оценивает selectivity каждого условия из статистики и упорядочивает вычисления оптимально.
  • IN (v1, v2, ...) PostgreSQL превращает в = v1 OR = v2 OR ... или Hash Join, в зависимости от размера списка. IN с небольшим списком буквальных значений превращается в array comparison и может использовать индекс. IN с подзапросом планировщик может переписать в Hash Semi Join — эффективная стратегия для больших подзапросов.
  • NULL и три-значная логика: SQL использует three-valued logic (TRUE, FALSE, NULL/UNKNOWN). Любое сравнение с NULL возвращает NULL: NULL = 1 → NULL, NULL <> 1 → NULL, NULL > 0 → NULL. В WHERE фильтр убирает строки только для FALSE и NULL — строки с NULL-условием не попадают в результат, так же как строки с FALSE. Поэтому WHERE phone = NULL убирает все строки.
  • Covering index: если индекс содержит все столбцы нужные запросу — PostgreSQL делает Index Only Scan (не обращается к heap страницам совсем). CREATE INDEX ON orders(user_id, status, created_at) покрывает SELECT created_at FROM orders WHERE user_id = X AND status = 'pending' — только операции с индексом, диск не читается.
  • Partial index для soft delete: CREATE INDEX ON orders(user_id, created_at) WHERE deleted_at IS NULL. Этот индекс содержит только 'живые' строки. Запрос WHERE user_id = X AND deleted_at IS NULL использует этот partial index — он меньше полного индекса и эффективнее для фильтрации активных записей.
  • OR условия сложнее для оптимизатора: WHERE user_id = 1 OR status = 'vip' не использует составной индекс. PostgreSQL может выполнить BitmapOr двух Index Scans: один по user_id, другой по status, объединить битовые маски и пройти по heap. Для OR c одним столбцом лучше использовать IN: WHERE status IN ('vip', 'premium').
  • Recheck Condition в Bitmap Index Scan: при использовании GIN или когда индекс lossy (не хранит точные значения) PostgreSQL после индексного прохода перепроверяет условие на реальных строках. Это видно в EXPLAIN как 'Recheck Cond'. Для pg_trgm поиска это нормально: GIN находит кандидатов, Recheck отсеивает false positives.

Типичные ошибки и заблуждения

  • «WHERE id = NULL найдёт строки с id равным NULL». NULL — не значение, это маркер отсутствия. NULL = NULL → NULL (не TRUE). Никакое сравнение через = с NULL не возвращает TRUE. WHERE id = NULL всегда возвращает 0 строк. Правильно: IS NULL. Даже опытные разработчики периодически делают эту ошибку в сложных динамических запросах.
  • «NOT IN (SELECT ...) безопасен как и IN». NOT IN (подзапрос) с NULL в результате подзапроса возвращает НОЛЬ строк — это ловушка. NOT IN (1, 2, NULL) разворачивается в id<>1 AND id<>2 AND id<>NULL. Третье условие id<>NULL = NULL (unknown), AND NULL = NULL. Ни одна строка не проходит. Всегда используй NOT EXISTS или LEFT JOIN + IS NULL вместо NOT IN с подзапросом.
  • «BETWEEN '2024-01-01' AND '2024-12-31' покрывает весь 2024 год». BETWEEN включает оба конца. '2024-12-31' это '2024-12-31 00:00:00', не конец дня. Все заказы 31 декабря после полуночи не попадут в выборку. Правильно для TIMESTAMPTZ: WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'.
  • «SELECT * проще и не медленнее перечисления столбцов». SELECT * передаёт все данные всех столбцов: большие TEXT, JSONB, BYTEA. Если в таблице есть столбец content TEXT с 10KB текста на строку — SELECT * при 1000 строках = 10MB трафика вместо килобайт для SELECT id, title. Плюс: SELECT * ломается при изменении схемы (если код ожидает определённые позиции столбцов). Всегда перечисляй нужные столбцы.
  • «LIKE 'prefix%' использует индекс как = 'prefix'». LIKE 'prefix%' использует B-tree индекс, но не так эффективно как =. B-tree делает range scan от 'prefix' до 'prefiy' (следующий возможный символ), что чуть дороже точного lookup. Разница обычно небольшая. Но LIKE '%prefix' и LIKE '%prefix%' индекс не используют вообще — seq scan.
  • «OR между условиями на разных столбцах всегда медленный». Для OR PostgreSQL может использовать BitmapOr: два отдельных Index Scan, объединение результатов через bitmap. WHERE category = 'electronics' OR is_featured = true — PostgreSQL сделает Index Scan по category, Index Scan по is_featured, объединит результаты. Это быстрее seq scan при хорошей селективности каждого условия.
  • «Порядок условий в WHERE влияет на производительность — самое строгое ставь первым». В PostgreSQL порядок условий в WHERE не определяет порядок их вычисления. Планировщик сам переупорядочивает условия на основе статистики selectivity. Исключение: volatile функции (RANDOM(), NOW()) и функции с побочными эффектами.

Ключевые выводы

  • IS NULL / IS NOT NULL — единственный правильный способ проверить отсутствие значения; WHERE col = NULL всегда возвращает 0 строк.
  • NOT IN (подзапрос) опасен: если подзапрос возвращает NULL — результат пустой. Используй NOT EXISTS или LEFT JOIN + IS NULL.
  • Функции над индексированными столбцами в WHERE убивают индекс: WHERE LOWER(email) = X без функционального индекса → seq scan.
  • LIKE 'prefix%' использует B-tree индекс; LIKE '%prefix%' требует GIN + pg_trgm для эффективного поиска подстрок.
  • AND имеет больший приоритет чем OR — используй скобки для ясности логики.
  • EXISTS эффективнее IN для больших подзапросов: останавливается при первом совпадении.
  • Перечисляй нужные столбцы вместо SELECT * — это уменьшает трафик и делает код устойчивее к изменениям схемы.

Термины урока

WHERE: предикат фильтрации строк, применяемый до GROUP BY, SELECT и ORDER BY; строки где условие FALSE или NULL не попадают в результат.
IN: проверка вхождения значения в список или подзапрос; превращается в серию OR-условий или Hash Semi Join.
NOT IN: исключение значений списка; опасен при наличии NULL в списке — возвращает пустой результат.
BETWEEN a AND b: включительный диапазон, эквивалент >= a AND <= b.
LIKE: шаблонное сравнение строк; % — любое количество символов, _ — ровно один. ILIKE — регистронезависимый LIKE.
IS NULL / IS NOT NULL: единственный способ проверить отсутствие значения; NULL = NULL → NULL (не TRUE).
EXISTS: проверка наличия хотя бы одной строки в подзапросе; останавливается при первом совпадении (short-circuit).
Functional index: индекс на выражении (CREATE INDEX ON t(LOWER(col))), используется при точно таком же выражении в WHERE.
Partial index: индекс с WHERE-условием, содержащий только часть строк таблицы; меньше, эффективнее для частых запросов к подмножеству данных.
Seq Scan: последовательный перебор всех страниц таблицы; дорог для больших таблиц; виден в EXPLAIN как тревожный сигнал при >100К строк.

Связь с работой backend-разработчика

Каждый WHERE на таблице > 100 000 строк — потенциальный seq scan, который нужно проверить через EXPLAIN. Правила для производительных WHERE: не применяй функции к indexed-столбцам (или создай functional index), избегай NOT IN с подзапросами, используй диапазоны вместо функций на датах (EXTRACT → >=, <), перечисляй столбцы вместо SELECT *. Для поиска подстрок в тексте — pg_trgm + GIN обязательны. Самый ценный навык: открыть EXPLAIN ANALYZE для любого медленного запроса и сразу увидеть где проблема — Seq Scan, большой rows estimate, неправильный JOIN.

Мини-разбор реальной ситуации

E-commerce-платформа имеет поиск товаров по названию: WHERE name LIKE '%ноутбук%'. На таблице 200 000 товаров запрос занимает 800мс. С ростом каталога до 2 миллионов: 8 секунд. Команда думает о кешировании Redis. Перед этим senior-разработчик смотрит EXPLAIN: Seq Scan on products (cost=0..45000 rows=200000 width=120) Filter: (name ~~ '%ноутбук%') Rows Removed by Filter: 199847. Полный перебор 2 миллионов строк. Решение: CREATE EXTENSION pg_trgm; CREATE INDEX CONCURRENTLY idx_products_name_gin ON products USING GIN (name gin_trgm_ops). Результат через 3 минуты (индекс строится конкурентно, без блокировки): WHERE name ILIKE '%ноутбук%' → 12мс. Кеш Redis не понадобился. Урок: сначала EXPLAIN, потом архитектурные решения.

Что запомнить

  • IS NULL для проверки отсутствия значения — = NULL всегда даёт 0 строк.
  • NOT EXISTS вместо NOT IN при работе с подзапросами — безопасно при наличии NULL.
  • EXPLAIN ANALYZE для любого медленного запроса — ищи Seq Scan на больших таблицах.
  • Функции на indexed-столбцах в WHERE → создавай функциональный индекс.
  • LIKE '%текст%' без pg_trgm = seq scan; с pg_trgm GIN-индексом = 50-500x быстрее.
  • Скобки в сложных OR/AND — AND имеет приоритет над OR, не полагайся на интуицию.

Итог

WHERE — это сердце каждого запроса к данным. Правильно написанное условие с нужным индексом превращает секунды в миллисекунды. Два инструмента которые нужно знать: EXPLAIN ANALYZE для диагностики, и знание какие операторы работают с индексами а какие нет. С этими знаниями любой медленный запрос станет диагностируемой и решаемой задачей.