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

Агрегатные функции: COUNT, SUM, FILTER и ловушки

COUNT(*) считает строки, COUNT(col) пропускает NULL, COUNT(DISTINCT) убирает дубли. FILTER лучше CASE для условных агрегатов, а целочисленный AVG и переполнение SUM — тихие баги.

SQL Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

«Средний чек — $0.» Дашборд тихо показывал ноль неделю. Причина: AVG(total), где total был колонкой integer, так что Postgres сделал целочисленное деление и отбросил каждый дробный цент. В ту же неделю SUM(amount) по высоконагруженной int-колонке перевалил за 2.1 миллиарда и выбросил integer out of range в 3 ночи. Агрегаты выглядят тривиально; баги, что они прячут, — нет.

Три лица COUNT

Выбор неверного варианта COUNT — самый частый баг агрегации в отчётном коде: число оказывается неверным без единой ошибки. Разберём, что именно делает каждая форма.

У COUNT три разных поведения, которые новички путают, — и разница меняет ответ:

  • COUNT(*) — считает строки, точка. NULL включены; значения колонок не проверяются.
  • COUNT(col) — считает строки, где col не NULL. Молча пропускает NULL.
  • COUNT(DISTINCT col) — считает уникальные не-NULL значения col.
SELECT
  COUNT(*)               AS total_orders,      -- каждая строка
  COUNT(discount_code)   AS with_discount,     -- строки, где код не NULL
  COUNT(DISTINCT user_id) AS distinct_buyers   -- уникальные пользователи
FROM orders;

Если существует 1000 заказов, но лишь у 200 есть discount_code, COUNT(*) равен 1000, а COUNT(discount_code) — 200. Потянуться за COUNT(какая_то_колонка), когда имел в виду «все строки», — баг отчётности из топ-5: один NULL в той колонке тихо занижает счёт.

FILTER: условные агрегаты без CASE

Часто нужно «число оплаченных заказов» и «число отменённых» в одной строке. Старый трюк — CASE внутри агрегата; современный, более ясный способ — предложение FILTER:

SELECT
  COUNT(*) FILTER (WHERE status = 'paid')      AS paid,
  COUNT(*) FILTER (WHERE status = 'cancelled') AS cancelled,
  SUM(total) FILTER (WHERE status = 'paid')    AS paid_revenue
FROM orders;

FILTER (WHERE …) ограничивает, какие строки видит один этот агрегат, независимо для каждого агрегата, не трогая WHERE запроса. Читается чище, чем SUM(CASE WHEN status='paid' THEN total END), и — что важно — COUNT(*) FILTER (WHERE …) считает совпавшие строки, тогда как COUNT(CASE WHEN … THEN 1 END) работает лишь потому, что несовпавшая ветка возвращает NULL (тонкость, что кусает писавших THEN 0, ведь это считает всё). FILTER — стандартный SQL и сеньорский дефолт для условной агрегации. (Полные сводные таблицы на нём ты построишь в уроке 05.)

Сторона производительности в том, что все три агрегата FILTER выше вычисляются за один проход по данным. Альтернатива, к которой тянутся джуны — три отдельных запроса (WHERE status='paid', затем WHERE status='cancelled', …), объединённых UNION или прогнанных последовательно, — сканирует таблицу трижды. На таблице orders в 12М строк это ~2.4с (3 × ~800мс Seq Scan) против ~850мс у однопроходной формы FILTER: движок читает каждую строку один раз и маршрутизирует её в те агрегаты, чьим предикатам она удовлетворяет. Отдельная ловушка — COUNT(DISTINCT col): в отличие от обычного COUNT, он не может быть потоковым счётчиком — ему нужно материализовать и дедуплицировать каждое значение, поэтому на колонке высокой кардинальности он строит sort или hash всех различных значений и первым в запросе спиллит на диск. COUNT(*) FILTER остаётся дешёвым счётчиком; COUNT(DISTINCT …) — дорогой родственник, за которым стоит следить в EXPLAIN ANALYZE.

Упорядоченные агрегаты: string_agg / array_agg

