🗄️ Раздел 1 · Вопрос #11

В чём разница между WHERE и HAVING?

Представьте инспекцию ящиков с яблоками в саду:


🟢 Junior Level

WHERE и HAVING — это ключевые слова SQL для фильтрации данных, но они работают с принципиально разными объектами и на разных этапах выполнения запроса:

  • WHERE — фильтрует исходные строки таблицы ДО того, как они объединяются в группы.
  • HAVING — фильтрует уже сформированные группы ПОСЛЕ выполнения агрегации (GROUP BY).
graph LR
    A["Таблица (Строки)"] --> B["WHERE (Фильтрация строк)"]
    B --> C["GROUP BY (Группировка)"]
    C --> D["HAVING (Фильтрация групп)"]
    D --> E["SELECT / Вывод"]

Наглядная аналогия

Представьте инспекцию ящиков с яблоками в саду:

  1. WHERE: Сначала вы перебираете все яблоки поштучно и отбрасываете гнилые (WHERE quality = 'GOOD').
  2. GROUP BY: Оставшиеся хорошие яблоки раскладываете по ящикам согласно сорту (GROUP BY apple_variety).
  3. HAVING: Вы взвешиваете каждый получившийся ящик и отправляете на склад только те ящики, где суммарный вес больше 50 кг (HAVING SUM(weight) > 50).

Базовый пример

-- WHERE отсекает отменённые заказы до агрегации
-- HAVING оставляет только клиентов с суммарным чеком более 10 000 ₽
SELECT 
    user_id,
    COUNT(*) AS total_orders,
    SUM(amount) AS total_spent
FROM orders
WHERE status != 'CANCELLED'       -- 1. Фильтр отдельных строк (до группировки)
GROUP BY user_id                  -- 2. Объединение в группы по пользователям
HAVING SUM(amount) > 10000;       -- 3. Фильтр групп по значению агрегата

Простое мнемоническое правило

  • Нужно отфильтровать данные по значению колонки (id, created_at, status) → используйте WHERE.
  • Нужно отфильтровать по результату агрегатной функции (COUNT, SUM, AVG, MIN, MAX) → используйте HAVING.
  • В условии WHERE агрегатные функции использовать запрещено (СУБД выдаст синтаксическую ошибку aggregate functions are not allowed in WHERE).

🟡 Middle Level

Логический порядок выполнения SQL-запроса (Logical Query Processing)

Чтобы безошибочно понимать поведение СУБД, необходимо знать, что запрос выполняется не в том порядке, в котором он написан:

SELECT user_id, COUNT(*) AS orders_count -- 5. SELECT (формирование проекции)
FROM orders                              -- 1. FROM (определение источника)
WHERE status = 'PAID'                    -- 2. WHERE (фильтрация строк, работа индексов)
GROUP BY user_id                         -- 3. GROUP BY (формирование групп)
HAVING COUNT(*) >= 5                     -- 4. HAVING (фильтрация групп)
ORDER BY orders_count DESC               -- 6. ORDER BY (сортировка вывода)
LIMIT 10;                                -- 7. LIMIT (усечение результата)

Почему агрегаты запрещены в WHERE: На шаге 2 (WHERE) группы строк ещё физически не сформированы, а промежуточные аккумуляторы агрегатов (COUNT, SUM) ещё не инициализированы. СУБД читает строки по одной и проверяет истинность предиката.

Влияние на производительность и план выполнения

Самая распространённая ошибка — помещение неагрегатных условий в блок HAVING:

-- ❌ КАТАСТРОФА: неагрегатный предикат в HAVING
SELECT department_id, AVG(salary)
FROM employees
GROUP BY department_id
HAVING department_id = 42; 
-- СУБД вынуждена прочитать и сгруппировать миллионы строк ВСЕХ отделов, 
-- а затем отбросить все группы, кроме отдела 42!

-- ✅ ЭТАЛОН: фильтрация в WHERE
SELECT department_id, AVG(salary)
FROM employees
WHERE department_id = 42   -- Индекс по department_id отсекает лишнее ДО группировки
GROUP BY department_id;
-- СУБД читает и группирует только строки отдела 42.

Сравнительная таблица WHERE vs HAVING

Критерий WHERE HAVING
Объект фильтрации Отдельные строки (Row-level) Агрегированные группы (Group-level)
Этап вычисления До GROUP BY После GROUP BY
Использование индексов ✅ Эффективно (Index Scan / Index Only Scan) ❌ Нет (фильтрует агрегированный поток)
Агрегатные функции ❌ Запрещены (COUNT, SUM вызовут ошибку) ✅ Разрешены и рекомендуются
Влияние на память Снижает нагрузку на work_mem Не снижает объём строк в хэш-таблице агрегации
Работа без GROUP BY ✅ Стандартный сценарий ✅ Допустим (вся таблица считается одной группой)

