Раздел 34 · MySQL с нуля

Агрегаты, GROUP BY и HAVING

junior~30 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

В прошлом уроке мы собирали строки из нескольких таблиц. Ответы получались длинными: по строке на билет, по строке на сеанс. Директору кинотеатра такая простыня не нужна. Он спрашивает коротко: сколько билетов продано, сколько денег принёс сеанс, какой режиссёр чаще всех в афише. Сегодня учимся сворачивать много строк в одно число.

От стопки билетов к итогу

Вечером кассир кладёт перед собой стопку корешков. Можно посчитать, сколько их всего. Можно разложить стопку на кучки по сеансам и пересчитать каждую. Можно отложить кучки, где меньше двух корешков. Три действия, и у каждого есть своё слово в SQL: агрегатная функция, GROUP BY и HAVING.

COUNT: три способа считать

COUNT считает строки, но смотря как его спросить:

SELECT COUNT(*), COUNT(viewer_id), COUNT(DISTINCT viewer_id) FROM tickets;
+----------+------------------+---------------------------+
| COUNT(*) | COUNT(viewer_id) | COUNT(DISTINCT viewer_id) |
+----------+------------------+---------------------------+
|       11 |                9 |                         5 |
+----------+------------------+---------------------------+
1 row in set (0.00 sec)

Три числа, три вопроса:

  • COUNT(*) считает строки целиком: билетов одиннадцать;
  • COUNT(viewer_id) считает строки, где в колонке не NULL: два кассовых билета без зрителя выпали;
  • COUNT(DISTINCT viewer_id) считает разные значения: билеты покупали пять разных зрителей.

Если нужно «сколько билетов», пиши COUNT(*). Если «сколько покупателей», то COUNT(DISTINCT viewer_id). Перепутать легко, а ошибка тихая: запрос отработает и вернёт правдоподобное число. Подробнее варианты разобраны в уроке для собеседования.

SUM, AVG, MIN и MAX

Остальные агрегаты работают с числами в колонке:

SELECT MIN(price), MAX(price), AVG(price), SUM(price) FROM screenings;
+------------+------------+------------+------------+
| MIN(price) | MAX(price) | AVG(price) | SUM(price) |
+------------+------------+------------+------------+
|     150.00 |     900.00 | 400.000000 |    2800.00 |
+------------+------------+------------+------------+
1 row in set (0.00 sec)

Посмотри на среднее: 400.000000, шесть знаков после запятой, хотя у price тип DECIMAL(7, 2). MySQL добавляет к точности среднего четыре знака. Для целой колонки получится четыре знака:

SELECT MIN(duration_min), MAX(duration_min), AVG(duration_min), SUM(duration_min) FROM films;
+-------------------+-------------------+-------------------+-------------------+
| MIN(duration_min) | MAX(duration_min) | AVG(duration_min) | SUM(duration_min) |
+-------------------+-------------------+-------------------+-------------------+
|                10 |               184 |          122.8571 |               860 |
+-------------------+-------------------+-------------------+-------------------+
1 row in set (0.00 sec)

Хвост убирают через ROUND(AVG(duration_min), 1).

Все агрегаты, кроме COUNT(*), пропускают NULL. И ещё одна мелочь, которая ломает отчёты: сумма по пустому набору строк это не ноль, а NULL.

SELECT SUM(price) FROM screenings WHERE id > 100;
+------------+
| SUM(price) |
+------------+
|       NULL |
+------------+
1 row in set (0.00 sec)

COUNT(*) на тех же строках вернёт 0. Если в отчёте нужен ноль, оберни сумму в COALESCE(SUM(price), 0).

GROUP BY: раскладываем по кучкам

GROUP BY делит строки на группы с одинаковым значением в колонке, и агрегат считается отдельно по каждой. Билеты по статусам:

SELECT status, COUNT(*) FROM tickets GROUP BY status ORDER BY COUNT(*) DESC;
+----------+----------+
| status   | COUNT(*) |
+----------+----------+
| paid     |        7 |
| used     |        3 |
| refunded |        1 |
+----------+----------+
3 rows in set (0.00 sec)

Одна строка ответа на одну группу. Сервер прошёл по одиннадцати билетам, разложил их на три кучки по статусу и в каждой посчитал корешки. Колонка status в SELECT разрешена, потому что внутри кучки она у всех одинаковая. Это правило понадобится чуть ниже.

NULL тоже образует группу, все NULL попадают в одну:

