
Как выбрать последнюю запись по клиенту в SQL без случайного результата


Когда нужно взять последнюю запись по клиенту, ошибка обычно выглядит безобидно: сортировка делается только по времени изменения, а потом берётся первая строка. Пока у каждого клиента все метки времени разные, запрос кажется правильным. Проблема появляется в тот момент, когда у одного клиента две записи имеют одинаковый changed_at. Тогда SQL уже не знает, какая из них должна считаться последней, если вы заранее не задали второе правило сравнения.
Надёжное решение состоит из двух частей: сначала определить, что именно значит «последняя» для вашей задачи, а затем выразить это определение в ORDER BY. В этом разборе правило будет таким: сначала более позднее время изменения, а при равенстве времени — больший уникальный ID. Тогда результат будет воспроизводимым, а не зависимым от внутреннего порядка хранения строк.
Что считать последней записью
Само слово «последняя» в SQL не имеет встроенного смысла. База данных не угадывает бизнес-логику: ей нужно точное правило сортировки. Если сказать только «последняя по времени», то при двух одинаковых метках времени вы всё ещё получите две равноправные строки. Значит, правило неполное.
Для одной записи на клиента нужно задать полный порядок. В нашей задаче он такой: changed_at DESC, change_id DESC. Первая часть выбирает самые поздние изменения, вторая снимает равенство между строками с одинаковым временем. Важно, что уникальный ID здесь используется именно как правило разрешения равенства. Если в вашей предметной области последняя запись должна определяться иначе, например по версии документа или по приоритету статуса, в сортировку нужно ставить эти поля, а не автоматически брать ID.
Отсюда следует и практический вывод: одного MAX(changed_at) недостаточно, если вам нужна ровно одна строка. Этот максимум находит момент времени, но не выбирает единственную запись внутри этого момента.
ROW_NUMBER с правилом разрешения равенства
Оконная функция ROW_NUMBER() хорошо подходит для задачи «одна лучшая строка в каждой группе». Мы делим строки по клиенту через PARTITION BY client_id, сортируем строки внутри клиента по полному правилу и оставляем только те, у которых номер равен 1.
Учебный пример. Ниже показан условный набор данных в синтаксисе, совместимом с PostgreSQL. У клиентов 1 и 2 есть по две записи с одинаковым временем изменения. Нужно вернуть по одной строке на клиента.
| change_id | client_id | changed_at | status_text |
|---|---|---|---|
| 101 | 1 | 2024-06-01 09:00:00 | created |
| 104 | 1 | 2024-06-03 12:30:00 | email_changed |
| 105 | 1 | 2024-06-03 12:30:00 | phone_changed |
| 201 | 2 | 2024-06-02 18:00:00 | created |
| 207 | 2 | 2024-06-04 08:15:00 | status_bronze |
| 208 | 2 | 2024-06-04 08:15:00 | status_silver |
| 301 | 3 | 2024-06-05 10:00:00 | created |
WITH client_events AS (
SELECT *
FROM (VALUES
(101, 1, TIMESTAMP '2024-06-01 09:00:00', 'created'),
(104, 1, TIMESTAMP '2024-06-03 12:30:00', 'email_changed'),
(105, 1, TIMESTAMP '2024-06-03 12:30:00', 'phone_changed'),
(201, 2, TIMESTAMP '2024-06-02 18:00:00', 'created'),
(207, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_bronze'),
(208, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_silver'),
(301, 3, TIMESTAMP '2024-06-05 10:00:00', 'created')
) AS t(change_id, client_id, changed_at, status_text)
),
ranked AS (
SELECT
change_id,
client_id,
changed_at,
status_text,
ROW_NUMBER() OVER (
PARTITION BY client_id
ORDER BY changed_at DESC, change_id DESC
) AS rn
FROM client_events
)
SELECT
client_id,
change_id,
changed_at,
status_text
FROM ranked
WHERE rn = 1
ORDER BY client_id;Ожидаемый результат у этого запроса такой:
| client_id | change_id | changed_at | status_text |
|---|---|---|---|
| 1 | 105 | 2024-06-03 12:30:00 | phone_changed |
| 2 | 208 | 2024-06-04 08:15:00 | status_silver |
| 3 | 301 | 2024-06-05 10:00:00 | created |
Почему именно эти строки? У клиента 1 время одинаковое у 104 и 105, поэтому побеждает больший ID — 105. У клиента 2 по той же причине выбирается 208. У клиента 3 запись одна, поэтому она и получает номер 1.
Почему без полного ORDER BY результат кажется случайным
Частая версия запроса выглядит так:
ROW_NUMBER() OVER (
PARTITION BY client_id
ORDER BY changed_at DESC
)На первый взгляд кажется, что этого достаточно. Но для клиента 1 строки 104 и 105 имеют одинаковый changed_at. Если в сортировке нет второго поля, база видит их как равные по условию упорядочивания. Тогда одна из них всё равно получит rn = 1, но какая именно — вы не контролируете правилом запроса.
На практике это проявляется как «то одна строка, то другая», особенно после изменения плана выполнения, переиндексации, загрузки данных в другом порядке или переноса запроса в другую СУБД. Правильнее называть такой результат не случайным, а неопределённым с точки зрения вашей логики. Причина не в нестабильности ROW_NUMBER(), а в том, что вы не завершили правило сортировки.
Есть и другая типичная ошибка: сначала найти MAX(changed_at) по клиенту, а потом присоединить строки с этим временем. Такой запрос вернёт обе строки с максимумом времени, если метка совпадает. Это нормальное поведение, но оно решает другую задачу: «все строки на максимальном времени», а не «одну последнюю строку».
Как проверить результат по каждому клиенту
После написания запроса полезно не сразу использовать его в отчёте, а проверить, как распределились номера строк внутри каждого клиента. Для этого можно временно убрать фильтр WHERE rn = 1 и посмотреть ранжирование целиком.
WITH client_events AS (
SELECT *
FROM (VALUES
(101, 1, TIMESTAMP '2024-06-01 09:00:00', 'created'),
(104, 1, TIMESTAMP '2024-06-03 12:30:00', 'email_changed'),
(105, 1, TIMESTAMP '2024-06-03 12:30:00', 'phone_changed'),
(201, 2, TIMESTAMP '2024-06-02 18:00:00', 'created'),
(207, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_bronze'),
(208, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_silver'),
(301, 3, TIMESTAMP '2024-06-05 10:00:00', 'created')
) AS t(change_id, client_id, changed_at, status_text)
)
SELECT
client_id,
change_id,
changed_at,
ROW_NUMBER() OVER (
PARTITION BY client_id
ORDER BY changed_at DESC, change_id DESC
) AS rn
FROM client_events
ORDER BY client_id, rn;Для нашего примера вы должны увидеть у клиента 1 порядок 105, 104, 101; у клиента 2 — 208, 207, 201; у клиента 3 — 301. Такая проверка быстро показывает, что правило отработало именно так, как вы задумали.
Ещё одна полезная проверка — сверить результат независимым способом. Например, выбрать строки, для которых не существует другой строки того же клиента с более поздним временем или с тем же временем, но большим ID:
WITH client_events AS (
SELECT *
FROM (VALUES
(101, 1, TIMESTAMP '2024-06-01 09:00:00', 'created'),
(104, 1, TIMESTAMP '2024-06-03 12:30:00', 'email_changed'),
(105, 1, TIMESTAMP '2024-06-03 12:30:00', 'phone_changed'),
(201, 2, TIMESTAMP '2024-06-02 18:00:00', 'created'),
(207, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_bronze'),
(208, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_silver'),
(301, 3, TIMESTAMP '2024-06-05 10:00:00', 'created')
) AS t(change_id, client_id, changed_at, status_text)
)
SELECT c1.*
FROM client_events c1
WHERE NOT EXISTS (
SELECT 1
FROM client_events c2
WHERE c2.client_id = c1.client_id
AND (
c2.changed_at > c1.changed_at
OR (c2.changed_at = c1.changed_at AND c2.change_id > c1.change_id)
)
)
ORDER BY c1.client_id;Если этот запрос возвращает те же строки, что и вариант с ROW_NUMBER(), у вас есть проверяемый результат, а не просто «похоже, что работает».
Одна запись против всех равных максимумов
Перед тем как закрепить решение, полезно отделить две похожие задачи. Первая: нужна ровно одна запись на клиента. Тогда вы задаёте полное правило сортировки и берёте ROW_NUMBER() = 1. Вторая: нужны все записи, у которых максимальное время изменения. Тогда не надо разрешать равенство по ID, иначе вы специально уберёте часть строк.
Если нужна вторая постановка, можно использовать RANK() только по времени:
WITH client_events AS (
SELECT *
FROM (VALUES
(101, 1, TIMESTAMP '2024-06-01 09:00:00', 'created'),
(104, 1, TIMESTAMP '2024-06-03 12:30:00', 'email_changed'),
(105, 1, TIMESTAMP '2024-06-03 12:30:00', 'phone_changed'),
(201, 2, TIMESTAMP '2024-06-02 18:00:00', 'created'),
(207, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_bronze'),
(208, 2, TIMESTAMP '2024-06-04 08:15:00', 'status_silver'),
(301, 3, TIMESTAMP '2024-06-05 10:00:00', 'created')
) AS t(change_id, client_id, changed_at, status_text)
),
ranked AS (
SELECT
change_id,
client_id,
changed_at,
status_text,
RANK() OVER (
PARTITION BY client_id
ORDER BY changed_at DESC
) AS rnk
FROM client_events
)
SELECT *
FROM ranked
WHERE rnk = 1
ORDER BY client_id, change_id;В нашем учебном наборе такой запрос вернёт для клиента 1 обе строки 104 и 105, а для клиента 2 — 207 и 208. Это не ошибка: просто смысл задачи другой. Поэтому главное решение здесь не в выборе «правильной» функции вообще, а в точной формулировке вопроса к данным.
Практический итог такой: если хотите избежать неустойчивого выбора последней строки, формулируйте последнее значение не одним полем времени, а полным правилом порядка. Для задачи из этого разбора это PARTITION BY client_id ORDER BY changed_at DESC, change_id DESC и фильтр rn = 1. Тогда каждая строка-победитель объяснима, проверяема и повторяема.
Если хотите отдельно потренировать такие запросы, на странице курса SQL для анализа данных есть сохранённое описание программы с темами по сортировке, работе с датой и временем и оконным функциям, включая row_number и партиции.





















