Модель GROUP BY: строки в корзины
GROUP BY сворачивает множество строк в одну строку на каждый уникальный ключ. Отсюда железное правило: каждое выражение в SELECT должно быть в GROUP BY либо обёрнуто в агрегат.
Джун выкатывает 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 раскладывает строки по корзинам, затем сворачивает каждую корзину в одну выходную строку — поэтому выбираемая колонка должна быть либо ключом корзины, либо _______ по всей корзине.
- 01Сформулируй правило списка SELECT для GROUP BY и объясни, почему оно существует.
- 02Что такое исключение по функциональной зависимости и когда оно действует?
- 03Дай два валидных способа починить SELECT user_id, status, SUM(total) FROM orders GROUP BY user_id и скажи, чем отличаются результаты.
GROUP BY — это операция раскладки по корзинам: строки, пережившие WHERE, делятся по ключу группировки, и каждая корзина сворачивается ровно в одну выходную строку. Эта свёртка — источник железного правила: каждое выражение в SELECT должно быть в GROUP BY (константа на корзину) или внутри агрегата (одно значение, посчитанное из корзины), потому что у сырой колонки нет единого значения по многострочной корзине. Postgres это требует, а не молча возвращает произвольную строку, — и эта дисциплина держит отчёты корректными. Единственное послабление — исключение по функциональной зависимости: сгруппируй по первичному ключу, и можешь выбирать другие колонки той таблицы без группировки, ведь ключ их определяет. Дальше посмотрим, когда происходит фильтрация вокруг этой свёртки — WHERE до группировки против HAVING после, — что решает и корректность, и производительность.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.