🗄️ Розділ 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 разів через дискове введення-виведення (I/O).
    • Фільтр у WHERE зменшує кількість рядків, що надходять на вхід HashAggregate, утримуючи операцію в швидкій RAM.
  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.

Пов’язані теми