SQL-соединение: выбор последних записей в отношении «один ко многим»

298

Предположим, у меня есть таблица клиентов и таблица покупок. Каждая покупка принадлежит одному клиенту. Я хочу получить список всех клиентов вместе с их последней покупкой в ​​одном операторе SELECT. Какова лучшая практика? Любой совет по созданию индексов?

Пожалуйста, используйте эти имена таблиц / столбцов в своем ответе:

  • Заказчик: идентификатор, имя
  • покупка: id, customer_id, item_id, дата

И в более сложных ситуациях было бы (с точки зрения производительности) выгодно денормализовать базу данных, поместив последнюю покупку в таблицу клиентов?

Если идентификатор (покупки) гарантированно отсортирован по дате, можно ли упростить выписки, используя что-то вроде LIMIT 1?

netvope
источник
Да, это может стоить денормализации (если это сильно повышает производительность, что можно узнать только путем тестирования обеих версий). Но недостатков денормализации обычно стоит избегать.
Винс Боудрен
2
Связанный: jan.kneschke.de/projects/mysql/groupwise-max
igorw

Ответы:

451

Это пример greatest-n-per-groupпроблемы, которая регулярно появлялась в StackOverflow.

Вот как я обычно рекомендую решить эту проблему:

SELECT c.*, p1.*
FROM customer c
JOIN purchase p1 ON (c.id = p1.customer_id)
LEFT OUTER JOIN purchase p2 ON (c.id = p2.customer_id AND 
    (p1.date < p2.date OR (p1.date = p2.date AND p1.id < p2.id)))
WHERE p2.id IS NULL;

Объяснение: для данной строки p1не должно быть строки p2с тем же клиентом и более поздней датой (или, в случае связей, более поздней id). Когда мы обнаруживаем, что это правда, p1это самая последняя покупка для этого клиента.

Что касается индексов, я бы создать составной индекс в purchaseтечение столбцов ( customer_id, date, id). Это может позволить внешнему соединению быть выполненным, используя индекс покрытия. Обязательно протестируйте на своей платформе, потому что оптимизация зависит от реализации. Используйте функции вашей РСУБД для анализа плана оптимизации. Например, EXPLAINна MySQL.


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

Билл Карвин
источник
3
Благоприятно, в общем. Но это зависит от марки базы данных, которую вы используете, а также от количества и распределения данных в вашей базе данных. Единственный способ получить точный ответ - проверить оба решения на соответствие вашим данным.
Билл Карвин
27
Если вы хотите включить клиентов, которые никогда не делали покупки, измените «Присоединение» к покупке «p1 ON» (c.id = p1.customer_id) на «Покупка влево» при помощи p1 ON (c.id = p1.customer_id)
GordonM
5
@russds, вам нужен какой-то уникальный столбец, который можно использовать для разрешения связи. Нет смысла иметь две одинаковые строки в реляционной базе данных.
Билл Карвин
6
Какова цель "ГДЕ p2.id НУЛЬ"?
clu
3
Это решение работает только при наличии более 1 записи о покупке. Существует ссылка 1: 1, она НЕ работает. там должно быть «ГДЕ (p2.id НЕДЕЙСТВИТЕЛЕН или p1.id = p2.id)
Бруно Дженнрих
126

Вы также можете попробовать сделать это с помощью суб-выбора

SELECT  c.*, p.*
FROM    customer c INNER JOIN
        (
            SELECT  customer_id,
                    MAX(date) MaxDate
            FROM    purchase
            GROUP BY customer_id
        ) MaxDates ON c.id = MaxDates.customer_id INNER JOIN
        purchase p ON   MaxDates.customer_id = p.customer_id
                    AND MaxDates.MaxDate = p.date

Выбор должен присоединиться ко всем клиентам и их дате последней покупки.

Адриан Стандер
источник
4
Спасибо, это только что спасло меня - это решение кажется более пригодным для повторного использования и обслуживания, чем другие, перечисленные +, не
относящееся к
Как бы я изменил это, если бы я хотел получить клиента, даже если не было никаких покупок?
clu
3
@clu: Изменение INNER JOINк LEFT OUTER JOIN.
Саша Чедыгов
3
Похоже, это предполагает, что в этот день была только одна покупка. Думаю, если бы их было два, вы бы получили две строки вывода для одного клиента.
artfulrobot
1
@IstiaqueAhmed - последний INNER JOIN принимает значение Max (date) и связывает его с исходной таблицей. Без этого объединения единственная информация, которую вы получили бы из purchaseтаблицы, - это дата и customer_id, но запрос запрашивает все поля из таблицы.
Смеющийся Вергилий
26

Вы не указали базу данных. Если это та функция, которая допускает аналитические функции, возможно, этот подход будет быстрее, чем метод GROUP BY (определенно быстрее в Oracle, скорее всего быстрее в поздних выпусках SQL Server, о других не знаю).

Синтаксис в SQL Server будет:

SELECT c.*, p.*
FROM customer c INNER JOIN 
     (SELECT RANK() OVER (PARTITION BY customer_id ORDER BY date DESC) r, *
             FROM purchase) p
