PostgreSQL
Агрегация: GROUP BY, HAVING, GROUPING SETS
Команда аналитики получила задачу: отчёт по продажам в разрезе год/месяц/категория/регион - с промежуточными итогами. Написали 4 отдельных запроса через UNION ALL: 45 секунд на 200 млн строк. Переписали на GROUPING SETS - 2 секунды. Один проход вместо четырёх.
- **BI-отчёты (Tableau, Metabase)** - автоматически генерируют ROLLUP/CUBE для drill-down отчётов по иерархиям
- **Финансовая аналитика** - GROUPING SETS для P&L по продуктам, регионам, кварталам за один запрос
- **FILTER в дашбордах** - conversion rate = `COUNT(*) FILTER (WHERE converted) / COUNT(*)::float` без подзапросов
Агрегатные функции: операции над группами строк
Агрегатная функция принимает набор строк и возвращает одно значение. В отличие от скалярных функций, они работают не построчно, а над группой. PostgreSQL выполняет агрегацию в два этапа: сначала группирует строки, затем применяет функцию к каждой группе.
**COUNT(*) vs COUNT(col):** `COUNT(*)` считает все строки включая NULL. `COUNT(col)` считает только строки, где `col IS NOT NULL`. `COUNT(DISTINCT col)` - уникальные непустые значения. Частая ошибка: `COUNT(col)` даёт меньше `COUNT(*)` при наличии NULL.
Таблица `orders` содержит 100 строк, из которых у 10 поле `amount IS NULL`. Что вернёт `COUNT(amount)`?
GROUP BY и HAVING: группировка и фильтр после агрегации
`GROUP BY` разбивает строки на группы по значениям указанных столбцов - по одной результирующей строке на группу. `HAVING` фильтрует уже сформированные группы (после агрегации), тогда как `WHERE` фильтрует строки до группировки. Порядок выполнения: FROM → WHERE → GROUP BY → HAVING → SELECT → ORDER BY.
**Частая ошибка:** использовать агрегатную функцию в WHERE. Запрос `WHERE SUM(amount) > 1000` вызовет ошибку - в момент WHERE агрегации ещё нет. Фильтр по агрегату всегда идёт в HAVING.
Нужно выбрать пользователей с более чем 10 заказами. Что правильно?
ROLLUP и CUBE: автоматические промежуточные итоги
`ROLLUP` генерирует иерархические промежуточные итоги. Для `GROUP BY ROLLUP(year, month, day)` PostgreSQL вычислит: итоги по (year, month, day), затем (year, month), затем (year), затем общий итог - двигаясь вверх по иерархии.
**NULL как маркер агрегации:** в строках с промежуточными итогами NULL означает «агрегировано по этому измерению». Для различия между NULL-значением данных и NULL-маркером ROLLUP/CUBE используйте функцию `GROUPING(col)` - она возвращает 1 для агрегированных строк.
`GROUP BY ROLLUP(year, month)` генерирует строки для группировок:
GROUPING SETS: точный контроль над группировками
`GROUPING SETS` позволяет явно указать, какие комбинации измерений нужно вычислить. Это эффективнее цепочки UNION ALL: PostgreSQL делает один проход по данным вместо нескольких, что особенно критично для больших таблиц.
**ROLLUP и CUBE - синтаксический сахар над GROUPING SETS:** `ROLLUP(A, B)` = `GROUPING SETS((A,B), (A), ())`. `CUBE(A, B)` = `GROUPING SETS((A,B), (A), (B), ())`. GROUPING SETS нужен, когда нужна произвольная комбинация - не иерархия и не все вариации.
Почему GROUPING SETS эффективнее эквивалентного UNION ALL?
FILTER: условная агрегация за один проход
До PostgreSQL 9.4 для условной агрегации использовали `SUM(CASE WHEN status='paid' THEN amount ELSE 0 END)`. Синтаксис `FILTER (WHERE ...)` делает то же самое, но читается в разы чище и работает с любой агрегатной функцией.
**FILTER vs CASE WHEN:** оба дают одинаковый результат и одинаковый execution plan в большинстве случаев. `FILTER` предпочтителен по читаемости. Оба работают за один скан таблицы - это не несколько подзапросов.
Каждый FILTER в SELECT вызывает дополнительный скан таблицы.
Все FILTER в одном SELECT выполняются за один проход по данным - PostgreSQL проверяет условие каждого FILTER для каждой строки при единственном сканировании.
FILTER - это часть агрегатной функции, а не отдельный WHERE. PostgreSQL обрабатывает строки одну за другой, применяя к каждой все агрегаты с их условиями.
Чем `COUNT(*) FILTER (WHERE active = true)` отличается от `COUNT(*) WHERE active = true`?
Агрегация в PostgreSQL
- **Агрегатные функции** (COUNT, SUM, AVG, STRING_AGG, ARRAY_AGG) работают над группами строк; COUNT(*) vs COUNT(col) - разница в обработке NULL
- **GROUP BY + HAVING:** GROUP BY группирует, HAVING фильтрует группы после агрегации; WHERE - до GROUP BY
- **ROLLUP (иерархия) и CUBE (все комбинации)** - синтаксический сахар над GROUPING SETS для промежуточных итогов
- **GROUPING SETS** - явные комбинации за один скан таблицы; эффективнее UNION ALL в N раз
- **FILTER (WHERE ...)** - условная агрегация для нескольких метрик с разными условиями в одном SELECT
Связанные темы
Агрегация тесно связана с оконными функциями и оптимизацией запросов.
- Оконные функции — Window functions - агрегация без GROUP BY, с доступом к каждой строке
- Индексы и планировщик — Partial indexes ускоряют агрегацию с условиями, похожими на FILTER
Вопросы для размышления
- В каких случаях CUBE генерирует избыточные группировки, и как GROUPING SETS помогает избежать лишних вычислений?
- Как функция GROUPING() помогает отличить строку с NULL-значением от строки промежуточного итога в результатах ROLLUP?
- Почему FILTER (WHERE ...) при агрегации предпочтительнее CASE WHEN ... THEN ... ELSE 0 END с точки зрения читаемости и поддержки?
Связанные уроки
- pg-06-select — Aggregation layers GROUP BY on top of basic SELECT; WHERE/HAVING distinction needs SELECT context
- pg-11-window — Window functions are aggregate functions without GROUP BY collapsing rows; aggregation is the prerequisite
- pg-09-subqueries — HAVING with subqueries is a common pattern that combines aggregation with correlated subqueries
- pg-07-joins — Most real aggregations join multiple tables before grouping; join knowledge is practically required
- db-06-sql-advanced