Продолжаю разбор задач с собеседований, под новым углом. Сегодня классика, на которой сыпется половина кандидатов.
❓ Задача
Есть таблица транзакций. Нужно вернуть последнюю по времени транзакцию каждого клиента(успешную или нет не важно) - целиком, со всеми полями, а не только max(время). top-1 на группу (greatest-N-per-group).
табличка транзакций
CREATE TABLE Transactions (
id BIGINT PRIMARY KEY, -- id транзакции
customer_id BIGINT, -- клиент
amount DECIMAL(12,2), -- сумма
status VARCHAR, -- статус
created_at TIMESTAMP -- время транзакции
);
Пример данных
id | customer | amount | status | created_at
101 | 1 | 50.00 | done | 2026-07-01 10:00
102 | 1 | 75.00 | done | 2026-07-10 14:30
201 | 2 | 20.00 | done | 2026-07-05 09:00
202 | 2 | 30.00 | failed | 2026-07-15 09:00
203 | 2 | 30.00 | done | 2026-07-15 09:00
301 | 3 | 100.00 | done | 2026-07-12 12:00
Итак какие у нас варианты решений - да их больше чем 1:
1) Коррелированный подзапрос. Просто в лоб и напрашивается само.
SELECT t.*
FROM Transactions t
WHERE t.created_at = (
SELECT MAX(t2.created_at)
FROM Transactions t2
WHERE t2.customer_id = t.customer_id
);
Да работает, но:
• На каждую строку лезет в подзапрос за MAX (оптимизатор иногда перепишет, иногда нет);
• БАГ: у customer_id=2 две транзакции с равным created_at → вернутся ОБЕ. Должно быть 3 строки а будет 4. Придется думать над доп фильтром;
2) Оконная функция ROW_NUMBER() - конечно же.
WITH ranked AS (
SELECT t.*,
ROW_NUMBER() OVER (
PARTITION BY customer_id
ORDER BY created_at DESC, id DESC
) AS rn
FROM Transactions t
)
SELECT * FROM ranked WHERE rn = 1;
Почему это стандарт для подобных задач?:
• один проход по таблице;
• тай-брейк вторым ключом (id DESC) - детерминированно одна строка, бага с дублями нет;
⚡️ ROW_NUMBER, а не RANK — RANK при равных значениях вернёт дубли.
Но есть ли еще что то ? Еще более быстро и красиво?
Знакомьтес с мистером LATERALL и CROSS APPLY
3) LATERAL (Postgres) / CROSS APPLY (Sql Server)
— Postgres
SELECT t.*
FROM Customers c
CROSS JOIN LATERAL (
SELECT *
FROM Transactions t
WHERE t.customer_id = c.customer_id
ORDER BY t.created_at DESC, t.id DESC
LIMIT 1
) t;
— Sql Server
SELECT t.*
FROM Customers c
CROSS APPLY (
SELECT TOP 1 *
FROM Transactions t
WHERE t.customer_id = c.customer_id
ORDER BY t.created_at DESC, t.id DESC
) t;
🟢Фишка этого решения - при индексе (customer_id, created_at DESC) движок делает index seek на каждую группу - это почти O(числа клиентов), а не сортировка всей таблицы.
CREATE INDEX ix_txn ON Transactions (customer_id, created_at DESC, id DESC);
маловероятно что в продакшн таблица транзакций не будет иметь индексов.
⚖️ Что выбрать - через призму архитектуры
• Классическая OLTP-СУБД с индексом (Postgres / SQL Server) → LATERAL / APPLY обычно быстрее: seek по индексу вместо полной сортировки.
• Колоночный движок / lakehouse (BigQuery, Snowflake, Spark, Iceberg + Trino) → индексов нет, зато дёшево сканировать и сортировать колонки → выигрывает ROW_NUMBER, и он отлично параллелится.
• Коррелированный подзапрос → ответ уровня а как ещё можно - не более :).
🔴 Ловушки, на которых валят
• RANK вместо ROW_NUMBER = дубли при равных ключах.
• Нет тай-брейка (id) в ORDER BY → результат недетерминирован, на равном времени каждый раз может быть разная строка.
• NULL в created_at: при ORDER BY ... DESC в Postgres NULL уезжает наверх (NULLS FIRST) и притворяется «последним». Пиши NULLS LAST.
• Нужны и клиенты без транзакций? Подзапрос и оконка их не вернут — бери LEFT JOIN LATERAL.
#SQL #window_functions #ETL #DataJungle