sqlpostgresqlaggregationperformance

COUNT(DISTINCT) в SQL: как считать уникальные значения и не перегрузить базу

Как работает COUNT(DISTINCT), почему точный подсчёт уникальных значений дорог на больших данных и когда уместны HLL-оценки.

12 мин чтенияСправочникsql · postgresql · aggregation · performance · analytics

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

Одно дело — спросить: «Сколько заказов было вчера?»
И совсем другое — спросить: «Сколько разных пользователей сделали хотя бы один заказ?»

Во втором случае базе мало просто пройти по строкам. Ей нужно заметить повторы, убрать их и посчитать только уникальные значения.

Такие вопросы встречаются постоянно:

  • сколько уникальных покупателей сделали заказ;
  • сколько разных стран встретилось среди пользователей;
  • сколько уникальных пар «пользователь — страна» было в отчёте;
  • сколько разных товаров купили в каждом регионе;
  • сколько уникальных клиентов пришло в каждый канал продаж.

Разберёмся спокойно: как работает COUNT(DISTINCT), почему он может быть дорогим, как считать уникальность по нескольким колонкам и когда вместо точного ответа можно использовать приближённый.

Чем COUNT отличается от COUNT(DISTINCT)

Обычный COUNT(*) считает строки. Каждая строка — плюс один к счётчику.

SELECT COUNT(*) AS total_orders
FROM orders;

Такой запрос отвечает на вопрос:

Сколько всего строк лежит в таблице orders?

Если один пользователь сделал пять заказов, COUNT(*) посчитает все пять строк.

А вот COUNT(DISTINCT user_id) отвечает на другой вопрос:

SELECT COUNT(DISTINCT user_id) AS unique_buyers
FROM orders;

Он считает не заказы, а разных пользователей, которые встречались в заказах.

Допустим, в таблице есть такие строки:

order_id user_id
1 10
2 10
3 25
4 25
5 31

COUNT(*) вернёт 5, потому что строк пять.

COUNT(DISTINCT user_id) вернёт 3, потому что уникальных пользователей три: 10, 25, 31.

Вот в этом и есть главная идея: COUNT(DISTINCT) сначала мысленно убирает повторы, а потом считает, сколько значений осталось.

Базовые примеры

Посчитать все заказы:

SELECT COUNT(*) AS total_orders
FROM orders;

Посчитать уникальных покупателей:

SELECT COUNT(DISTINCT user_id) AS unique_buyers
FROM orders;

Посчитать, из скольких разных стран зарегистрировались пользователи:

SELECT COUNT(DISTINCT country) AS countries_count
FROM users;

На практике COUNT(DISTINCT) часто используется именно в бизнес-метриках. Руководителю обычно важно не только «сколько было действий», но и «сколько разных людей эти действия совершили».

Например:

SELECT
  status,
  COUNT(*) AS orders_count,
  COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
GROUP BY status;

Такой запрос показывает сразу две разные картины:

  • сколько заказов было в каждом статусе;
  • сколько разных покупателей дошли до каждого статуса.

Это не одно и то же. Один активный клиент может сделать десять заказов и сильно раздуть обычный COUNT(*), но в COUNT(DISTINCT user_id) он всё равно будет учитываться как один покупатель.

Как COUNT(DISTINCT) работает с NULL

Важное правило: COUNT(DISTINCT col) не считает NULL.

SELECT COUNT(DISTINCT country) AS countries_count
FROM users;

Если в колонке country есть значения USA, Germany, Brazil и несколько NULL, то NULL не станет отдельной «неизвестной страной». Он просто будет проигнорирован.

Это похоже на поведение обычного COUNT(col): он тоже считает только строки, где значение в колонке не равно NULL.

Сравните:

SELECT
  COUNT(*) AS total_rows,
  COUNT(country) AS rows_with_country,
  COUNT(DISTINCT country) AS unique_countries
FROM users;

Здесь:

  • COUNT(*) считает все строки;
  • COUNT(country) считает строки, где country заполнен;
  • COUNT(DISTINCT country) считает разные заполненные значения country.

Для новичков это частая ловушка. Если в отчёте нужно отдельно показать пользователей без страны, это нужно делать явно, например через CASE.

SELECT
  COUNT(DISTINCT CASE WHEN country IS NULL THEN user_id END) AS users_without_country,
  COUNT(DISTINCT CASE WHEN country IS NOT NULL THEN user_id END) AS users_with_country
FROM users;

