3. WHERE и фильтрация
«Самая эффективная строка кода — та, которая никогда не исполняется, а самая быстрая запись базы данных — та, которую вы отфильтровали еще до того, как она коснулась оперативной памяти».
Предикаты и ограничение выборки
В математическом аппарате реляционной алгебры операция, которая отсекает ненужные кортежи по заданному логическому условию, называется селекцией (Selection) или ограничением (Restriction) (традиционно обозначается греческой буквой $\sigma$). В синтаксисе языка SQL за реализацию этой операции отвечает предложение WHERE.
Если проекция SELECT сжимает ширину потока данных (отсекая лишние атрибуты и колонки), то предикаты WHERE сжимают его длину (отсекая строки). С точки зрения принципа Mechanical Sympathy (механического сочувствия к оборудованию), фильтрация данных непосредственно на уровне движка СУБД — это первая линия обороны всей вашей бэкенд-системы. Раннее отсечение строк кардинально разгружает три критических физических узла:
- Дисковая подсистема (Disk IO): Существенно снижается объем страниц данных, которые СУБД вынуждена считывать в свой буферный пул (Shared Buffers), особенно при наличии селективных индексов.
- Процессор (CPU): Минимизируется количество строк, которые ядро базы данных десериализует, декодирует и передает в конвейер вычислений.
- Сетевой стек (Network IO): Исключается передача миллионов «мусорных» байтов по протоколу TCP в сетевой поллер (
netpoll) вашего Go-приложения.
Как БД выполняет WHERE: Seq Scan vs Index Scan
Когда парсер СУБД встречает блок WHERE, он транслирует логический предикат в узлы синтаксического дерева и передает их стоимостному оптимизатору. На физическом уровне у базы данных есть два фундаментально противоположных пути исполнения фильтра:
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Запрос с WHERE"]:::entry --> Optimizer["Оптимизатор<br>Анализ статистики"]
Optimizer -->|"Нет индекса или индекс невыгоден"| SeqScan["Sequential Scan<br>Последовательное сканирование"]
Optimizer -->|"Есть подходящий индекс"| IndexScan["Index Scan<br>Поиск по B-Tree"]
SeqScan -->|"Чтение всей таблицы в RAM"| CPUFilter["CPU фильтрация<br>Проверка каждой строки"]
IndexScan -->|"Чтение только нужных узлов"| ReadPages["Чтение конкретных страниц"]
CPUFilter --> Result["Отправка в Go"]
ReadPages --> Result
- Sequential Scan (Полное последовательное сканирование таблицы):
СУБД последовательно вычитывает с диска в оперативную память (Shared Buffers) каждую страницу таблицы (по 8 КБ в PostgreSQL). Затем исполнитель запросов последовательно перебирает каждый кортеж на странице и процессор вычисляет значение предиката (например,age > 18). Если условие ложно, кортеж отбрасывается. Если таблица насчитывает 50 миллионов строк, а вам требовалось найти аккаунт одного пользователя, подобный подход сжигает гигабайты пропускной способности памяти и миллиарды процессорных тактов впустую. - Index Scan (Сканирование индекса):
СУБД спускается по сбалансированному дереву индекса (классический B-Tree), выполняя логарифмический поиск $\mathcal{O}(\log N)$. В листовых узлах индекса база находит точные физические указатели — TID (Tuple Identifier), содержащие номер дисковой страницы и смещение строки внутри страницы. Исполнитель обращается исключительно к целевым страницам таблицы, полностью минуя чтение гигабайтов остального массива. Подробнее архитектуру B-Tree и устройство индексов мы разберем в статье 1. Что такое индекс и зачем он нужен.
SARGability: Главный секрет производительного WHERE
На собеседованиях уровня Middle+ и Senior вопрос о SARGability предикатов задают с завидной регулярностью.
Аббревиатура SARGable (Search ARGument ABLE) определяет способность СУБД использовать существующий индекс для непосредственного поиска по предикату вместо деградации в полный перебор таблицы (Sequential Scan).
Золотое правило SARGability: колонка индекса обязана стоять слева от оператора сравнения в первозданном, немодифицированном виде. Любое применение функции, математической операции, приведения типов или конкатенации к индексируемому полю "ломает" возможность спуска по B-Tree.
❌ Non-SARGable примеры (Индекс НЕ будет использован):
-- 1. Функция над колонкой: СУБД вынуждена вычислить YEAR() для КАЖДОЙ строки таблицы!
SELECT id FROM users WHERE YEAR(created_at) = 2023;
-- 2. Арифметика над колонкой: B-Tree не знает значения выражения price * 0.9.
SELECT id FROM products WHERE price * 0.9 < 100;
-- 3. Поиск по шаблону с префиксным подстановочным знаком:
-- B-Tree отсортирован слева направо и не способен совершить бинарный спуск с конца слова.
SELECT email FROM users WHERE email LIKE '%@gmail.com';
✅ SARGable примеры (Индекс будет использован):
-- 1. Диапазон дат: B-Tree моментально позиционируется на начало диапазона
-- и сканирует листовые страницы до верхней границы.
SELECT id FROM users WHERE created_at >= '2023-01-01' AND created_at < '2024-01-01';
-- 2. Математика перенесена вправо: константа 100 / 0.9 вычисляется один раз
-- на этапе компиляции запроса, а колонка price остается чистым операндом.
SELECT id FROM products WHERE price < 100 / 0.9;
-- 3. Префиксный поиск: B-Tree находит узел с префиксом 'john.doe@' за O(log N).
SELECT email FROM users WHERE email LIKE 'john.doe@%';
Ловушки при работе с WHERE в Go
При проектировании серверного слоя на Go динамическое конструирование фильтров в предложении WHERE таит в себе две классические архитектурные ловушки.
1. Срез в операторе IN
Одна из распространенных потребностей — запросить сущности по списку идентификаторов: WHERE id IN (1, 2, 3). Начинающие разработчики часто пытаются скормить срез Go (slice) напрямую в качестве одиночного аргумента:
// ❌ ГРУБАЯ ОШИБКА: Драйвер database/sql не умеет автоматически раскрывать срез Go
// в перечисление SQL-аргументов. Запрос завершится синтаксической ошибкой СУБД.
ids := []int{1, 2, 3}
db.Query("SELECT name FROM users WHERE id IN ($1)", ids)
В стандартном протоколе базы данных плейсхолдер $1 соответствует ровно одному скалярному значению, а не списку.
✅ Правильный идиоматичный подход (чистый Go без сторонних библиотек):
Мы динамически генерируем строку с позиционными параметрами ($1, $2, $3) и передаем распакованный срез интерфейсов args...:
package repository
import (
"context"
"database/sql"
"fmt"
"strings"
)
type User struct {
ID int64
Name string
}
func GetUsersByIDs(ctx context.Context, db *sql.DB, ids []int) ([]User, error) {
if len(ids) == 0 {
return nil, nil // Быстрый возврат: предотвращаем бессмысленный network round-trip к БД
}
// Аллоцируем буферы под строковые плейсхолдеры и аргументы
placeholders := make([]string, len(ids))
args := make([]any, len(ids)) // interface{} срез для передачи в variadic QueryContext
for i, id := range ids {
placeholders[i] = fmt.Sprintf("$%d", i+1)
args[i] = id
}
query := fmt.Sprintf(
"SELECT id, name FROM users WHERE id IN (%s)",
strings.Join(placeholders, ", "),
)
// args... разворачивает срез []any в вариативные параметры
rows, err := db.QueryContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("query execute error: %w", err)
}
defer rows.Close()
var users []User
for rows.Next() {
var u User
if err := rows.Scan(&u.ID, &u.Name); err != nil {
return nil, fmt.Errorf("scan user: %w", err)
}
users = append(users, u)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("rows iteration: %w", err)
}
return users, nil
}
2. Трехзначная логика и NULL
Вторая классическая ошибка разработчиков, пришедших из языков с булевой алгеброй — попытка проверить поле на отсутствие данных через знак равенства:
-- ❌ НЕВЕРНО: Всегда вернет 0 строк!
SELECT id FROM users WHERE deleted_at = NULL;
Реляционный мир SQL оперирует не классической булевой логикой, а трехзначной логикой (Three-Valued Logic, 3VL), включающей три состояния: TRUE, FALSE и UNKNOWN.
NULL в реляционной теории — это не число ноль и не пустая строка. Это маркер отсутствия информации или неопределенности. Нельзя утверждать, равно ли «неизвестное» другому «неизвестному». Поэтому любое математическое или логическое сравнение с NULL (даже парадоксальное NULL = NULL или NULL != NULL) всегда возвращает результат UNKNOWN.
А предложение WHERE пропускает кортеж дальше по конвейеру только и исключительно в том случае, если итоговое выражение вычислено в строгое TRUE. Значения FALSE и UNKNOWN безжалостно отбрасываются.
Для проверки отсутствия данных стандарт SQL предоставляет специализированные предикаты:
-- ✅ ВЕРНО: Корректная проверка на присутствие или отсутствие значения
SELECT id FROM users WHERE deleted_at IS NULL;
SELECT id FROM users WHERE deleted_at IS NOT NULL;
Глубокий разбор трехзначных таблиц истинности и подводных камней агрегации ждет нас в статье 12. Работа с NULL в SQL.
Итог
WHEREвыполняет операцию селекции ($\sigma$), фильтруя кортежи максимально близко к физическому накопителю. Никогда не вычитывайте миллионы строк в Go, чтобы отфильтровать их цикломfor.- Архитектура предикатов определяет способ доступа: высокоселективные условия ведут к быстрому Index Scan, а неселективные или сломанные предикаты провоцируют тяжелый Sequential Scan.
- Соблюдайте SARGability: держите индексируемые колонки "голыми" слева от оператора сравнения. Любая функция над колонкой уничтожает возможность поиска по B-Tree.
- Передача срезов Go в
WHERE ... INтребует генерации списка плейсхолдеров ($1, $2, ...) либо использования оператора= ANY($1)в связке с драйвером Postgres. NULLподчиняется трехзначной логике (3VL): сравнение через=всегда даетUNKNOWN. Для проверки маркера отсутствия используйте толькоIS NULLиIS NOT NULL.
Ограничив выборку нужными предикатами, мы часто хотим упорядочить результат или ограничить размер пачки для постраничного вывода (пагинации). О том, как сортировка влияет на оперативную память и дисковый Spilling, мы поговорим в следующей статье: 4. ORDER BY, LIMIT.