Що робить GROUP BY?
PostgreSQL використовує дві стратегії для GROUP BY:
🟢 Junior Level
GROUP BY — об’єднує рядки з однаковими значеннями у групи і дозволяє застосовувати до них агрегатні функції.
Проста аналогія: У вас є кошик з фруктами різних типів. GROUP BY — це коли ви розкладаєте їх по купках: яблука окремо, банани окремо, апельсини окремо. Потім можете порахувати, скільки фруктів у кожній купці.
Приклад:
-- Таблиця orders:
-- user_id | amount
-- 1 | 100
-- 1 | 200
-- 2 | 150
-- 2 | 50
-- Групуємо за user_id і рахуємо суму
SELECT user_id, SUM(amount) as total
FROM orders
GROUP BY user_id;
-- Результат:
-- user_id | total
-- 1 | 300
-- 2 | 200
Основні агрегатні функції:
COUNT(*)— кількість рядківSUM(col)— сумаAVG(col)— середнєMIN(col)/MAX(col)— мінімум/максимум
🟡 Middle Level
Як це працює всередині
PostgreSQL використовує дві стратегії для GROUP BY:
work_mem — параметр PostgreSQL, що обмежує об’єм пам’яті для однієї операції. За замовчуванням 4MB. Spill to disk — коли операція не поміщається у work_mem, PostgreSQL записує проміжні дані на диск у тимчасові файли. Це сповільнює виконання у 10-100 разів.
1. HashAggregate
1. Створюємо хеш-таблицю в пам'яті (work_mem)
2. Для кожного рядка:
- Обчислюємо хеш від ключа групування
- Якщо ключ є у хеші → оновлюємо агрегат
- Якщо ні → створюємо новий запис
- Коли: Дані не відсортовані
- Вимагає:
work_memдля зберігання всіх груп
2. GroupAggregate
1. Сортуємо дані за ключем групування
2. Йдемо за відсортованими даними і групуємо підряд однакові ключі
- Коли: Дані вже відсортовані (за індексом)
- Плюс: Майже не вимагає пам’яті
Порівняння стратегій
| Стратегія | Коли | Пам’ять | Сортування |
|---|---|---|---|
| HashAggregate | Дані не відсортовані | work_mem |
Не потрібне |
| GroupAggregate | Дані відсортовані | Мінімум | Потрібне (або індекс) |
Обробка NULL
-- Всі NULL групуються РАЗОМ
SELECT status, COUNT(*)
FROM orders
GROUP BY status;
-- Результат:
-- status | count
-- ACTIVE | 100
-- PENDING | 50
-- NULL | 10 ← Всі NULL в одній групі!
Типові помилки
- Забули колонку у GROUP BY
-- ❌ ПОМИЛКА: name не у GROUP BY SELECT user_id, name, SUM(amount) FROM orders GROUP BY user_id; -- ✅ Або додайте name у GROUP BY SELECT user_id, name, SUM(amount) FROM orders GROUP BY user_id, name; -- ✅ Або використайте агрегат SELECT user_id, MAX(name), SUM(amount) FROM orders GROUP BY user_id; - Виняток: групування за PRIMARY KEY
-- ✅ Можна! PK функціонально визначає всі колонки SELECT u.id, u.name, u.email, COUNT(o.id) FROM users u LEFT JOIN orders o ON u.id = o.user_id GROUP BY u.id; -- name та email можна не вказувати - Фільтрація після GROUP BY
-- ❌ WHERE не можна використовувати з агрегатами SELECT user_id, COUNT(*) FROM orders WHERE COUNT(*) > 5 GROUP BY user_id; -- ✅ Використовуйте HAVING SELECT user_id, COUNT(*) FROM orders GROUP BY user_id HAVING COUNT(*) > 5;
Практичні приклади
-- Пошук дублікатів
SELECT email, COUNT(*) as cnt
FROM users
GROUP BY email
HAVING COUNT(*) > 1;
-- Агрегація з умовами (FILTER)
SELECT
department,
COUNT(*) FILTER (WHERE status = 'ACTIVE') as active_count,
COUNT(*) FILTER (WHERE status = 'INACTIVE') as inactive_count
FROM employees
GROUP BY department;
🔴 Senior Level
Коли НЕ використовувати GROUP BY
- Коли потрібні деталі кожного рядка — використовуйте віконні функції замість GROUP BY (вони не згортають рядки)
- Коли груп занадто багато (мільйони) — використовуйте пре-агрегацію через CTE або Materialized Views
- Коли потрібно лише перевірити існування — використовуйте EXISTS (Semi-Join), а не GROUP BY + HAVING COUNT(*) > 0
Фізичні стратегії: Deep Dive
HashAggregate у деталях:
Phase 1: Build
For each row:
hash_key = hash(group_by_columns)
if hash_key in hash_table:
update_aggregate(hash_table[hash_key], row)
else:
create_new_group(hash_key, row)
Phase 2: Output
For each group in hash_table:
emit(group)
Spill to Disk (PG 13+):
⚠️ ВАЖЛИВО для міграції: До PG 13 запит з HashAggregate, що перевищує work_mem, завершувався помилкою
out of memory. На PG 13+ той самий запит виконається, але мовчки піде на диск. При міграції з PG 12 на PG 13 запити, які раніше падали, почнуть працювати — але екстремально повільно. МонітортеDisk: X kBв EXPLAIN!
До PG 13: HashAggregate не влізає у work_mem → помилка
PG 13+: HashAggregate скидає частини хешу на диск
→ Обробляє batch за batch
→ Повільніше, ніж у пам'яті, але працює!
GroupAggregate + Index:
-- Якщо є індекс за колонкою групування
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Запит використовує Index Scan → дані вже відсортовані
SELECT user_id, SUM(amount) FROM orders GROUP BY user_id;
-- Plan: GroupAggregate → Index Scan (без Sort!)
-- → Мінімум пам'яті, максимум швидкості
Паралельна агрегація
-- PostgreSQL розбиває роботу на воркери
EXPLAIN SELECT department, COUNT(*) FROM employees GROUP BY department;
-- Plan:
-- Finalize GroupAggregate ← Головний процес об'єднує результати від воркерів
-- Gather ← Вузол збору даних від паралельних воркерів
-- Partial GroupAggregate ← Воркер 1: групує СВОЮ частину даних
-- Parallel Seq Scan ← Воркер 1 паралельно читає таблицю
-- Partial GroupAggregate ← Воркер 2: групує СВОЮ частину даних
-- Parallel Seq Scan ← Воркер 2 паралельно читає таблицю
-- Кожен воркер читає свою частину таблиці, групує її (Partial),
-- потім головний процес об'єднує проміжні результати (Finalize).
Як працює:
- Кожен воркер групує свою частину даних (Partial Aggregate)
- Головний процес об’єднує проміжні результати (Combine)
- Фіналізує агрегати (Finalize)
Обмеження:
- Не всі агрегатні функції підтримують паралелізм
COUNT,SUM,AVG— підтримують- Кастомні агрегати — потрібно перевірити
proparallelуpg_proc
ROLLUP, CUBE, GROUPING SETS
ROLLUP — ієрархічні підсумки:
SELECT department, city, COUNT(*)
FROM employees
GROUP BY ROLLUP(department, city);
-- Результат:
-- dept | city | count
-- IT | Moscow | 10
-- IT | SPb | 5
-- IT | NULL | 15 ← Підсумок по IT
-- NULL | NULL | 50 ← Підсумок по всіх
CUBE — всі комбінації:
SELECT department, city, COUNT(*)
FROM employees
GROUP BY CUBE(department, city);
-- Результат включає:
-- (dept, city), (dept, NULL), (NULL, city), (NULL, NULL)
-- 4 рівні деталізації за ОДИН прохід!
GROUPING SETS — явний список:
SELECT department, city, COUNT(*)
FROM employees
GROUP BY GROUPING SETS (
(department, city), -- Деталізація
(department), -- Підсумок по відділу
() -- Підсумок по всіх
);
Чому це краще UNION ALL:
-- ❌ 3 проходи по таблиці
SELECT department, city, COUNT(*) FROM employees GROUP BY department, city
UNION ALL
SELECT department, NULL, COUNT(*) FROM employees GROUP BY department
UNION ALL
SELECT NULL, NULL, COUNT(*) FROM employees;
-- ✅ 1 прохід (Hash або Sort)
SELECT department, city, COUNT(*) FROM employees
GROUP BY ROLLUP(department, city);
FILTER (Clean SQL)
-- Елегантна агрегація без CASE WHEN
SELECT
department,
COUNT(*) as total,
COUNT(*) FILTER (WHERE salary > 100000) as high_paid,
COUNT(*) FILTER (WHERE hire_date > '2023-01-01') as new_hires,
AVG(salary) FILTER (WHERE role = 'Senior') as senior_avg_salary
FROM employees
GROUP BY department;
Incremental Sort (PG 13+)
-- Індекс за (department, hire_date)
CREATE INDEX idx_emp_dept_date ON employees(department, hire_date);
-- Запит з GROUP BY та ORDER BY
SELECT department, hire_date, COUNT(*)
FROM employees
GROUP BY department, hire_date
ORDER BY department, hire_date;
-- PG 13+: Incremental Sort
-- Дані вже відсортовані за department (з індексу)
→ Сортуємо лише за hire_date всередині кожного department
→ У 5-10 разів швидше повного сортування!
На PG 12 і нижче — повне сортування всіх колонок ORDER BY, навіть якщо частина вже відсортована індексом.
Edge Cases
- Порожній результат GROUP BY
-- Якщо таблиця порожня → 0 рядків (не NULL!) SELECT status, COUNT(*) FROM empty_table GROUP BY status; -- Result: 0 rows - GROUP BY з виразами
-- Групування за виразом SELECT EXTRACT(YEAR FROM created_at) as year, EXTRACT(MONTH FROM created_at) as month, COUNT(*) FROM orders GROUP BY year, month ORDER BY year, month; - GROUPING() функція
-- Визначаємо, чи є рядок підсумковим SELECT department, city, COUNT(*), GROUPING(department) as is_dept_total, -- 1 якщо NULL з ROLLUP GROUPING(city) as is_city_total FROM employees GROUP BY ROLLUP(department, city);
Performance Tuning
work_mem для HashAggregate:
-- Перевірка поточного значення
SHOW work_mem; -- 4MB за замовчуванням
-- Якщо бачите spill на диск → збільште
SET work_mem = '256MB';
SELECT department, COUNT(*) FROM large_table GROUP BY department;
Індекс для GroupAggregate:
-- Створюємо індекс за колонками GROUP BY
CREATE INDEX idx_orders_status_date ON orders(status, created_at);
-- Запит без Sort
SELECT status, created_at, COUNT(*)
FROM orders
GROUP BY status, created_at;
-- Plan: GroupAggregate → Index Only Scan
Production Experience
Реальний сценарій #1: HashAggregate Spill
- Аналітика: 200 млн рядків, GROUP BY за 5 колонками
- work_mem = 4MB → spill 15 ГБ на диск
- Час: 8 хвилин
- Рішення:
SET work_mem = '512MB'→ все у пам’яті - Результат: 45 секунд (прискорення у 10 разів)
Реальний сценарій #2: CUBE замість UNION ALL
- Звіт: 8 UNION ALL для різних розрізів
- Час: 2 хвилини (8 проходів по таблиці)
- Рішення: GROUP BY CUBE(region, product, month)
- Результат: 15 секунд (1 прохід)
Monitoring
-- 1. Перевірка стратегії агрегації
EXPLAIN (ANALYZE, BUFFERS)
SELECT department, COUNT(*) FROM employees GROUP BY department;
-- HashAggregate vs GroupAggregate
-- 2. Перевірка spill
EXPLAIN (ANALYZE)
SELECT ... GROUP BY ...;
-- Шукайте: "Disk: X kB" → погано!
-- 3. Перевірка паралелізму
EXPLAIN SELECT ... GROUP BY ...;
-- Workers Planned: 4 → чудово
-- Workers Planned: 0 → перевірте max_parallel_workers_per_gather
-- 4. Пошук повільних GROUP BY
SELECT query, mean_exec_time
FROM pg_stat_statements
WHERE query LIKE '%GROUP BY%'
ORDER BY mean_exec_time DESC
LIMIT 10;
Best Practices
- Індекс за колонками GROUP BY → GroupAggregate без Sort
- work_mem контролюйте при HashAggregate
- FILTER замість
CASE WHENв агрегатах - ROLLUP/CUBE/GROUPING SETS замість UNION ALL
- PK у GROUP BY дозволяє не вказувати інші колонки таблиці
- Паралелізм перевірте через
EXPLAIN - Incremental Sort (PG 13+) використовуйте з індексами
Резюме для Senior
- 2 стратегії: HashAggregate (вимагає work_mem) vs GroupAggregate (вимагає Sort/індекс)
- PG 13+: HashAggregate вміє spill → не падає, але повільніше
- Паралельна агрегація: Partial → Gather → Finalize
- ROLLUP/CUBE/GROUPING SETS: один прохід замість UNION ALL
- FILTER: чистий SQL для умовної агрегації
- Incremental Sort (PG 13+): економія при partial сортуванні
- Індекс за GROUP BY → GroupAggregate без Sort → мінімум RAM
- Завжди перевіряйте план: Hash vs Group, Workers, Spill
🎯 Шпаргалка для інтерв’ю
Обов’язково знати:
- 2 стратегії: HashAggregate (хеш-таблиця у work_mem) vs GroupAggregate (відсортовані дані)
- HashAggregate: O(N), вимагає work_mem, PG 13+ вміє spill на диск
- GroupAggregate: O(N log N) для сортування, але мінімум RAM; з індексом → без Sort
- Паралельна агрегація: Partial (воркери) → Gather → Finalize (головний процес)
- ROLLUP: ієрархічні підсумки, CUBE: всі комбінації, GROUPING SETS: явний список
- ROLLUP/CUBE/GROUPING SETS = 1 прохід vs UNION ALL = N проходів
- FILTER:
COUNT(*) FILTER (WHERE status = 'ACTIVE')— краще CASE WHEN - Incremental Sort (PG 13+): дані частково відсортовані → сортуємо решту
- Індекс за колонками GROUP BY → GroupAggregate без Sort
- NULL групуються разом
Часті уточнюючі запитання:
- «Коли HashAggregate кращий за GroupAggregate?» → Дані не відсортовані + поміщаються у work_mem
- «Що буде, якщо HashAggregate не влізе у work_mem?» → PG 13+ spill на диск, до PG 13 — помилка
- «Чому ROLLUP кращий за UNION ALL?» → 1 прохід замість N
- «Як прискорити GROUP BY на 200 млн рядків?» → work_mem ↑, індекс за GROUP BY, паралелізм
Червоні прапорці (НЕ говорити):
- ❌ «GROUP BY завжди сортує» (HashAggregate не сортує)
- ❌ «spill на диск — це нормально» (у 10-100 разів повільніше!)
- ❌ «UNION ALL для різних розрізів — норма» (CUBE/GROUPING SETS швидше)
Пов’язані теми:
- [[В чому різниця між WHERE та HAVING]] → фільтрація до/після GROUP BY
- [[Коли потрібно використовувати HAVING]] → фільтрація за агрегатами
- [[Що таке віконні функції]] → альтернатива для агрегації без групування