COUNT(DISTINCT) вместе с GROUP BY

COUNT(DISTINCT) отлично работает внутри групп.

Например, посчитаем уникальных покупателей по статусам заказов:

SELECT
  status,
  COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
GROUP BY status
ORDER BY status;

Здесь база делает не один общий подсчёт, а отдельный подсчёт внутри каждой группы.

То есть она отвечает на вопросы:

  • сколько разных пользователей было в статусе created;
  • сколько разных пользователей было в статусе paid;
  • сколько разных пользователей было в статусе cancelled;
  • сколько разных пользователей было в статусе delivered.

Это удобно, когда нужно сравнивать этапы воронки.

Но важно помнить: один и тот же пользователь может попасть сразу в несколько групп. Например, у него был один оплаченный заказ и один отменённый. Тогда он будет учтён и в paid, и в cancelled.

Это нормально, если вопрос звучит так:

Сколько уникальных пользователей встречалось в каждом статусе?

Но это уже не ответ на вопрос:

Сколько всего уникальных пользователей было во всех статусах вместе?

Для второго вопроса нужен отдельный общий запрос.

SELECT COUNT(DISTINCT user_id) AS unique_buyers
FROM orders;

Почему COUNT(DISTINCT) может быть дорогим

COUNT(*) базе считать относительно просто. Она идёт по строкам и увеличивает счётчик.

А COUNT(DISTINCT) должен помнить, какие значения уже встречались. Иначе он не сможет понять, это новое значение или повтор.

Представьте, что вы стоите на входе в большой концертный зал и считаете не количество входов, а количество разных людей. Один человек может выйти и зайти снова. Значит, вам нужен список уже увиденных людей. Чем больше людей, тем больше список.

У базы похожая задача. Ей нужно построить множество уникальных значений. Обычно для этого используется один из подходов:

  • сортировка значений и удаление соседних дублей;
  • хеш-таблица, куда складываются уже встреченные значения.

Оба способа требуют ресурсов.

Чем больше строк и чем больше уникальных значений, тем тяжелее запрос.

Особенно дорого становится, когда:

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

Например:

SELECT
  COUNT(DISTINCT o.user_id) AS buyers,
  COUNT(DISTINCT u.country) AS countries
FROM orders o
JOIN users u ON u.id = o.user_id;

Выглядит аккуратно, но внутри база может делать две отдельные дедупликации:

  • отдельно по user_id;
  • отдельно по country.

Если данных много, такой запрос может оказаться заметно тяжелее, чем кажется по его размеру.

Индекс не всегда спасает

Иногда кажется: «У меня же есть индекс по user_id, значит COUNT(DISTINCT user_id) должен работать мгновенно».

Не обязательно.

Индекс может помочь, особенно если запрос хорошо совпадает с его структурой. Но индекс не превращает любую задачу на уникальность в готовый ответ.

Например, запрос может содержать фильтр:

SELECT COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE created_at >= DATE '2026-01-01';

Если есть подходящий индекс по дате и пользователю, базе может быть легче. Но ей всё равно нужно понять, какие user_id уникальны среди выбранных строк.

Ещё сложнее становится после соединений:

SELECT COUNT(DISTINCT o.user_id) AS unique_buyers
FROM orders o
JOIN payments p ON p.order_id = o.id
WHERE p.status = 'success';

Здесь уникальность считается уже не просто по таблице orders, а после соединения с платежами и фильтрации успешных оплат.

Индекс помогает найти данные быстрее, но сам вопрос «сколько разных значений осталось после всех условий» всё равно требует работы.

DISTINCT по нескольким столбцам

Иногда уникальность определяется не одной колонкой, а сочетанием нескольких.

Например, нужно посчитать уникальные пары:

пользователь + страна

Пользователь 10 в стране Brazil и пользователь 10 в стране Germany — это две разные пары.

В PostgreSQL можно считать уникальные кортежи:

SELECT COUNT(DISTINCT (o.user_id, u.country)) AS unique_pairs
FROM orders o
JOIN users u ON u.id = o.user_id;

Здесь уникальным считается не отдельно user_id и не отдельно country, а именно пара значений.

Более явный вариант — сначала получить уникальные строки, а потом посчитать их:

SELECT COUNT(*) AS unique_pairs
FROM (
  SELECT DISTINCT
    o.user_id,
    u.country
  FROM orders o
  JOIN users u ON u.id = o.user_id
) t;

