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

DISTINCT, DISTINCT ON и операции над множествами

DISTINCT и UNION дедуплицируют, сортируя или хешируя весь результат — реальная цена. UNION ALL это пропускает. А DISTINCT ON — идиоматичный в Postgres «последняя строка на группу».

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

Эндпоинт отчётности сшивал «активных пользователей за неделю» из двух таблиц через UNION. В staging было нормально. В продакшене он бежал 11 секунд и упирал CPU, потому что UNION молча сортировал 9 миллионов объединённых строк, чтобы убрать дубликаты, которых не могло быть — два источника были уже непересекающимися. Замена одного ключевого слова на UNION ALL уронила это до 600 мс. Урок: дедупликация никогда не бесплатна, и чаще всего ты платишь за неё, не нуждаясь в ней.

DISTINCT убирает дублирующиеся строки — сортировкой или хешем

SELECT DISTINCT схлопывает идентичные выходные строки в одну. «Идентичные» значит, что все выбранные колонки совпадают. Механизм важен: чтобы найти дубликаты, Postgres должен либо отсортировать результат (тогда соседние равные строки очевидны), либо построить хеш каждой строки. Оба — это O(n) работа по всему набору результатов плюс память; если набор больше work_mem, сортировка или хеш сбрасываются на диск (spill), и отсюда берутся обрывы латентности.

-- одна строка на каждую различную пару (country, plan) в users
SELECT DISTINCT country, plan FROM users;

Так что DISTINCT — не бесплатная «уборка», это сортировка-или-хеш по всему, что ты выбрал. Если ты тянешься к нему, чтобы замаскировать дублирующиеся строки, порождённые пропущенным условием join, чини join вместо этого; DISTINCT прячет баг и добавляет цену.

DISTINCT ON: «последняя строка на группу» в Postgres

Специфичное для Postgres расширение решает вопрос, с которым обычный SQL справляется неуклюже: «дай мне по одной целой строке на группу — конкретно последнюю». DISTINCT ON (expr) оставляет первую строку для каждого различного значения expr, а «первая» решается через ORDER BY. Правило, на котором все спотыкаются: ORDER BY должен начинаться с того же выражения(й), что и DISTINCT ON, а затем добавлять тайбрейкер, выбирающий строку-победителя.

-- последний заказ на пользователя: по одной строке, самая свежая по created_at
SELECT DISTINCT ON (user_id) user_id, id, total, created_at
FROM orders
ORDER BY user_id, created_at DESC;   -- user_id первым (обязательно), затем побеждает свежайшая

Это драматически чище переносимых альтернатив (коррелированный подзапрос или оконная функция ROW_NUMBER(), отфильтрованная по = 1, с которой ты встретишься в разделе оконных функций). Для «свежайшей/лучшей строки на ключ» DISTINCT ON — идиоматичный инструмент Postgres.

UNION против UNION ALL: налог на дедупликацию

Операторы множеств складывают два набора результатов вертикально. Ключевое различие:

  • UNION объединяет обе стороны и убирает дублирующиеся строки — а значит, сортирует или хеширует весь объединённый результат, ровно как DISTINCT.
  • UNION ALL объединяет обе стороны и сохраняет всё, включая дубликаты. Ни шага дедупа, ни сортировки, ни хеша — просто конкатенирует потоки.

Продакшен-правило: по умолчанию UNION ALL; используй UNION, только когда дубликаты реально возможны и ты реально хочешь их убрать. Когда два входа непересекающиеся по построению (разные статусы, разные диапазоны дат, разные таблицы, не способные делить строку), UNION — чистая бесполезная сортировка. Инцидент из вступления был именно этим — непересекающиеся источники, ненужный дедуп 9 миллионов строк.

INTERSECT, EXCEPT и правила колонок

Ещё два оператора множеств, оба тоже дедуплицируют по умолчанию:

  • INTERSECT возвращает строки, присутствующие в обеих сторонах.
  • EXCEPT возвращает строки первой стороны, которых нет во второй (разность множеств; другие диалекты зовут это MINUS).