Конструкция HAVING без GROUP BY

Если указать HAVING без явного GROUP BY, вся таблица неявно считается одной группой:

-- Запрос вернёт одну строку, если в таблице больше 100 000 пользователей, иначе 0 строк
SELECT 'High traffic system' AS system_flag
FROM users
HAVING COUNT(*) > 100000;

Конструкция FILTER (WHERE …) — современная альтернатива в PostgreSQL

Начиная с SQL:2003 и в современных версиях PostgreSQL для условной агрегации рекомендуется использовать конструкцию FILTER (WHERE ...) вместо раздувания условий HAVING или использования CASE WHEN:

-- Подсчёт разных метрик в рамках одного запроса:
SELECT 
    department_id,
    COUNT(*) AS total_employees,
    COUNT(*) FILTER (WHERE salary > 100000) AS high_earners,
    AVG(salary) FILTER (WHERE hire_date >= '2023-01-01') AS new_hires_avg_salary
FROM employees
GROUP BY department_id
HAVING COUNT(*) >= 5; -- HAVING фильтрует всю группу, FILTER — конкретный агрегат!

Проекция в Java, Spring Data JPA и Hibernate

В backend-разработке на Java разработчики часто сталкиваются с WHERE и HAVING в JPQL/HQL и Criteria API:

// Пример JPQL с WHERE и HAVING:
@Query("""
    SELECT o.userId, COUNT(o.id), SUM(o.amount)
    FROM Order o
    WHERE o.status = :status
    GROUP BY o.userId
    HAVING SUM(o.amount) > :minSpend
""")
List<UserOrderStats> findTopSpenders(
    @Param("status") OrderStatus status, 
    @Param("minSpend") BigDecimal minSpend
);

Критическая ошибка junior-разработчиков (In-Memory Processing): Выгрузить все 500 000 заказов в память приложения через findAll() и попытаться сымитировать SQL-агрегацию через Java Stream API:

// ❌ АНТИПАТТЕРН: перегрузка памяти JVM (OutOfMemoryError) и базы данных
List<Order> orders = orderRepository.findAll();
Map<Long, Double> userSpending = orders.stream()
    .filter(o -> o.getStatus() == OrderStatus.PAID) // Аналог WHERE
    .collect(Collectors.groupingBy(
        Order::getUserId, 
        Collectors.summingDouble(Order::getAmount)
    ))
    .entrySet().stream()
    .filter(entry -> entry.getValue() > 10000.0)    // Аналог HAVING
    .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
// База данных делает это за миллисекунды с использованием индексов и без перекачки гигабайт по сети!

🔴 Senior Level

Физические операторы агрегации в PostgreSQL и давление на work_mem

PostgreSQL выполняет агрегацию одним из двух физических методов:

  1. HashAggregate:
    • Планировщик строит в оперативной памяти хэш-таблицу, где ключом является группа (GROUP BY key), а значениями — аккумуляторы агрегатов.
    • Размер выделяемой памяти на одну операцию ограничен параметром work_mem (по умолчанию 4 МБ).
    • Опасность: HAVING не спасает от переполнения памяти! В хэш-таблицу попадают все строки, прошедшие фильтр WHERE. Если объём агрегируемых данных превышает work_mem, PostgreSQL сбрасывает разделы хэш-таблицы на диск (spill to disk) во временные файлы (temp_files). Это приводит к деградации производительности в 10–100 раз из-за дискового ввода-вывода.
    • Фильтр в WHERE уменьшает число строк, поступающих на вход HashAggregate, удерживая операцию в оперативной памяти.
  2. GroupAggregate:
    • Требует предварительно отсортированного потока данных (через явный узел Sort или через чтение из B-tree индекса по колонкам группировки).
    • Потребляет константный объём памяти $O(1)$ на группу, вычисляя агрегаты потоково.
    • Если сортировка выполняется в памяти, она также конкурирует за work_mem.
Сценарий: 10 000 000 строк в таблице logs (work_mem = 4MB)

Вариант 1 (Фильтр в HAVING):
SELECT service_id, COUNT(*) 
FROM logs 
GROUP BY service_id 
HAVING service_id = 'auth-service';
-> HashAggregate: читает 10 000 000 строк -> Spill to disk (temp_files ~ 180MB) -> Время: 6.8 сек.

