Когда использовать HAVING?
Правильное разделение фильтров — признак хорошего SQL:
🟢 Junior Level
HAVING используется, когда нужно отфильтровать результаты после группировки по агрегатным функциям (COUNT, SUM, AVG и т.д.).
Простое правило:
- Фильтр по обычным полям →
WHERE - Фильтр по результатам агрегации →
HAVING
Пример:
-- Найти пользователей с более чем 5 заказами
SELECT user_id, COUNT(*) as orders_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 5; -- Фильтр ПОСЛЕ группировки
-- Найти отделы со средней зарплатой > 100,000
SELECT department, AVG(salary) as avg_sal
FROM employees
GROUP BY department
HAVING AVG(salary) > 100000;
Частые сценарии:
- Поиск дубликатов (
HAVING COUNT(*) > 1) - Поиск групп, превышающих порог
- Фильтрация по средним/суммарным значениям
🟡 Middle Level
Архитектурный паттерн: WHERE + HAVING
Правильное разделение фильтров — признак хорошего SQL:
SELECT customer_id, AVG(amount) as avg_amount
FROM orders
WHERE status = 'delivered' -- 1. Фильтр ДО группировки (индекс!)
AND created_at > '2024-01-01' -- 2. Сокращаем входные данные
GROUP BY customer_id
HAVING COUNT(*) > 10 -- 3. Фильтр ПОСЛЕ группировки
AND AVG(amount) > 5000; -- 4. Бизнес-логика
Почему так важно:
WHEREсокращает количество строк ДО GROUP BY → меньше работыHAVINGфильтрует уже сгруппированные данные
Основные сценарии
1. Агрегированные пороги:
-- Продавцы с выручкой > 1 млн
SELECT seller_id, SUM(price) as revenue
FROM sales
GROUP BY seller_id
HAVING SUM(price) > 1000000;
2. Поиск дубликатов:
-- Дубликаты email
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
3. Проверка свойств группы:
-- Проекты с проблемами (больше 2 заблокированных задач)
SELECT project_id
FROM tasks
GROUP BY project_id
HAVING COUNT(*) > 5
AND COUNT(*) FILTER (WHERE status = 'blocked') > 2;
4. HAVING без GROUP BY:
-- Проверка условия по всей таблице
SELECT 'Warning: High avg salary'
FROM employees
HAVING AVG(salary) > 200000;
-- Вернёт 1 строку если условие истинно, иначе 0 строк
Типичные ошибки
- Фильтрация неагрегатного поля в HAVING
-- ❌ ПЛОХО: city не агрегат SELECT city, COUNT(*) FROM users GROUP BY city HAVING city = 'Moscow'; -- ✅ ХОРОШО: в WHERE SELECT city, COUNT(*) FROM users WHERE city = 'Moscow' GROUP BY city; - Избыточность в ORM
-- ORM часто генерирует: SELECT city, COUNT(*) FROM users GROUP BY city HAVING city = 'Moscow'; -- Это антипаттерн! Город должен быть в WHERE -- Позволяет использовать индекс и сократить данные для агрегации
Сравнение WHERE и HAVING
| Сценарий | WHERE | HAVING |
|---|---|---|
status = 'ACTIVE' |
✅ | ❌ (если нет GROUP BY) |
COUNT(*) > 10 |
❌ Нельзя | ✅ |
SUM(amount) > 1000 |
❌ Нельзя | ✅ |
city = 'Moscow' |
✅ | ⚠️ Можно, но плохо |
🔴 Senior Level
Когда НЕ использовать HAVING
- Для неагрегатных фильтров —
HAVING city = 'Moscow'группирует ВСЕ города, потом фильтрует. Перенесите в WHERE - Для фильтрации по оконным функциям — оконные функции считаются ПОСЛЕ HAVING. Используйте подзапрос/CTE
- Когда групп слишком много — миллионы групп → spill на диск. Пре-фильтруйте в WHERE или используйте Materialized Views
Проблема huge number of groups
Если после WHERE остаётся 100 млн строк и 10 млн групп:
-- Проблема: база должна выделить память для ВСЕХ 10 млн групп
SELECT user_id, SUM(amount)
FROM orders
WHERE created_at > '2023-01-01' -- 100 млн строк
GROUP BY user_id -- 10 млн групп
HAVING SUM(amount) > 10000; -- Фильтр после
Что происходит:
- HashAggregate пытается разместить 10 млн групп в
work_mem(work_mem — память для одной операции, по умолчанию 4MB) - Если не влезает → spill на диск (temp_files — временные файлы на диске)
- Производительность падает в 10-100 раз (операции с диском в миллионы раз медленнее RAM)
Решения:
-- 1. Предварительная фильтрация через CTE
WITH filtered_orders AS (
SELECT user_id, amount
FROM orders
WHERE created_at > '2023-01-01'
AND amount > 100 -- Дополнительный фильтр
)
SELECT user_id, SUM(amount)
FROM filtered_orders
GROUP BY user_id
HAVING SUM(amount) > 10000;
-- 2. Материализованное представление для частых запросов
CREATE MATERIALIZED VIEW mv_user_stats AS
SELECT user_id, SUM(amount) as total, COUNT(*) as cnt
FROM orders
GROUP BY user_id;
-- Потом быстрый запрос
SELECT user_id FROM mv_user_stats WHERE total > 10000;
FILTER в HAVING для декларативности
-- Элегантная проверка сложных условий
SELECT project_id
FROM tasks
GROUP BY project_id
HAVING
COUNT(*) > 10 -- Минимум 10 задач
AND COUNT(*) FILTER (WHERE status = 'done') >= 8 -- 80% выполнено
AND COUNT(*) FILTER (WHERE status = 'blocked') = 0 -- Нет блокировок
AND MAX(due_date) > CURRENT_DATE; -- Дедлайн не прошёл
Redundancy и оптимизатор
PostgreSQL пытается делать Filter Push-down — переносить условия из HAVING в WHERE:
-- Запрос
SELECT city, COUNT(*) FROM users GROUP BY city HAVING city = 'Moscow';
-- Оптимизатор превращает в:
SELECT city, COUNT(*) FROM users WHERE city = 'Moscow' GROUP BY city;
НО: Это работает не всегда!
- Сложные выражения с подзапросами
- Внутри Views
- CTE с
MATERIALIZED
Золотое правило: Всегда пишите неагрегатные фильтры в WHERE явно.
Стандарты SQL и переносимость
-- ⚠️ Работает в PostgreSQL, НЕ работает в SQL Server/Oracle
SELECT city, COUNT(*) as cnt FROM users
GROUP BY city
HAVING cnt > 10; -- cnt — алиас из SELECT
-- ✅ Переносимо во все СУБД
SELECT city, COUNT(*) FROM users
GROUP BY city
HAVING COUNT(*) > 10;
Edge Cases
- HAVING с подзапросом
-- Пользователи, у которых сумма заказов больше средней SELECT user_id, SUM(amount) as total FROM orders GROUP BY user_id HAVING SUM(amount) > (SELECT AVG(total) FROM ( SELECT SUM(amount) as total FROM orders GROUP BY user_id ) sub); - HAVING с оконными функциями — НЕЛЬЗЯ
-- ❌ ОШИБКА: оконные функции считаются ПОСЛЕ HAVING SELECT user_id, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) as rn FROM orders GROUP BY user_id, amount, created_at HAVING rn = 1; -- Ошибка! -- ✅ Решение: CTE WITH ranked AS ( SELECT user_id, amount, ROW_NUMBER() OVER(PARTITION BY user_id ORDER BY created_at DESC) as rn FROM orders ) SELECT user_id, amount FROM ranked WHERE rn = 1; - HAVING с несколькими GROUP BY
-- ROLLUP + HAVING SELECT department, city, COUNT(*) FROM employees GROUP BY ROLLUP(department, city) HAVING COUNT(*) > 5; -- Фильтрует ВСЕ уровни (детальные и итоговые строки)
Performance Impact
-- Мониторинг давления на work_mem
EXPLAIN (ANALYZE, BUFFERS)
SELECT status, COUNT(*) FROM orders GROUP BY status HAVING COUNT(*) > 100;
-- Ищите:
-- HashAggregate (memory usage: X kB) → если X близко к work_mem → spill риск
-- HashAggregate (disk: Y kB) → spill произошёл! Увеличьте work_mem
Production Experience
Реальный сценарий:
- E-commerce: отчёт по продажам (500 млн строк)
- ORM генерировал
HAVING status = 'COMPLETED'вместоWHERE - HashAggregate: 500 млн строк → spill 30 ГБ на диск
- Время: 15 минут
- Решение:
WHERE status = 'COMPLETED'до GROUP BY - Результат: 10 секунд (ускорение в 90 раз)
- Почему: ORM генерировал
HAVING status = 'COMPLETED'— база сначала группировала все 500 млн строк (включая отменённые, возвращённые и т.д.), создавая миллионы групп, и только потом фильтровала. Перенос в WHERE сократил входные данные для GROUP BY в 10 раз + позволил использовать индекс по status.
Monitoring
-- 1. Поиск запросов с HAVING в pg_stat_statements
SELECT query, mean_exec_time, calls, shared_blks_read
FROM pg_stat_statements
WHERE query LIKE '%HAVING%'
ORDER BY mean_exec_time DESC
LIMIT 10;
-- 2. Проверка на spill
EXPLAIN (ANALYZE, BUFFERS)
SELECT ... GROUP BY ... HAVING ...;
-- "Disk: X kB" → проблема!
-- 3. Проверка Filter Push-down
EXPLAIN (VERBOSE)
SELECT city, COUNT(*) FROM users GROUP BY city HAVING city = 'Moscow';
-- В плане увидите, перенесён ли фильтр
Best Practices
- WHERE для неагрегатных фильтров, HAVING для агрегатных
- FILTER (WHERE …) внутри HAVING для сложных условий
- Избегайте алиасов из SELECT в HAVING (непереносимо)
- Мониторьте work_mem при больших GROUP BY
- CTE/Materialized Views для предварительной фильтрации
- Проверяйте план на spill (Disk usage)
- Помните: HAVING не спасает от переполнения памяти во время GROUP BY
Резюме для Senior
- HAVING — последний рубеж фильтрации после GROUP BY
- Не экономит ресурсы на этапе агрегации — все группы уже созданы
- Filter Push-down (PG 12+) не всегда работает — пишите WHERE явно
- work_mem давление: HAVING фильтрует ВЫХОД, не ВХОД
- Оконные функции недоступны в HAVING → CTE/подзапрос
- FILTER для декларативности сложных условий
- Переносимость: не используйте алиасы из SELECT в HAVING
🎯 Шпаргалка для интервью
Обязательно знать:
- HAVING фильтрует РЕЗУЛЬТАТ GROUP BY, не входные данные
- WHERE сокращает данные ДО GROUP BY → меньше work_mem, быстрее
- Filter Push-down (PG 12+): автоматический перенос из HAVING в WHERE, но НЕ всегда
- Huge number of groups: 10 млн групп → spill на disk → CTE/Materialized View
- FILTER в HAVING:
COUNT(*) FILTER (WHERE status = 'blocked') > 2 - Оконные функции считаются ПОСЛЕ HAVING → подзапрос/CTE
- Алиасы из SELECT в HAVING: НЕ переносимо (SQL Server/Oracle не поддерживают)
- HAVING с подзапросом: возможно, но медленно
Частые уточняющие вопросы:
- «Почему ORM генерирует HAVING вместо WHERE?» → ORM антипаттерн, не оптимизирует
- «Как избежать spill при 10 млн групп?» → Фильтр в WHERE, work_mem ↑, Materialized View
- «Можно ли FILTER в HAVING?» → Да, элегантно для сложных условий
- «Когда HAVING с подзапросом оправдан?» → Сравнение группы со средней по всем
Красные флаги (НЕ говорить):
- ❌ «HAVING сокращает данные для GROUP BY» (нет, фильтрует ВЫХОД)
- ❌ «Можно положиться на Filter Push-down» (не всегда работает!)
- ❌ «Оконные функции можно в HAVING» (ПОСЛЕ HAVING считаются!)
Связанные темы:
- [[В чём разница между WHERE и HAVING]] → порядок выполнения
- [[Что делает GROUP BY]] → huge number of groups
- [[Что такое оконные функции]] → почему нельзя в HAVING