8. HAVING
«В вычислениях порядок решает всё. Нельзя отфильтровать то, что еще не было вычислено, точно так же, как нельзя собрать урожай, не засеяв поле. Понимание того, когда выполняется каждая фаза — отличительный признак истинного системного инженера».
Парадокс фильтрации: Как отсеять то, чего еще нет
В предыдущей главе 7. GROUP BY и агрегатные функции мы исследовали математику свертки тысяч строк в компактные агрегированные показатели. Однако в инженерной практике бизнес-требования часто формулируются с обратным условием: "Выгрузить только тех клиентов, у которых суммарный объем оплаченных покупок превысил 100 000 рублей".
Естественный импульс инженера, привыкшего к фильтрации через предикаты из главы 3. WHERE и фильтрация — подставить агрегат прямо в условие отбора:
-- ❌ ОШИБКА: СУБД вернет синтаксическую ошибку
SELECT user_id, SUM(amount)
FROM orders
WHERE SUM(amount) > 100000
GROUP BY user_id;
Компилятор любой реляционной СУБД (будь то PostgreSQL, MySQL или SQLite) немедленно прервет транзакцию с сообщением: ERROR: aggregate functions are not allowed in WHERE.
Этот парадокс обнажает фундаментальную основу реляционного конвейера: Порядок выполнения (Order of Execution). СУБД физически не способна отфильтровать кортежи по предикату SUM(amount) на этапе WHERE, поскольку в этот момент времени агрегат еще математически не существует! Операция WHERE выполняется над сырыми дисковыми строками задолго до того, как база приступит к группировке и суммированию.
Для разрешения этого архитектурного противоречия стандарт SQL вводит специализированный оператор HAVING — предикат, применяемый строго к уже сформированным и сгруппированным кортежам:
-- ✅ ВЕРНО: Каноническое использование HAVING
SELECT user_id, SUM(amount) AS total_spent
FROM orders
WHERE status = 'paid'
GROUP BY user_id
HAVING SUM(amount) > 100000;
Жизненный цикл запроса: Конвейер СУБД
Чтобы писать высокопроизводительный бэкенд и безошибочно читать вывод планов выполнения, инженер обязан четко разграничивать лексический порядок написания запроса (как мы его пишем в коде) и логический порядок выполнения (в каком порядке ядро СУБД обрабатывает операторы).
На технических интервью для позиций Middle+ и Senior вопрос о фазах конвейера SQL входит в число обязательных.
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
Disk["Файлы БД на диске"]:::data --> FROM["1 - FROM и JOIN<br>Определение источника данных"]
FROM --> WHERE["2 - WHERE<br>Фильтрация сырых строк"]
WHERE --> GROUP["3 - GROUP BY<br>Формирование групп HashAgg / SortAgg"]
GROUP --> HAVING["4 - HAVING<br>Фильтрация сгруппированных строк"]
HAVING --> SELECT["5 - SELECT<br>Проекция и вычисление алиасов"]
SELECT --> ORDER["6 - ORDER BY<br>Сортировка результата"]
ORDER --> LIMIT["7 - LIMIT / OFFSET<br>Усечение выборки"]
LIMIT --> Network["TCP Socket<br>Отправка в Go"]
Разберем эту строгую последовательность шагов:
FROMиJOIN: Определение отношений, декартово умножение и соединение таблиц по условиямON.WHERE: Первичная селекция — отсечение сырых кортежей до любых группировок (с использованием B-Tree индексов).GROUP BY: Разделение оставшихся строк на корзины эквивалентности (алгоритмамиHashAggилиSortAgg).HAVING: Вторичная фильтрация — отбрасывание целых групп по вычисленным агрегатным критериям.SELECT: Формирование проекции, вычисление скалярных выражений и связывание имен псевдонимов (алиасов).ORDER BY: Упорядочивание результирующего потока (может опираться на алиасы изSELECT, так как выполняется позже).LIMIT/OFFSET: Физическое усечение количества возвращаемых строк.
Почему нельзя использовать алиасы в HAVING?
Mechanical Sympathy: WHERE vs HAVING
Различие между WHERE и HAVING — это не академический спор о синтаксисе, а вопрос выживания аппаратных ресурсов (CPU и памяти work_mem) вашего сервера базы данных.
Рассмотрим практическую задачу: "Получить идентификаторы категорий товаров, в которых насчитывается более 10 активных позиций".
❌ Антипаттерн: Фильтрация через поздний HAVING
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id
HAVING status = 'active' AND COUNT(*) > 10;
Что происходит с аппаратурой:
- СУБД вынуждена вычитать с диска в буферный пул всю таблицу
productsцеликом, включая устаревшие, заблокированные и удаленные товары. - Ядро базы аллоцирует массивную хэш-таблицу в оперативной памяти (
work_mem) под механизм HashAgg. Процессор честно подсчитывает количество абсолютно всех товаров по всем категориям. - И лишь затем, на шаге
HAVING, СУБД анализирует предикатstatus = 'active'и выбрасывает 95% проделанной работы на помойку!
✅ Высокопроизводительный стандарт: Раннее отсечение через WHERE
SELECT category_id, COUNT(*)
FROM products
WHERE status = 'active'
GROUP BY category_id
HAVING COUNT(*) > 10;
Что происходит с аппаратурой:
- На этапе
WHEREСУБД использует B-Tree индекс по полюstatusи мгновенно отсекает весь балласт неактивных строк через быстрыйIndex Scan. - В память механизма группировки (
work_mem) поступает на два порядка меньше кортежей. Хэш-таблица гарантированно удерживается в L3-кэше или быстрой RAM, полностью исключая аварийный сброс временных файлов на диск (Spill to Disk). - Оператор
HAVINGвыполняет только свою непосредственную работу — фильтрует уже свернутые счетчикиCOUNT(*) > 10.
Архитектура: Бэкенд на Go и агрегация
Перенос логики пост-фильтрации групп из базы данных в код приложения на Go — распространенная системная ошибка при проектировании микросервисов.
Рассмотрим антипаттерн, при котором разработчик решает «разгрузить базу» и отфильтровать агрегаты вручную в памяти Go:
// ❌ АНТИПАТТЕРН: Эмуляция HAVING в рантайме Go
rows, err := db.QueryContext(ctx, `SELECT user_id, SUM(amount) FROM orders GROUP BY user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var premiumUsers []int64
for rows.Next() {
var userID int64
var totalAmount float64
if err := rows.Scan(&userID, &totalAmount); err != nil {
return nil, err
}
// Эмуляция HAVING в памяти приложения
if totalAmount > 100000 {
// Слайс вынужден динамически расти через runtime.growslice
premiumUsers = append(premiumUsers, userID)
}
}
Почему этот подход катастрофичен с позиций Mechanical Sympathy?
- Network IO: База данных вынуждена сериализовать в TCP-сокет данные сотен тысяч мелких покупателей с суммой
< 100000. Пропускная способность сетевого интерфейса тратится впустую. - Go Runtime и рефлексия: Метод
rows.Scanпакетаdatabase/sqlвынужден для каждой лишней строки вызывать машинериюreflect, валидируя соответствие типов в рантайме и нагревая ядра CPU на стороне Go-сервиса. - Garbage Collector и аллокации: Срез
premiumUsersпри заполнении многократно вызываетruntime.growslice, копируя буферы в куче. Миллионы временных структур после выхода из функции создают колоссальное давление на сборщик мусора Go, провоцируя частые фазы Mark & Sweep.
Делегирование фильтрации предложению HAVING на стороне СУБД превращает этот кошмар в передачу ровно нескольких десятков байтов: база возвращает готовый список целевых идентификаторов, Go-сервер аллоцирует минимальный буфер, а процессор приложения остается свободным для полезной бизнес-логики. Для подтверждения корректности планов всегда привлекайте инструмент 10. План выполнения запроса. EXPLAIN.
Итог
- Конвейер выполнения:
WHEREработает до агрегации (фильтрует сырые строки), аHAVING— после (фильтрует агрегированные группы). - Никогда не помещайте в
HAVINGпредикаты, которые можно проверить по сырым колонкам (например,status = 'active'). Это перегружает процессор и оперативную память базы данных. ОставляйтеHAVINGисключительно для вычисленных агрегатных метрик (SUM,COUNT,AVG). - Попытка эмулировать
HAVINGна стороне Go-приложения — верный путь к деградации сети и перегрузке Garbage Collector-а. - Помните про запрет использования алиасов из
SELECTв блокеHAVINGсогласно стандарту ANSI SQL. HAVINGбезGROUP BYсинтаксически валиден и трактует всю таблицу как единую неделимую группу.
Мы изучили полный цикл фильтрации, соединений и агрегации в пределах одного составного выражения. Однако в архитектуре систем часто возникает необходимость использовать результат одного вычисления в качестве динамического фильтра для другого. О том, как безопасно и эффективно встраивать одни запросы внутрь других, мы поговорим в следующей статье: 9. Подзапросы.