Вариант 2 (Фильтр в WHERE):
SELECT service_id, COUNT(*) 
FROM logs 
WHERE service_id = 'auth-service' 
GROUP BY service_id;
-> Index Scan -> HashAggregate: читает 15 000 строк -> In-Memory (Memory: 64kB) -> Время: 8 мс.

Механизм Filter Push-Down и его ограничения

Начиная с PostgreSQL 12+, оптимизатор запросов реализует эвристику Filter Push-Down: если в секцию HAVING по ошибке помещено условие, не зависящее от агрегатных функций, оптимизатор попытается автоматически «протолкнуть» его вниз — в секцию WHERE.

-- Исходный запрос
SELECT category_id, COUNT(*)
FROM products
GROUP BY category_id
HAVING category_id > 100 AND COUNT(*) > 5;

-- План выполнения (EXPLAIN):
-- Filter (Push-down): (category_id > 100) перемещается в узел Seq Scan / Index Scan
-- На уровне Aggregates остаётся только: (COUNT(*) > 5)

Когда Filter Push-Down гарантированно НЕ сработает:

  1. Волатильные функции (Volatile Functions):
    -- random() волатилен, оптимизатор не имеет права менять порядок его вызова
    SELECT category_id, COUNT(*)
    FROM products
    GROUP BY category_id
    HAVING category_id > random() * 100;
    
  2. CTE с модификатором MATERIALIZED: Ограничивающий барьер оптимизации (Optimization Fence) в WITH cte AS MATERIALIZED (...) препятствует сквозному переносу условий.
  3. Сложные представления (Views) с UNION/GROUP BY: Если условие наложено снаружи view с группировкой, СУБД не всегда может протолкнуть предикат внутрь агрегата.
  4. Алиасы и скалярные подзапросы в HAVING: Подзапросы внутри HAVING выполняются на уровне групп.

[!IMPORTANT] Наличие оптимизатора не отменяет ответственности инженера: полагаться на Filter Push-Down запрещено правилами написания продакшн-запросов. Неагрегатные условия обязаны находиться строго в WHERE.

Почему Window Functions несовместимы с WHERE и HAVING

Оконные функции (ROW_NUMBER(), RANK(), SUM() OVER(...)) вычисляются на шаге WindowAgg, который в логическом пайплайне находится строго после HAVING и непосредственно перед SELECT.

Логический конвейер:
FROM -> WHERE -> GROUP BY -> HAVING -> WindowAgg -> SELECT -> DISTINCT -> ORDER BY -> LIMIT

По этой причине:

  • Нельзя отфильтровать оконную функцию в WHERE (она ещё не существует).
  • Нельзя отфильтровать оконную функцию в HAVING (HAVING работает с группами GROUP BY, а не окнами PARTITION BY).
-- ❌ ОШИБКА СИНТАКСИСА: window functions are not allowed in HAVING
SELECT department_id, salary, 
       ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) as rn
FROM employees
HAVING ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) <= 3;

-- ✅ ЭТАЛОННОЕ РЕШЕНИЕ: Оборачивание в CTE или подзапрос
WITH RankedEmployees AS (
    SELECT department_id, salary,
           ROW_NUMBER() OVER(PARTITION BY department_id ORDER BY salary DESC) AS rn
    FROM employees
)
SELECT department_id, salary, rn
FROM RankedEmployees
WHERE rn <= 3; -- Внешний WHERE фильтрует готовые строки окна

Архитектура составных индексов под WHERE + GROUP BY

Для запросов, содержащих одновременно WHERE и GROUP BY, решающее значение имеет порядок колонок в составном индексе (B-tree Composite Index):

SELECT tenant_id, status, COUNT(*)
FROM payments
WHERE tenant_id = 'tenant-42'
GROUP BY status;

Анализ вариантов индекса:

  • Индекс (tenant_id, status):
    1. Планировщик выполняет Index Scan точно к ветке tenant_id = 'tenant-42'.
    2. Внутри этого поддерева данные уже отсортированы по status.
    3. PostgreSQL может применить быстрый потоковый GroupAggregate без дополнительной сортировки (Sort узел отсутствует в плане!).
  • Индекс (status, tenant_id):
    1. Планировщику придётся сканировать весь индекс или выполнять Bitmap Index Scan.
    2. Потребуется явная фаза Sort перед агрегацией.

Анализ реального плана запроса в EXPLAIN (ANALYZE, BUFFERS)

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, COUNT(*) AS orders_cnt
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY customer_id
HAVING COUNT(*) > 10;

