open atlas
↑ К треку
SQL и PostgreSQL вглубь SQL · 03 · 02

HAVING против WHERE: фильтр до или после группировки

WHERE фильтрует строки до группировки; HAVING фильтрует целые группы после агрегации. В WHERE агрегат нельзя, а проталкивание построчных фильтров в WHERE — реальный рычаг производительности.

SQL Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior

Отчёт по выручке показывает «топ-клиентов с более чем $10k оплаченных заказов». Первый черновик засунул paid и > 10000 в один HAVING и работал 9 секунд, сканируя каждый когда-либо сделанный заказ. Перенос одного предиката на одно предложение вверх — paid в WHERE — уронил время до 200мс, потому что группировке внезапно осталось переваривать в десять раз меньше строк. WHERE против HAVING — это не стиль; это меняет работу движка.

Два фильтра в два разных момента

У агрегации фиксированный конвейер, и два фильтрующих предложения стоят по разные стороны свёртки:

  1. FROM / JOIN — собрать сырые строки.
  2. WHERE — отбросить строки, которые не нужны. Работает до группировки, поэтому действует на отдельные строки и не видит ни одного агрегата (групп ещё нет).
  3. GROUP BY — свернуть пережившие строки в корзины.
  4. HAVING — отбросить целые группы, которые не нужны. Работает после агрегации, поэтому может ссылаться на агрегаты вроде SUM(total) или COUNT(*).
  5. SELECT / ORDER BY / LIMIT — оформить вывод.

В совокупности эти шаги означают, что у каждого фильтра есть ровно одно верное место: если условие об отдельной строке — шаг 2 (WHERE); если о результате свёртки группы — шаг 4 (HAVING). Поставишь построчный фильтр на шаг 4 — зря оплатишь всю работу группировки по строкам, которые собирался выбросить.

Конкретно — правильный отчёт:

SELECT user_id, SUM(total) AS paid_revenue
FROM orders
WHERE status = 'paid'          -- построчное условие → до группировки
GROUP BY user_id
HAVING SUM(total) > 10000;      -- условие на агрегат → после группировки

status = 'paid' решается на одной строке, поэтому идёт в WHERE и сжимает вход для GROUP BY. SUM(total) > 10000 можно вычислить только когда целая корзина существует, поэтому это обязательно HAVING.

Почему агрегаты в WHERE недопустимы

Попробуй перенести порог в WHERE — и Postgres откажет:

SELECT user_id, SUM(total)
FROM orders
WHERE SUM(total) > 10000        -- ERROR: aggregate functions are not allowed in WHERE
GROUP BY user_id;

Это не недостающая фича — это невозможность по времени. WHERE работает до GROUP BY, в точке, где ещё нет ни одной группы, так что SUM(total) нечего суммировать. Агрегат в этот момент не определён. HAVING существует именно как фильтр, работающий после того, как группы (и их агрегаты) материализуются.

Рычаг производительности: проталкивай фильтры вниз

Вот часть, отделяющая корректный запрос от быстрого. Оба возвращают одни и те же строки:

-- МЕДЛЕННО: каждый заказ группируется, потом неоплаченные группы... всё равно неверно и дорого
SELECT user_id, SUM(total)
FROM orders
GROUP BY user_id
HAVING bool_and(status = 'paid');   -- вычурно и сначала группирует ВСЕ заказы

-- БЫСТРО: отбросить неоплаченные строки до того, как они дойдут до группировки
SELECT user_id, SUM(total)
FROM orders
WHERE status = 'paid'
GROUP BY user_id;

Построчный фильтр место в WHERE, чтобы до (дорогого) шага группировки доходило меньше строк. Группировке приходится хешировать или сортировать вход; вдвое меньший вход примерно вдвое дешевле, а если есть частичный индекс по status, планировщик может вовсе пропустить несовпадающие строки. Положить простое построчное условие в HAVING — это code smell (признак проблемного кода): обычно это значит, что фильтр, который должен был отработать до группировки, работает после неё, выполняя всю работу группировки над строками, которые ты собирался выбросить.

Подкрепим цифрами. На таблице orders в 12М строк, где paid всего ~1.2М, версия только с HAVING в EXPLAIN ANALYZE выглядит как Seq Scan (rows=12000000), питающий HashAggregate, который строит корзину для каждого пользователя — 9с по часам, причём агрегат спиллит на диск (Disk Usage: 180000 kB), ведь пришлось материализовать все 2М групп пользователей. Перенеси status='paid' в WHERE — и план становится Bitmap Index Scan по частичному индексу WHERE status='paid', возвращающим rows=1200000, который питает HashAggregate из ~400k корзин переживших пользователей, теперь влезающий в work_mem (строки Disk нет) — ~200мс. Те же строки результата, но группировка пережевала в 10 раз меньше входных строк и построила в ~5 раз меньше корзин. Этот рычаг не микрооптимизация: это разница между запросом на 9с и на 0.2с.

Частая ошибка

Классическая ловушка: HAVING status = 'paid'. В Postgres это синтаксически законно, только если status функционально зависит от ключа группировки — иначе получишь ту же ошибку «must appear in GROUP BY». Даже когда парсится, это неверный подход: ты сгруппировал каждый заказ, включая те, что отбросишь. Правило большого пальца — если условие не упоминает ни одного агрегата, оно почти всегда место в WHERE. Резервируй HAVING для условий на SUM, COUNT, AVG, MAX, MIN и т.п.

Викторина

Почему WHERE SUM(total) > 10000 поднимает ошибку?

Викторина

Тебе нужна оплаченная выручка на пользователя свыше $10k. В какое предложение идёт status='paid' и почему?

Расставь шаги по порядку

Упорядочи предложения по тому, когда Postgres логически их применяет:

  1. 1 FROM / JOIN — собрать сырые строки
  2. 2 WHERE — фильтр отдельных строк
  3. 3 GROUP BY — свернуть строки в корзины
  4. 4 HAVING — фильтр целых групп через агрегаты
  5. 5 SELECT — спроецировать выходные колонки
  6. 6 ORDER BY / LIMIT — отсортировать и обрезать
Вспомните перед уходом
  1. 01
    Одним предложением о каждом: что фильтруют WHERE и HAVING и в каком порядке работают?
  2. 02
    Почему WHERE SUM(total) > 10000 это ошибка и в чём фикс?
  3. 03
    Почему положить status = 'paid' в HAVING вместо WHERE — это и smell, и проблема производительности?
Итог

Конвейер агрегации идёт FROMWHEREGROUP BYHAVINGSELECTORDER BY, и два фильтра стоят по разные стороны свёртки. WHERE фильтрует отдельные строки до группировки и поэтому не может ссылаться на агрегат — групп ещё нет, так что SUM(total) не определён и Postgres его отвергает. HAVING фильтрует целые группы после агрегации и именно туда место агрегатным условиям вроде SUM(total) > 10000. Сеньорская привычка — проталкивать каждый построчный предикат в WHERE, чтобы до дорогого шага группировки доходило меньше строк (вдвое меньший вход примерно вдвое дешевле по hash/sort, и частичные индексы могут отсечь строки), и считать простое неагрегатное условие, застрявшее в HAVING, smell’ом. Дальше углубимся в сами агрегатные функции: чем COUNT(*) отличается от COUNT(col), предложение FILTER и числовые подводные камни AVG и SUM.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.