Этот вариант длиннее, зато его проще читать новичку:

  1. Внутренний запрос получает уникальные пары.
  2. Внешний запрос считает, сколько таких пар получилось.

Почему не стоит склеивать колонки строкой

Иногда можно встретить такой подход:

SELECT COUNT(DISTINCT user_id || '-' || country) AS unique_pairs
FROM users;

Лучше так не делать, если есть нормальная альтернатива.

Проблема в том, что склейка строк может дать неожиданные совпадения. Например, разные исходные пары могут превратиться в одинаковую строку из-за разделителей, пустых значений или приведения типов.

К тому же такой код хуже читается. База видит не пару колонок, а новую строку, которую ещё нужно собрать.

Лучше использовать кортеж или подзапрос:

SELECT COUNT(DISTINCT (user_id, country)) AS unique_pairs
FROM users;

Или так:

SELECT COUNT(*) AS unique_pairs
FROM (
  SELECT DISTINCT user_id, country
  FROM users
) t;

Скучнее, зато надёжнее. А в SQL надёжность почти всегда важнее красивого трюка.

Отличия между PostgreSQL и MySQL

В MySQL можно писать так:

SELECT COUNT(DISTINCT user_id, country) AS unique_pairs
FROM orders;

В PostgreSQL такая форма не используется. Для нескольких колонок в PostgreSQL применяют кортеж:

SELECT COUNT(DISTINCT (user_id, country)) AS unique_pairs
FROM orders;

Или универсальный вариант через подзапрос:

SELECT COUNT(*) AS unique_pairs
FROM (
  SELECT DISTINCT user_id, country
  FROM orders
) t;

Если вы пишете учебный SQL или хотите, чтобы запрос было проще перенести между диалектами, вариант с подзапросом часто оказывается самым понятным.

DISTINCT внутри других агрегатов

DISTINCT можно использовать не только с COUNT.

Например:

SELECT
  dept,
  SUM(DISTINCT salary) AS sum_unique_salaries
FROM employees
GROUP BY dept;

Но здесь нужно быть очень осторожным.

SUM(DISTINCT salary) не означает «посчитать зарплаты уникальных сотрудников». Он означает «взять разные значения зарплаты и сложить их».

Допустим, в отделе три сотрудника:

employee_id salary
1 1000
2 1000
3 1500

Обычный SUM(salary) даст 3500.

А SUM(DISTINCT salary) даст 2500, потому что значение 1000 встретилось два раза и будет учтено только один раз.

Это редко то, что нужно в зарплатных отчётах.

Ещё пример:

SELECT
  manager_id,
  string_agg(DISTINCT dept, ', ') AS departments
FROM employees
GROUP BY manager_id;

Здесь DISTINCT выглядит уместно: мы хотим получить список разных отделов для каждого менеджера, без повторов.

То же самое можно делать с массивами:

SELECT
  manager_id,
  array_agg(DISTINCT dept) AS departments
FROM employees
GROUP BY manager_id;

Главное правило простое:

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

Не добавляйте DISTINCT «на всякий случай». Он может не только замедлить запрос, но и изменить смысл результата.

Частая ошибка: DISTINCT не чинит неправильный JOIN

Иногда в отчёте появляются дубли после соединения таблиц. Новичок видит завышенные числа и пытается «прибить» проблему через COUNT(DISTINCT).

Например:

SELECT COUNT(DISTINCT o.id) AS orders_count
FROM orders o
JOIN order_items i ON i.order_id = o.id;

Такой запрос действительно посчитает уникальные заказы, даже если в каждом заказе несколько товаров.

Но важно понимать, почему появились дубли.

Если один заказ содержит пять позиций, после JOIN с order_items заказ превратится в пять строк. Это не ошибка базы, а нормальное поведение соединения «один ко многим».

Иногда COUNT(DISTINCT) здесь оправдан. Но иногда правильнее сначала агрегировать данные на нужном уровне.

Например, если нам нужны заказы, где сумма товаров больше 1000, можно сначала посчитать сумму по заказу:

WITH order_totals AS (
  SELECT
    order_id,
    SUM(price * quantity) AS total_amount
  FROM order_items
  GROUP BY order_id
)
SELECT COUNT(*) AS expensive_orders
FROM order_totals
WHERE total_amount > 1000;

Так запрос становится понятнее: сначала приводим данные к уровню одного заказа, потом считаем заказы.

Это важная привычка в аналитике: сначала определить зерно данных.

