DISTINCT, DISTINCT ON и операции над множествами
DISTINCT и UNION дедуплицируют, сортируя или хешируя весь результат — реальная цена. UNION ALL это пропускает. А DISTINCT ON — идиоматичный в Postgres «последняя строка на группу».
Эндпоинт отчётности сшивал «активных пользователей за неделю» из двух таблиц через 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 Реши ключ группы — колонку, по которой нужна одна строка (user_id)
- 2 Напиши DISTINCT ON (user_id) и перечисли нужные колонки
- 3 Начни ORDER BY с того же ключа: ORDER BY user_id
- 4 Добавь тайбрейкер, выбирающий строку-победителя: , created_at DESC
- 5 Проверь, что вернулась ровно одна строка на user_id, свежайшая первой
Известно, что два SELECT дают непересекающиеся строки (ни одно значение не может быть в обоих). Какой комбинатор корректен и быстрее?
Что возвращает DISTINCT ON (user_id) ... ORDER BY user_id, created_at DESC?
- 01Почему UNION дороже UNION ALL и когда какой использовать?
- 02Как DISTINCT ON выбирает, какую строку оставить, и какое правило ORDER BY?
- 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-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.