QUERY PLAN:
HashAggregate  (cost=12540.20..12890.50 rows=3503 width=12) (actual time=48.210..52.140 rows=142 loops=1)
  Group Key: customer_id
  Filter: (count(*) > 10)
  Rows Removed by Filter: 8450
  Buffers: shared hit=4210 read=180
  ->  Bitmap Heap Scan on orders  (cost=142.10..11200.00 rows=152000 width=4) (actual time=3.120..22.450 rows=151800 loops=1)
        Recheck Cond: (created_at >= '2026-01-01'::date)
        Buffers: shared hit=890 read=180
        ->  Bitmap Index Scan on idx_orders_created_at  (cost=0.00..104.10 rows=152000 width=0) (actual time=2.100..2.100 rows=151800 loops=1)
              Index Cond: (created_at >= '2026-01-01'::date)
              Buffers: shared hit=45

Что показывает план:

  1. Bitmap Index Scan + Bitmap Heap Scan: фильтрация created_at в блоке WHERE отсекла миллионы старых записей на диске по индексу.
  2. В HashAggregate поступили только 151 800 актуальных строк.
  3. Узел HashAggregate сформировал группы и применил фильтр Filter: (count(*) > 10). Метрика Rows Removed by Filter: 8450 показывает, сколько групп отброшено условием HAVING.

🎯 Шпаргалка для интервью

Обязательно знать (Первые 30 секунд ответа)

  • Фундаментальная разница: WHERE фильтрует строки до агрегации; HAVING фильтрует группы после агрегации.
  • Индексы: WHERE активно использует B-tree/BRIN индексы и уменьшает объём читаемых данных; HAVING проверяет предикаты на выходе хэш-таблицы или сортировки.
  • Агрегатные функции: В WHERE агрегаты использовать запрещено (синтаксическая ошибка); в HAVING агрегаты — основное предназначение.
  • Порядок выполнения: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT.
  • Без GROUP BY: HAVING может использоваться без GROUP BY — в этом случае вся таблица выступает как одна сводная группа.

Частые уточняющие вопросы на засыпку

  1. «Что произойдёт, если неагрегатное поле указать в HAVING вместо WHERE?» Ответ: В старых версиях СУБД (PostgreSQL < 12, MySQL 5.6) это приводит к колоссальному падению производительности: СУБД сначала группирует абсолютно все строки таблицы, заполняя память (work_mem) или сбрасывая данные на диск (temp_files), и лишь потом отбрасывает лишние группы. В современных версиях оптимизатор попытается сделать Filter Push-Down, но полагаться на это в продакшене недопустимо.

  2. «Можно ли использовать алиас из секции SELECT внутри WHERE или HAVING?» Ответ: В стандартном SQL алиасы из SELECT недоступны ни в WHERE, ни в HAVING, так как этап SELECT выполняется позже них. Однако PostgreSQL и MySQL в виде расширения стандарта разрешают использовать алиасы выражений в HAVING и GROUP BY, но запрещают в WHERE. Для переносимости кода следует повторять агрегатную функцию в HAVING либо использовать CTE/подзапрос.

  3. «В чём разница между COUNT(*) FILTER (WHERE status = 'PAID') и HAVING status = 'PAID'?» Ответ: Условие HAVING status = 'PAID' применимо только если status входит в GROUP BY, и оно отбрасывает всю группу целиком, если условие ложно. Предложение FILTER (WHERE ...) вычисляет агрегат внутри группы только по совпадающим строкам, не исключая саму группу из итогового набора данных.

  4. «Как отфильтровать строки по результату оконной функции?» Ответ: Напрямую ни в WHERE, ни в HAVING это сделать невозможно, так как оконные функции (WindowAgg) рассчитываются после шага HAVING. Фильтрация оконных функций производится исключительно через оборачивание запроса в CTE (WITH) или подзапрос с последующим фильтром во внешнем WHERE.

Красные флаги (НЕ говорить на интервью)

  • ❌ «HAVING быстрее WHERE, если колонка входит в индекс» — грубейшее непонимание. Индексы применяются в WHERE. HAVING вычисляется над уже сформированными агрегатными слотами.
  • ❌ «Если в запросе есть GROUP BY, все фильтры нужно писать в HAVING» — фатальная ошибка, приводящая к чтению всей таблицы вместо выборочного сканирования.
  • ❌ «HAVING уменьшает размер используемой памяти work_mem при агрегации» — нет, строки попадают в структуру HashAggregate до применения фильтра HAVING.
  • ❌ «Оконные функции можно передавать в HAVING, если они делят строки по той же колонке, что и GROUP BY» — синтаксис SQL строго запрещает Window Functions в секции HAVING.

Связанные темы