Зерно — это уровень, на котором одна строка описывает одну сущность:

  • одна строка = один заказ;
  • одна строка = один пользователь;
  • одна строка = один товар в заказе;
  • одна строка = один день пользователя;
  • одна строка = одно событие.

Если зерно выбрано неправильно, COUNT(DISTINCT) часто становится пластырем поверх более глубокой проблемы.

Как сделать COUNT(DISTINCT) легче

Универсальной волшебной кнопки нет, но есть несколько хороших практик.

Фильтруйте данные раньше

Не заставляйте базу дедуплицировать лишние строки.

Плохо, если сначала соединяются огромные таблицы, а потом отбрасывается почти всё.

Лучше как можно раньше оставить только нужный период, статус или тип события.

SELECT COUNT(DISTINCT user_id) AS unique_buyers
FROM orders
WHERE status = 'paid'
  AND created_at >= DATE '2026-01-01';

Чем меньше строк дойдёт до COUNT(DISTINCT), тем проще базе.

Считайте на правильном уровне

Если отчёт строится по событиям, а вам нужны пользователи за день, можно сначала получить уникальные пары user_id и event_date.

WITH daily_users AS (
  SELECT DISTINCT
    user_id,
    event_date
  FROM events
  WHERE event_name = 'purchase'
)
SELECT
  event_date,
  COUNT(*) AS daily_buyers
FROM daily_users
GROUP BY event_date
ORDER BY event_date;

Такой запрос явно показывает логику:

  1. Сначала оставляем по одной строке на пользователя в день.
  2. Потом считаем строки по дням.

Не смешивайте много тяжёлых DISTINCT без необходимости

Запрос с несколькими разными COUNT(DISTINCT) может быть очень тяжёлым:

SELECT
  COUNT(DISTINCT user_id) AS users_count,
  COUNT(DISTINCT session_id) AS sessions_count,
  COUNT(DISTINCT order_id) AS orders_count
FROM events
WHERE event_date >= DATE '2026-01-01';

Иногда это нормально. Но на больших данных стоит проверить план выполнения и подумать, нельзя ли часть метрик заранее подготовить в витрине.

Например, отдельно хранить дневные агрегаты:

CREATE TABLE daily_metrics AS
SELECT
  event_date,
  COUNT(DISTINCT user_id) AS users_count,
  COUNT(DISTINCT session_id) AS sessions_count,
  COUNT(DISTINCT order_id) AS orders_count
FROM events
GROUP BY event_date;

Тогда дашборд будет читать уже подготовленную таблицу, а не каждый раз проходить по сырым событиям.

Дедуплицируйте в ETL, если это повторяющаяся метрика

Если один и тот же тяжёлый COUNT(DISTINCT) нужен каждый день, возможно, его не стоит считать заново из сырых данных.

Например, можно заранее готовить таблицу уникальных пользователей по дням:

CREATE TABLE daily_active_users AS
SELECT DISTINCT
  event_date,
  user_id
FROM events
WHERE event_name IN ('login', 'purchase', 'page_view');

А потом отчёт будет проще:

SELECT
  event_date,
  COUNT(*) AS active_users
FROM daily_active_users
GROUP BY event_date
ORDER BY event_date;

Это не меняет смысл метрики, но сильно меняет объём работы.

База уже не ищет уникальных пользователей среди всех событий. Она считает строки в заранее очищенной таблице.

Приближённый COUNT(DISTINCT): когда точность не обязательна

На маленьких и средних данных обычно хочется точный ответ. Но на миллиардах событий точный COUNT(DISTINCT) может быть слишком дорогим.

Для некоторых задач точность до последнего пользователя не нужна. Например:

  • график активной аудитории;
  • мониторинг продуктовой воронки;
  • сравнение недель;
  • быстрый дашборд для команды;
  • оценка охвата;
  • грубый анализ тренда.

Если вчера было примерно 1 000 000 активных пользователей, а сегодня примерно 1 080 000, продуктовой команде часто важнее быстро увидеть рост, чем знать точное число до единицы.

Для таких задач используют приближённые алгоритмы. Самый известный подход — HyperLogLog.

Идея простая: вместо хранения всех уникальных значений алгоритм хранит компактный «скетч» — маленькую структуру, по которой можно оценить количество уникальных элементов с небольшой погрешностью.

Плюсы:

  • работает быстро;
  • использует ограниченную память;
  • хорошо подходит для огромных потоков событий;
  • скетчи можно объединять.

Минус очевидный:

  • результат приблизительный, а не точный.

