Аналитика24 октября 2026 г.

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

Автор
Ненашев А.
На чтение
8 мин
Дата обновления
24.10.2026
Старт:В любой момент
Срок обучения:2 месяца
SQL для анализа данных
Курс для тех, кому нужно работать с базами данных. Вы освоите язык запросов SQL – и с его помощью сможете самостоятельно получать нужные данные, сопоставлять и анализировать их.
Подробнее
50 828 ₽92 414 ₽
#COURSE##INNER#

Как выбрать последнюю запись по клиенту в 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_idclient_idchanged_atstatus_text
10112024-06-01 09:00:00created
10412024-06-03 12:30:00email_changed
10512024-06-03 12:30:00phone_changed
20122024-06-02 18:00:00created
20722024-06-04 08:15:00status_bronze
20822024-06-04 08:15:00status_silver
30132024-06-05 10:00:00created
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_idchange_idchanged_atstatus_text
11052024-06-03 12:30:00phone_changed
22082024-06-04 08:15:00status_silver
33012024-06-05 10:00:00created

Почему именно эти строки? У клиента 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 и партиции.

Курсы

3798
4,9
11 отзывов
SQL для анализа данных
  • В любой момент
  • 2 месяца
  • Онлайн
  • Диплом
50 828 ₽92 414 ₽
-45%
Перейти на сайт