Все операторы множеств делят строгие структурные правила: каждая сторона должна иметь одинаковое число колонок, а соответствующие колонки — совместимые типы. Имена выходных колонок берутся из первого запроса; остальные для именования игнорируются. И один нюанс с NULL, отсылающий к прошлому уроку — для дедупликации операторов множеств два NULL считаются равными (так что UNION схлопывает дублирующиеся NULL-строки), что противоположно тому, как = их трактует. Это намеренный особый случай для контекстов группировки/дедупа.

Когда видишь ошибку несовместимости типов в операторе множеств, проверь число колонок и добавь CAST к несовпадающей колонке в одной из веток. Если поведение NULL в результате UNION удивляет — вспомни: в контекстах дедупа два NULL считаются равными; то же правило работает в GROUP BY и DISTINCT.

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

Почему UNION дедуплицирует, а JOIN нет? Потому что они отвечают на разные вопросы. JOIN сопоставляет строки между таблицами по условию и может законно размножать строки (один заказ, много позиций). Операторы множеств складывают строки, уже делящие одну форму, и спрашивают «каково объединённое множество?» — а множество по определению не имеет дубликатов, так что UNION/INTERSECT/EXCEPT обеспечивают семантику множества проходом дедупа. UNION ALL — это запасной выход, говорящий «мне нужен мешок (мультимножество), а не множество — пропусти дедуп». Выбрать его — заявить, что семантика множества не нужна, что обычно верно и почти всегда быстрее.

Расставь шаги по порядку

Расставь шаги, чтобы написать корректный запрос «последняя строка на пользователя» через DISTINCT ON:

  1. 1 Реши ключ группы — колонку, по которой нужна одна строка (user_id)
  2. 2 Напиши DISTINCT ON (user_id) и перечисли нужные колонки
  3. 3 Начни ORDER BY с того же ключа: ORDER BY user_id
  4. 4 Добавь тайбрейкер, выбирающий строку-победителя: , created_at DESC
  5. 5 Проверь, что вернулась ровно одна строка на user_id, свежайшая первой
Викторина

Известно, что два SELECT дают непересекающиеся строки (ни одно значение не может быть в обоих). Какой комбинатор корректен и быстрее?

Викторина

Что возвращает DISTINCT ON (user_id) ... ORDER BY user_id, created_at DESC?

Вспомните перед уходом
  1. 01
    Почему UNION дороже UNION ALL и когда какой использовать?
  2. 02
    Как DISTINCT ON выбирает, какую строку оставить, и какое правило ORDER BY?
  3. 03
    Сформулируй структурные правила операторов множеств и нюанс с NULL.
Итог

Дедупликация — сквозная линия. SELECT DISTINCT схлопывает идентичные строки сортировкой или хешем по всему результату — реальный CPU и память, со spill на диск, как только превышен work_mem — так что это не бесплатная уборка, и использовать его для маскировки дубликатов, порождённых join, значит прятать баг. Операторы множеств UNION, INTERSECT и EXCEPT дедуплицируют так же; сеньорский рефлекс — по умолчанию брать UNION ALL (простая конкатенация, без прохода дедупа) и беречь UNION для случаев, где дубликаты реально могут возникнуть и ты хочешь их убрать — замена ненужного UNION на UNION ALL способна превратить 11-секундный запрос в 600-миллисекундный. Для «одной строки на группу, конкретно последней» DISTINCT ON (key) в паре с ORDER BY key, тайбрейкер — идиоматичный ответ Postgres, чище коррелированного подзапроса и предшественник оконных паттернов из раздела 04. Операторам множеств нужны совпадающее число колонок и совместимые типы, они берут имена из первой ветки и — уникально — считают два NULL равными при дедупликации. Теперь, когда пишешь UNION и запрос медленнее ожидаемого, — сразу спроси: могут ли два входа вообще делить строку? Если нет, UNION ALL — и цена падает немедленно.

Практика

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

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

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

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

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

Trademarks belong to their respective owners. Editorial reference only.