Примеры APPROX и HLL в разных базах

В ClickHouse есть несколько функций для подсчёта уникальных значений.

Приближённый вариант:

SELECT uniq(user_id) AS approx_buyers
FROM orders;

Ещё один приближённый вариант на HyperLogLog:

SELECT uniqHLL12(user_id) AS approx_buyers
FROM orders;

Точный вариант:

SELECT uniqExact(user_id) AS exact_buyers
FROM orders;

В BigQuery и Snowflake используется функция такого вида:

SELECT APPROX_COUNT_DISTINCT(user_id) AS approx_buyers
FROM orders;

В PostgreSQL в стандартном ядре нет встроенного аналога APPROX_COUNT_DISTINCT, но есть расширения, например postgresql-hll.

Главная сила HLL — скетчи можно объединять.

Например, можно хранить дневные скетчи уникальных пользователей, а потом быстро получать оценку за неделю или месяц без повторного прохода по всем сырым событиям.

Это особенно полезно для событийных таблиц, где данных очень много, а отчёты строятся постоянно.

Где нужен точный результат, а где можно оценку

Очень важно не смешивать точные и приближённые сценарии.

Точный COUNT(DISTINCT) нужен там, где ошибка недопустима:

  • биллинг;
  • деньги;
  • лимиты;
  • юридическая отчётность;
  • расчёт бонусов;
  • дедупликация реальных сущностей;
  • выгрузки, по которым принимаются формальные решения.

Приближённый подсчёт может быть нормальным выбором там, где важен тренд:

  • активная аудитория;
  • продуктовые дашборды;
  • мониторинг воронки;
  • сравнение недель и месяцев;
  • оценка охвата;
  • быстрые аналитические срезы.

Самая опасная ошибка — молча заменить точный подсчёт приближённым и не подписать это в отчёте.

Если метрика оценочная, это должно быть видно: например, approx_unique_users, а не просто unique_users.

Как думать перед написанием COUNT(DISTINCT)

Перед тем как писать COUNT(DISTINCT), полезно задать себе несколько вопросов.

Первый вопрос:

Что именно я считаю уникальным?

Пользователей? Заказы? Сессии? Пары пользователь-день? Пары пользователь-страна?

Второй вопрос:

На каком зерне лежат мои данные?

Если таблица событийная, один пользователь может иметь тысячи строк. Если таблица заказов, один пользователь может иметь десятки строк. Если таблица пользователей, один пользователь обычно представлен одной строкой.

Третий вопрос:

Нужна точность до единицы или достаточно оценки?

Для денег и юридических отчётов — точность. Для графика тренда — иногда достаточно приближённого результата.

Четвёртый вопрос:

Можно ли заранее уменьшить данные?

Иногда лучше сначала подготовить витрину: например, одну строку на пользователя в день. Тогда финальный отчёт будет проще, быстрее и понятнее.

Главное

COUNT(DISTINCT) — это не просто «чуть более умный COUNT». Это операция дедупликации.

Обычный COUNT(*) считает строки. COUNT(DISTINCT col) считает разные значения и игнорирует NULL.

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

На больших данных COUNT(DISTINCT) может быть дорогим, потому что базе нужно помнить уже встреченные значения. Для этого используются сортировки, хеш-таблицы и иногда временные файлы на диске.

Для уникальности по нескольким колонкам в PostgreSQL используйте кортеж:

SELECT COUNT(DISTINCT (user_id, country)) AS unique_pairs
FROM orders;

Или более явный вариант через подзапрос:

SELECT COUNT(*) AS unique_pairs
FROM (
  SELECT DISTINCT user_id, country
  FROM orders
) t;

Не склеивайте колонки строкой без необходимости. Это может дать ошибки и ухудшить читаемость.

DISTINCT внутри других агрегатов тоже требует осторожности. SUM(DISTINCT salary) складывает разные значения зарплат, а не зарплаты разных сотрудников.

На огромных данных точный подсчёт уникальных значений может быть слишком дорогим. Для продуктовых дашбордов и трендов иногда используют приближённые функции вроде uniq() или APPROX_COUNT_DISTINCT, но такую метрику нужно честно подписывать как оценочную.

Хороший аналитик не просто пишет COUNT(DISTINCT). Он сначала понимает, какую сущность считает, на каком зерне лежат данные и нужна ли точность до последней строки.

Закрепи на практике

Решай задачи в SQL-тренажёре с мгновенной проверкой и подсказками.

Открыть тренажёр