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

Модель GROUP BY: строки в корзины

GROUP BY сворачивает множество строк в одну строку на каждый уникальный ключ. Отсюда железное правило: каждое выражение в SELECT должно быть в GROUP BY либо обёрнуто в агрегат.

SQL Middle ◷ 14 min
Уровень
ОсновыJuniorMiddleSenior
Уже знаешь этот юнит? Пройди быструю проверку за минуту →

Джун выкатывает SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id. Локально возвращает аккуратные суммы по пользователям. На ревью даже не запустится: ERROR: column "orders.status" must appear in the GROUP BY clause or be used in an aggregate function. Это не придирка Postgres — база говорит, что запрос задал невозможный вопрос. Пойми, почему он невозможен, и больше никогда не будешь воевать с GROUP BY.

Группировка — это раскладка по корзинам

GROUP BY берёт строки, пережившие WHERE, и раскладывает их по корзинам по значению ключа группировки. Все строки с одинаковым ключом попадают в одну корзину. Затем — в этом вся суть — каждая корзина сворачивается ровно в одну выходную строку.

Представь пять заказов. Группируем по status:

SELECT status, COUNT(*) AS n, SUM(total) AS revenue
FROM orders
GROUP BY status;

Postgres делит строки на одну корзину на каждое уникальное status (paid, pending, cancelled, …) и выдаёт по одной строке на корзину. Результат — это уже не «заказы», а «статусы», по строке на каждый.

Железное правило, и почему оно не произвольно

Когда корзина держит три заказа, спроси: какой тот самый id у этой корзины? Его нет — их три. А тот самый created_at? Снова три. У корзины есть одно определённое status (это ключ), но нет единого значения ни для какой другой сырой колонки. Postgres упирается в противоречие: ты просишь одну выходную строку на корзину, но SELECT id требует значения, которого у корзины нет.

Вот правило, и оно прямо вытекает из свёртки:

Каждое выражение в списке SELECT должно быть либо перечислено в GROUP BY (тогда у него одно значение на корзину), либо обёрнуто в агрегат (который вычисляет одно значение из корзины).

COUNT(*), SUM(total), MAX(created_at), array_agg(id) — каждый агрегат берёт всю корзину и возвращает один скаляр. Всё, что не агрегировано, должно быть константой внутри корзины — а это ровно то, что гарантирует попадание в GROUP BY. Ошибка «must appear in GROUP BY or be used in an aggregate» — это отказ Postgres молча взять значение одной произвольной строки. Именно так делал старый дефолт MySQL — и десятилетие молча выдавал неверные отчёты.

-- НЕВЕРНО: status не сгруппирован и не агрегирован
SELECT user_id, status, SUM(total)
FROM orders
GROUP BY user_id;            -- ERROR: status должен быть в GROUP BY или агрегате

-- ВЕРНО, вариант A: сгруппировать и по нему (одна строка на пару user+status)
SELECT user_id, status, SUM(total)
FROM orders
GROUP BY user_id, status;

-- ВЕРНО, вариант B: убрать его агрегатом (одна строка на пользователя)
SELECT user_id, MAX(status) AS some_status, SUM(total)
FROM orders
GROUP BY user_id;

Исключение по функциональной зависимости

Есть одно место, где Postgres ослабляет правило, и это не лазейка — это корректность. Если ты делаешь GROUP BY по первичному ключу таблицы, каждая другая колонка этой таблицы функционально зависит от ключа: ключ однозначно определяет строку, поэтому каждая корзина держит ровно одну строку этой таблицы, и у каждой колонки есть единственное чётко определённое значение. Postgres знает это из каталога и разрешает выбирать такие зависимые колонки без группировки:

-- u.id — это PK таблицы users, поэтому u.name и u.email функционально
-- зависят от него — Postgres разрешает их без перечисления в GROUP BY:
SELECT u.id, u.name, u.email, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id;               -- не u.id, u.name, u.email — достаточно PK

Это часть стандарта SQL (правило функциональной зависимости из SQL:1999), и Postgres реализует его для первичных ключей и других not-null unique-ограничений. Оно избавляет от набивания GROUP BY дюжиной колонок только ради их вывода.

Как на самом деле выполняется свёртка

Бакетизация не бесплатна — и у планировщика два способа её сделать. На таблице orders в 12М строк GROUP BY user_id в ~2М корзин в EXPLAIN ANALYZE показывается как один из двух узлов. HashAggregate строит in-memory хеш-таблицу по ключу user_id, одна запись на корзину с накопленным состоянием агрегата — сортировка входа не нужна, но все различные ключи должны разом поместиться в память. GroupAggregate требует, чтобы вход был уже отсортирован по user_id (обычно через предшествующий Sort или index scan), затем стримит по одной корзине за раз, выдавая и выбрасывая каждую до следующей — почти постоянная память, но платит за сортировку. Планировщик выбирает HashAggregate, когда оценивает, что хеш-таблица влезет в work_mem, и переключается на sort-based GroupAggregate, когда нет.

Почему это работает

Конкретные формы из EXPLAIN (ANALYZE, BUFFERS) на той таблице в 12М строк. При work_mem = 4MB (по умолчанию) и ~2М различных групп user_id хеш-таблице нужно сильно больше 4MB, поэтому PG14+ показывает HashAggregate ... Disk Usage: 320000 kB (он сбрасывает хеш-партиции во временный файл) либо планировщик откатывается к Sort + GroupAggregate с Sort Method: external merge Disk: 285000 kB — и то и другое значит, что агрегация ушла на диск, а узел подскочил с ~900мс до ~4–6с. Поставь SET work_mem = '256MB' — и тот же запрос отчитывается HashAggregate ... Batches: 1 Memory Usage: 210000 kB без строки Disk, падая обратно до ~1.1с. Правило большого пальца: примерно различных_групп × ~50–100 байт состояния на группу должно влезть в work_mem, иначе спилл. Запрос с группировкой в 50 корзин не спиллит никогда; с группировкой в миллионы — почти всегда на дефолтных 4MB.

Граничные случаи

Зависимость должна быть такой, которую Postgres может доказать из ограничения. Группировка по u.id работает, потому что это объявленный первичный ключ. Группировка по колонке, которая просто случайно уникальна в твоих данных, но не имеет ограничения UNIQUE/PRIMARY KEY, всё равно будет отвергнута — планировщик не доверяет уникальности во время выполнения, только объявленным ограничениям. И зависимость — потаблично: группировка по users.id позволяет выбирать другие колонки users без группировки, но не сырые колонки orders, ведь один пользователь может владеть многими заказами.

Викторина

Почему SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id падает?

Викторина

SELECT u.id, u.name, u.email, COUNT(o.id) FROM users u LEFT JOIN orders o ON o.user_id=u.id GROUP BY u.id принимается. Почему u.name и u.email разрешены без их перечисления?

Закончи аналогию

Заполни пропуск: GROUP BY раскладывает строки по корзинам, затем сворачивает каждую корзину в одну выходную строку — поэтому выбираемая колонка должна быть либо ключом корзины, либо _______ по всей корзине.

Вспомните перед уходом
  1. 01
    Сформулируй правило списка SELECT для GROUP BY и объясни, почему оно существует.
  2. 02
    Что такое исключение по функциональной зависимости и когда оно действует?
  3. 03
    Дай два валидных способа починить SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id и скажи, чем отличаются результаты.
Итог

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

Практика

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

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

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

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

Примени это

Примени этот урок в реальном проекте.

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

Trademarks belong to their respective owners. Editorial reference only.