ON (c.id = p.customer_id)
WHERE p.r = 1
Мадалина Драгомир
источник
10
Это неправильный ответ на вопрос, потому что вы используете «RANK ()» вместо «ROW_NUMBER ()». RANK по-прежнему будет вызывать ту же проблему связей, когда две покупки имеют одинаковую дату. Это то, что делает функция ранжирования; если первые 2 совпадают, им обоим присваивается значение 1, а третьей записи присваивается значение 3. При использовании Row_Number связь отсутствует, она уникальна для всего раздела.
MikeTeeVee
4
Испытывая подход Билла Карвина к подходу Мадалины здесь, с планами выполнения, включенными под SQL Server 2008, я обнаружил, что запрос Билла Карвина имел стоимость запроса 43%, в отличие от подхода Мадалины, который использовал 57% - поэтому, несмотря на более элегантный синтаксис этого ответа, я все равно одобрил бы версию Билла!
Шоусон
26

Другой подход заключается в использовании NOT EXISTSусловия в вашем условии соединения для проверки последующих покупок:

SELECT *
FROM customer c
LEFT JOIN purchase p ON (
       c.id = p.customer_id
   AND NOT EXISTS (
     SELECT 1 FROM purchase p1
     WHERE p1.customer_id = c.id
     AND p1.id > p.id
   )
)
Стефан Хаберл
источник
Можете ли вы объяснить AND NOT EXISTSроль в простых словах?
Истак Ахмед
Подвыбор только проверяет, есть ли строка с более высоким идентификатором. Вы получите только строку в вашем наборе результатов, если ни один с более высоким идентификатором не найден. Это должно быть единственным самым высоким.
Стефан
2
Это для меня самое читаемое решение. Если это важно
fguillen
:) Спасибо. Я всегда стремлюсь к наиболее читаемому решению, потому что это важно.
Стефан
19

Я нашел эту тему как решение моей проблемы.

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

With MaxDates as (
SELECT  customer_id,
                MAX(date) MaxDate
        FROM    purchase
        GROUP BY customer_id
)

SELECT  c.*, M.*
FROM    customer c INNER JOIN
        MaxDates as M ON c.id = M.customer_id 

Надеюсь, это будет полезно.

Mathee
источник
чтобы получить только 1 я использовал top 1и ordered it byMaxDatedesc
Рошна Омер
1
Это простое и понятное решение, в МОЕМ случае (много клиентов, мало покупок) на 10% быстрее, чем решение @Stefan Haberl, и более чем в 10 раз лучше, чем принятый ответ
Юрай Безручка
Отличное предложение с использованием общих табличных выражений (CTE) для решения этой проблемы. Это значительно улучшило производительность запросов во многих ситуациях.
AdamsTips
Лучший ответ, легко читаемый, предложение MAX () дает отличную производительность по сравнению с ORDER BY + LIMIT 1
mrj
10

Если вы используете PostgreSQL, вы можете DISTINCT ONнайти первую строку в группе.

SELECT customer.*, purchase.*
FROM customer
JOIN (
   SELECT DISTINCT ON (customer_id) *
   FROM purchase
   ORDER BY customer_id, date DESC
) purchase ON purchase.customer_id = customer.id

Документы PostgreSQL - четко определенные

Обратите внимание, что DISTINCT ONполе (поля) - здесь customer_id- должно соответствовать крайнему левому полю (ам) в ORDER BYпредложении.

Предостережение: это нестандартное предложение.

Тейт Терстон
источник
8

Попробуйте это, это поможет.

Я использовал это в своем проекте.

SELECT 
*
FROM
customer c
OUTER APPLY(SELECT top 1 * FROM purchase pi 
WHERE pi.customer_id = c.Id order by pi.Id desc) AS [LastPurchasePrice]
Рахул Мурари
источник
Откуда берется псевдоним "p"?
TiagoA
это не работает хорошо .... заняло вечность, где другие примеры заняли 2 секунды на наборе данных, которые у меня есть ...
Joel_J
3

Протестировано на SQLite:

SELECT c.*, p.*, max(p.date)
FROM customer c
LEFT OUTER JOIN purchase p
ON c.id = p.customer_id
GROUP BY c.id

Функция max()агрегирования будет следить за тем, чтобы в каждой группе была выбрана самая последняя покупка (но предполагается, что столбец даты имеет формат, в котором max () выдает самую последнюю - что обычно имеет место). Если вы хотите обрабатывать покупки с той же датой, вы можете использовать max(p.date, p.id).

Что касается индексов, я бы использовал индекс покупок с (customer_id, date, [любые другие столбцы покупок, которые вы хотите вернуть в вашем выборе]).

LEFT OUTER JOIN(В отличие от INNER JOIN) будет убедиться , что клиенты , которые никогда не делали покупки, также включены.

отметка
источник
не будет работать в t-sql, поскольку у select c. * есть столбцы, не
входящие
1

Пожалуйста, попробуйте это,

SELECT 
c.Id,
c.name,
(SELECT pi.price FROM purchase pi WHERE pi.Id = MAX(p.Id)) AS [LastPurchasePrice]
FROM customer c INNER JOIN purchase p 
ON c.Id = p.customerId 
GROUP BY c.Id,c.name;
Милад Шахбази
источник