SELECT director, COUNT(*) AS films_count FROM films GROUP BY director ORDER BY films_count DESC, director;
+----------------------+-------------+
| director             | films_count |
+----------------------+-------------+
| Тарковский           |           2 |
| NULL                 |           1 |
| Балабанов            |           1 |
| Меньшов              |           1 |
| Норштейн             |           1 |
| Рязанов              |           1 |
+----------------------+-------------+
6 rows in set (0.00 sec)

Группа по колонке ENUM сортируется не по алфавиту, а по порядку значений в объявлении типа, как в уроке про строковые типы. Число фильмов и средняя длительность по возрастному рейтингу:

SELECT age_rating, COUNT(*), ROUND(AVG(duration_min), 1) FROM films GROUP BY age_rating ORDER BY age_rating;
+------------+----------+-----------------------------+
| age_rating | COUNT(*) | ROUND(AVG(duration_min), 1) |
+------------+----------+-----------------------------+
| 0+         |        1 |                        10.0 |
| 6+         |        1 |                       184.0 |
| 12+        |        3 |                       160.0 |
| 16+        |        1 |                        96.0 |
| 18+        |        1 |                        90.0 |
+------------+----------+-----------------------------+
5 rows in set (0.00 sec)

12+ стоит после 6+, хотя при сравнении строк «1» меньше «6». Рейтинги идут от детских к взрослым, ровно как их перечислили в CREATE TABLE. А ROUND(..., 1) срезал лишние знаки среднего до одного.

Группировать можно и по вычисленному значению. Зрители по году регистрации:

SELECT YEAR(registered_at) AS y, COUNT(*) FROM viewers GROUP BY y ORDER BY y;
+------+----------+
| y    | COUNT(*) |
+------+----------+
| 2025 |        1 |
| 2026 |        5 |
+------+----------+
2 rows in set (0.00 sec)

ONLY_FULL_GROUP_BY: что можно писать в SELECT

Захотим вывести рядом с режиссёром название фильма:

SELECT director, title, COUNT(*) FROM films GROUP BY director;
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated column 'kino.films.title' which is not functionally dependent on columns in GROUP BY clause; this is incompatible with sql_mode=only_full_group_by

Ошибка 1055 по делу. У Тарковского два фильма, а строка в группе одна. Какое название в неё поставить, «Сталкер» или «Солярис»? Сервер не угадывает. Режим ONLY_FULL_GROUP_BY включён в MySQL 8.4 по умолчанию и требует, чтобы каждая колонка в SELECT была либо в GROUP BY, либо внутри агрегата. Старые версии MySQL без этого режима подставляли любое значение из группы, и из-за этого в старых статьях встречаются запросы, которые сейчас падают.

Родственная ошибка 1140 бывает без GROUP BY вовсе:

SELECT title, COUNT(*) FROM films;
ERROR 1140 (42000): In aggregated query without GROUP BY, expression #1 of SELECT list contains nonaggregated column 'kino.films.title'; this is incompatible with sql_mode=only_full_group_by

COUNT(*) сворачивает всю таблицу в одну строку, и для title в ней снова нет однозначного значения.

Есть важное исключение. Если группировать по первичному ключу, остальные колонки той же таблицы писать можно: у одного id ровно одно название, и сервер это знает. Это называется функциональной зависимостью. Число сеансов у каждого фильма:

SELECT f.id, f.title, COUNT(s.id) AS shows
FROM films f
LEFT JOIN screenings s ON s.film_id = f.id
GROUP BY f.id
ORDER BY f.id;
+----+-------------------------------------------+-------+
| id | title                                     | shows |
+----+-------------------------------------------+-------+
|  1 | Брат                                      |     2 |
|  2 | Москва слезам не верит                    |     1 |
|  3 | Ирония судьбы                             |     1 |
|  4 | Сталкер                                   |     1 |
|  5 | Ёжик в тумане                             |     1 |
|  6 | Новые имена                               |     1 |
|  7 | Солярис                                   |     0 |
+----+-------------------------------------------+-------+
7 rows in set (0.00 sec)

Здесь пригодилась разница из первого раздела. «Солярис» пришёл через LEFT JOIN с NULL в s.id, и COUNT(s.id) дал честный ноль. COUNT(*) посчитал бы эту строку и выдал единицу.

Выручка по сеансам

Соберём всё вместе: для каждого сеанса цену, число непогашенных билетов и выручку. Возвраты отсекаем в ON, как в прошлом уроке, чтобы сеанс без билетов не потерялся:

