Последняя запись на клиента в SQL: как разрешить ничью по времени
В журнале клиента два изменения статуса записаны с точностью до секунды, и обе строки имеют время 09:00. Отчёт просит «последний статус», а запрос с MAX(changed_at) возвращает сразу две строки. Если после этого соединить статус с заказами, заказы задвоятся. Проблема не в JOIN: до соединения ещё не определено, что считать последним событием.
У запроса должно быть полное правило порядка. В учебном журнале event_id возрастает вместе с порядком регистрации события, поэтому среди одинаковых changed_at мы считаем более поздним больший event_id. В реальном источнике это свойство нужно подтвердить. Если идентификатор выдаётся независимо от времени, он годится лишь для произвольного, хотя и стабильного, выбора; для смысла «последний» нужен последовательный номер версии или время записи с достаточной точностью.
Одна строка на клиента до соединения
ROW_NUMBER() нумерует события отдельно по каждому клиенту. Сортировка по времени и разрешающему ничью ключу даёт единственную строку с номером 1. Сначала получаем её, затем можем присоединять к другим таблицам.
CREATE TABLE status_events (event_id INTEGER, customer_id INTEGER, changed_at TEXT, status TEXT);
INSERT INTO status_events VALUES
(10, 7, '2025-06-01 10:00:00', 'new'),
(11, 7, '2025-06-03 09:00:00', 'regular'),
(12, 7, '2025-06-03 09:00:00', 'vip'),
(13, 8, '2025-06-02 12:00:00', 'regular');
WITH ranked AS (
SELECT customer_id, event_id, changed_at, status,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY changed_at DESC, event_id DESC
) AS rn
FROM status_events
)
SELECT customer_id, event_id, status
FROM ranked WHERE rn = 1
ORDER BY customer_id;
Результат: для клиента 7 — событие 12 со статусом vip, для клиента 8 — событие 13 со статусом regular. События 11 и 12 имеют одинаковое время, но номер 12 больше. Если убрать event_id DESC, запрос по-прежнему вернёт одну строку на клиента, однако при ничьей порядок не будет определён вашим SQL. Такой результат может казаться стабильным на малом наборе и смениться после изменения плана или загрузки.
Подзапрос с MAX(changed_at) полезен, когда требуется вывести все события последнего момента. Это другая постановка. Для одного «победителя» добавьте явный второй критерий; для честного показа конфликта оставьте обе строки и отправьте их на разбор владельцу данных. Не маскируйте противоречивые статусы молчаливым MAX(status): алфавитный порядок текста никак не связан с бизнес-порядком статусов.
Текущий статус и статус в прошлом
Здесь запрос выбирает последнюю запись в журнале вообще. Если нужен статус на момент заказа, нельзя сначала выбрать текущую строку и затем проверить дату заказа: прошлые версии уже потеряны. Там нужна версия, чей интервал действия покрывает дату заказа. Обе задачи похожи видом данных, но отвечают на разные вопросы.
Для проверки на рабочей таблице найдите группы (customer_id, changed_at) с количеством больше одного. Посмотрите, отражают ли совпадения честную последовательность событий или повторную доставку одной и той же записи. Во втором случае сначала устраните дубли по идентификатору события, иначе «последняя запись» может быть просто последней копией.
Порядок фильтрации тоже влияет на ответ. Если нужно выбрать последний любой статус, а затем узнать, активен ли клиент, фильтр по status = 'active' ставят после rn=1. Если поместить его внутрь CTE до нумерации, запрос найдёт последний статус active, даже когда позднее клиент стал заблокированным. Это другой, иногда полезный вопрос, но для текущего состояния он опасен.
Когда последний timestamp не уникален, можно сделать отдельный диагностический отчёт по таким клиентам до выбора победителя. Сверьте события с источником: возможно, время округлено при экспорте, а исходная система хранит миллисекунды. Тогда правильнее восстановить точное время, чем придумывать вторичный порядок. Если события действительно одновременно меняют разные поля, одной колонке status может понадобиться своя версия.
Для производительности важен тип запроса. Сначала проверьте план для индекса по (customer_id, changed_at DESC, event_id DESC) на своей СУБД и объёме. На маленькой таблице оконная нумерация читается проще и работает быстро; на миллионах событий стоимость сортировки может стать заметной. Оптимизацию делайте после проверки результата на ничьих.
В «Продвинутом SQL» этот приём продолжается историческими соединениями и проверкой дублей после JOIN. Практика на SQLite проверяет запросы на нескольких наборах данных, где встречаются равные даты и пропуски. Посмотрите программу, если хотите научиться задавать полный порядок и сохранять правильную кратность в аналитической витрине.
Когда нужен именно прошлый статус заказа, разберите соединение с историей на дату заказа.
Продолжить на практике
- Продвинутый SQL: сложные запросы и задачи для собеседований ↗
Продолжение базового SQL: сложные JOIN, оконные рамки, временные ряды, retention, воронки и рекурсивные CTE. Разбирайте примеры и пишите запросы с автоматической проверкой — от отдельных расчётов до витрины интернет-магазина.