Некоторым агрегатам важен порядок. string_agg и array_agg конкатенируют значения группы, и порядок можно задать внутри агрегата:

SELECT user_id,
       string_agg(status, ',' ORDER BY created_at) AS status_timeline,
       array_agg(id ORDER BY created_at DESC)      AS recent_first
FROM orders
GROUP BY user_id;

Без ORDER BY внутри агрегата порядок конкатенации не определён — он зависит от плана и может меняться между запусками. Если порядок важен (таймлайн, CSV), всегда задавай его внутри скобок, а не во внешнем ORDER BY запроса (который сортирует лишь финальные строки, а не значения внутри каждого агрегата).

Числовые ловушки

Два бага агрегации тихие и дорогие:

Целочисленный AVG. AVG по колонке integer возвращает numeric в Postgres (хорошо), но SUM/деление, написанные руками по целым, усекают. Классика — SUM(total)/COUNT(*) по int-колонкам: это целочисленное деление, отбрасывающее дробь. Сначала приведи тип: SUM(total)::numeric / COUNT(*) или просто используй AVG(total).

Переполнение SUM. SUM по колонке integer возвращает bigint (безопасно до ~9.2×10^18), но SUM по колонке bigint возвращает numeric — и SUM по int-колонке с миллиардами крупных строк всё ещё может переполниться, если приводишь тип обратно. Боевой отказ: суммирование денежной колонки, хранимой как int-центы, по нагруженной таблице со временем спотыкается о integer out of range. Защищайся: храни деньги как numeric (или bigint-центы) и при сомнении приводи внутри SUM: SUM(amount::bigint).

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

Почему Postgres автоматически расширяет SUM(int) до bigint, но не защищает каждый случай? Потому что тип результата выбирается из входного типа, а не из величины во время выполнения — планировщик не знает число строк заранее. SUM(int)bigint покрывает почти всё, но SUM(bigint)numeric (произвольная точность, медленнее) — это страховка для по-настоящему огромных сумм. Урок: выбирай тип хранения под сумму, которую будешь считать, а не только под отдельное значение. Построчный int нормален; годовая SUM миллионов таких может требовать bigint или numeric.

Викторина

В orders 1000 строк; у 200 есть не-NULL discount_code. Что вернёт COUNT(discount_code)?

Викторина

Почему предпочесть COUNT(*) FILTER (WHERE status='paid') над COUNT(CASE WHEN status='paid' THEN 0 END)?

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

Заполни пропуск: чтобы string_agg или array_agg выдавали значения в гарантированном порядке, помести ORDER BY _______ скобок агрегата, а не во внешний ORDER BY запроса.

Вспомните перед уходом
  1. 01
    Различи COUNT(*), COUNT(col) и COUNT(DISTINCT col) на одном примере.
  2. 02
    Что делает FILTER (WHERE …) и почему он лучше CASE для условной агрегации?
  3. 03
    Назови две тихие числовые ловушки агрегации и как избежать каждой.
Итог

Агрегатные функции выглядят просто и прячут острые края. COUNT(*) считает строки; COUNT(col) молча пропускает NULL; COUNT(DISTINCT col) считает уникальные не-NULL значения — путать их — классический баг отчётности. Предложение FILTER (WHERE …) — современный, стандартный способ условной агрегации: оно независимо ограничивает строки, видимые каждым агрегатом, читается яснее CASE и обходит ловушку THEN 0. Когда важен порядок конкатенации, string_agg и array_agg берут свой ORDER BY внутри скобок — внешний ORDER BY не поможет. Наконец, две числовые ловушки: целочисленное деление усекает (приведи к numeric или используй AVG), а SUM по int может переполниться на высоконагруженных таблицах (храни деньги как numeric/bigint). Теперь, когда встретишь отчётное число, которое чуть-чуть неверно — занижено на константу или подозрительно равно нулю, — первый вопрос: какой вариант COUNT использован и не хранятся ли деньги в int-колонке. Следующий урок масштабирует агрегацию: GROUPING SETS, ROLLUP и CUBE считают много группировок — подытоги и общий итог — за один проход.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.