SELECT s.id, s.price, COUNT(t.id) AS sold, s.price * COUNT(t.id) AS revenue
FROM screenings s
LEFT JOIN tickets t ON t.screening_id = s.id AND t.status <> 'refunded'
GROUP BY s.id
ORDER BY revenue DESC, s.id;
+----+--------+------+---------+
| id | price  | sold | revenue |
+----+--------+------+---------+
|  4 | 900.00 |    2 | 1800.00 |
|  1 | 450.00 |    3 | 1350.00 |
|  3 | 400.00 |    2 |  800.00 |
|  2 | 350.00 |    1 |  350.00 |
|  5 | 300.00 |    1 |  300.00 |
|  6 | 150.00 |    1 |  150.00 |
|  7 | 250.00 |    0 |    0.00 |
+----+--------+------+---------+
7 rows in set (0.00 sec)

s.price в SELECT разрешён без агрегата по тому же правилу первичного ключа: группа это один сеанс, у него одна цена. Сеанс 7 остался в отчёте с нулём.

HAVING: фильтр после подсчёта

Нужны сеансы, где продано два билета и больше. Первая мысль: написать условие в WHERE.

SELECT screening_id, COUNT(*) FROM tickets WHERE COUNT(*) > 1 GROUP BY screening_id;
ERROR 1111 (HY000): Invalid use of group function

Ошибка 1111. WHERE отбирает отдельные строки до того, как их разложили по кучкам, и никакого COUNT в этот момент ещё нет. Условие на итог группы пишут в HAVING:

SELECT screening_id, COUNT(*) AS sold FROM tickets GROUP BY screening_id HAVING COUNT(*) >= 2 ORDER BY screening_id;
+--------------+------+
| screening_id | sold |
+--------------+------+
|            1 |    3 |
|            3 |    3 |
|            4 |    2 |
+--------------+------+
3 rows in set (0.00 sec)

WHERE и HAVING часто работают вместе. Сначала WHERE оставляет только оплаченные билеты, потом HAVING оставляет крупные группы. В MySQL в HAVING можно сослаться на псевдоним колонки:

SELECT screening_id, COUNT(*) AS sold FROM tickets WHERE status = 'paid' GROUP BY screening_id HAVING sold >= 2 ORDER BY screening_id;
+--------------+------+
| screening_id | sold |
+--------------+------+
|            3 |    2 |
|            4 |    2 |
+--------------+------+
2 rows in set (0.00 sec)

Сеанс 1 выпал: его билеты уже погашены, статус used. Порядок работы запроса такой: FROM и JOIN, затем WHERE, GROUP BY, HAVING, SELECT, ORDER BY, LIMIT. Он подробно разобран в уроке про WHERE и HAVING.

GROUP_CONCAT: склеить группу в строку

Иногда из группы нужно не число, а список. GROUP_CONCAT склеивает значения через разделитель. Места каждого зрителя одной строкой:

SELECT viewer_id, GROUP_CONCAT(seat ORDER BY seat SEPARATOR ', ') AS seats
FROM tickets
WHERE viewer_id IS NOT NULL
GROUP BY viewer_id
ORDER BY viewer_id;
+-----------+--------+
| viewer_id | seats  |
+-----------+--------+
|         1 | A1, C3 |
|         2 | A2, B1 |
|         3 | A1, A2 |
|         4 | V2     |
|         5 | D4, V1 |
+-----------+--------+
5 rows in set (0.00 sec)

ORDER BY внутри скобок задаёт порядок в списке, SEPARATOR задаёт разделитель, по умолчанию это запятая без пробела. В PostgreSQL то же самое делает string_agg.

Что запомнить

  • COUNT(*) считает строки, COUNT(колонка) пропускает NULL, COUNT(DISTINCT ...) считает разные значения.
  • AVG от целых даёт DECIMAL с четырьмя знаками после запятой.
  • GROUP BY раскладывает строки по группам, а в SELECT остаются только колонки группировки и агрегаты.
  • WHERE отбирает строки до подсчёта, HAVING отбирает группы после.
  • GROUP_CONCAT склеивает значения группы в одну строку.

Попробуй сам

В песочнице база kino. Первый запрос считает продажи и выручку по фильмам.

Здесь SUM(s.price) честно считает выручку: каждая строка это один билет со своей ценой сеанса. Найди, каких фильмов нет в ответе, и объясни почему. Добавь HAVING sold >= 2. Почини второй запрос двумя способами: убери title или оберни его в GROUP_CONCAT.

Проверь себя

Задание

Дальше

Иногда для ответа нужен результат другого запроса: фильмы длиннее среднего, зрители, которые ничего не купили. Для этого запрос вкладывают в запрос. Следующий урок про подзапросы и UNION.

домашка

Домашка