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

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

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Используйте оконную функцию ROW_NUMBER с разбиением по клиенту и сортировкой записей от самых новых к старым. В отличие от GROUP BY, оконная функция не сворачивает строки: она нумерует их, после чего внешний запрос оставляет строки с номером 1.

WITH ranked AS ( SELECT o.*, ROW_NUMBER() OVER ( PARTITION BY customer_id ORDER BY created_at DESC, id DESC ) AS row_num FROM orders AS o ) SELECT * FROM ranked WHERE row_num = 1;

PARTITION BY формирует независимую группу для каждого клиента, а ORDER BY определяет, какая запись станет первой. Поле id используется как дополнительный детерминирующий критерий при одинаковом времени создания.

Исторический контекст

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

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

Постановка проблемы

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

Попытка использовать GROUP BY customer_id вместе с MAX(created_at) найдёт последнюю дату, но не определит, из какой строки брать остальные столбцы. Простое добавление этих столбцов в GROUP BY изменит смысл группировки, а совпадение дат может привести к нескольким подходящим строкам.

Подробное решение

Сначала оконная функция рассматривает строки каждого клиента отдельно. Сортировка по created_at DESC назначает номер 1 самой свежей записи, номер 2 — следующей и так далее. Затем внешний запрос фильтрует уже рассчитанный номер и возвращает полную строку.

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

Если бизнес-правило требует вернуть всех клиентов, у которых есть несколько записей с одинаковой максимальной датой, вместо ROW_NUMBER применяют RANK или находят максимум с последующим соединением. Это меняет результат: RANK сохранит все строки с первым рангом, а ROW_NUMBER оставит ровно одну.

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

Ситуация из практики

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

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

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

Что кандидаты часто упускают

  1. Чем ROW_NUMBER отличается от RANK при одинаковых значениях сортировки?

    ROW_NUMBER присваивает уникальные последовательные номера, поэтому при фильтрации по номеру 1 останется одна строка. RANK назначает одинаковый ранг строкам с одинаковыми значениями сортировки, поэтому все они могут попасть в результат. После группы с одинаковым первым рангом следующий ранг у RANK пропускается, а DENSE_RANK не пропускает.

  2. Почему сортировка только по дате может сделать результат нестабильным?

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

  3. Как предварительный фильтр по дате меняет смысл результата?

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