PostgreSQL - получить строку, которая имеет максимальное значение для столбца

96

Я имею дело с таблицей Postgres (называемой "жизнями"), которая содержит записи со столбцами для time_stamp, usr_id, transaction_id и life_remaining. Мне нужен запрос, который предоставит мне самое последнее количество жизней_ремайн для каждого usr_id

  1. Есть несколько пользователей (разные usr_id)
  2. time_stamp не является уникальным идентификатором: иногда пользовательские события (по одному в таблице) будут происходить с одной и той же time_stamp.
  3. trans_id уникален только для очень малых временных диапазонов: со временем он повторяется
  4. оставшееся_жизнь (для данного пользователя) может как увеличиваться, так и уменьшаться с течением времени

пример:

отметка_времени | жизнь_ремонта | usr_id | trans_id
-----------------------------------------
  07:00 | 1 | 1 | 1    
  09:00 | 4 | 2 | 2    
  10:00 | 2 | 3 | 3    
  10:00 | 1 | 2 | 4    
  11:00 | 4 | 1 | 5    
  11:00 | 3 | 1 | 6    
  13:00 | 3 | 3 | 1    

Поскольку мне нужно будет получить доступ к другим столбцам строки с последними данными для каждого заданного usr_id, мне нужен запрос, который дает такой результат:

отметка_времени | жизнь_ремонта | usr_id | trans_id
-----------------------------------------
  11:00 | 3 | 1 | 6    
  10:00 | 1 | 2 | 4    
  13:00 | 3 | 3 | 1    

Как уже упоминалось, каждый usr_id может приносить или терять жизни, и иногда эти события с отметкой времени происходят так близко друг к другу, что имеют одинаковую отметку времени! Следовательно, этот запрос не будет работать:

SELECT b.time_stamp,b.lives_remaining,b.usr_id,b.trans_id FROM 
      (SELECT usr_id, max(time_stamp) AS max_timestamp 
       FROM lives GROUP BY usr_id ORDER BY usr_id) a 
JOIN lives b ON a.max_timestamp = b.time_stamp

Вместо этого мне нужно использовать time_stamp (first) и trans_id (second), чтобы идентифицировать правильную строку. Затем мне также нужно передать эту информацию из подзапроса в основной запрос, который предоставит данные для других столбцов соответствующих строк. Это взломанный запрос, с которым мне пришлось работать:

SELECT b.time_stamp,b.lives_remaining,b.usr_id,b.trans_id FROM 
      (SELECT usr_id, max(time_stamp || '*' || trans_id) 
       AS max_timestamp_transid
       FROM lives GROUP BY usr_id ORDER BY usr_id) a 
JOIN lives b ON a.max_timestamp_transid = b.time_stamp || '*' || b.trans_id 
ORDER BY b.usr_id

Хорошо, это работает, но мне это не нравится. Для этого требуется запрос внутри запроса, самосоединение, и мне кажется, что это может быть намного проще, если взять строку, которая, как обнаружил MAX, имеет наибольшую временную метку и trans_id. Таблица "живет" содержит десятки миллионов строк для анализа, поэтому мне хотелось бы, чтобы этот запрос был как можно более быстрым и эффективным. Я новичок в RDBM и Postgres в частности, поэтому знаю, что мне нужно эффективно использовать правильные индексы. Я немного не понимаю, как оптимизировать.

Я нашел подобное обсуждение здесь . Могу ли я выполнить какой-либо тип Postgres, эквивалентный аналитической функции Oracle?

Мы будем очень благодарны за любые советы по доступу к связанной информации столбцов, используемой агрегатной функцией (например, MAX), созданию индексов и созданию более качественных запросов!

PS Для создания моего примера вы можете использовать следующее:

create TABLE lives (time_stamp timestamp, lives_remaining integer, 
                    usr_id integer, trans_id integer);
insert into lives values ('2000-01-01 07:00', 1, 1, 1);
insert into lives values ('2000-01-01 09:00', 4, 2, 2);
insert into lives values ('2000-01-01 10:00', 2, 3, 3);
insert into lives values ('2000-01-01 10:00', 1, 2, 4);
insert into lives values ('2000-01-01 11:00', 4, 1, 5);
insert into lives values ('2000-01-01 11:00', 3, 1, 6);
insert into lives values ('2000-01-01 13:00', 3, 3, 1);
Джошуа Берри
источник
Джош, вам может не понравиться самосоединение запроса и т. Д., Но это нормально для СУБД.
vladr
1
Самосоединение фактически преобразуется в простое отображение индекса, где внутренний SELECT (тот, что с MAX) сканирует индекс, отбрасывая нерелевантные записи, а внешний SELECT просто захватывает остальные столбцы из таблицы соответствующему суженному индексу.
vladr
Влад, спасибо за советы и объяснения. Это открыло мне глаза на то, как начать понимать внутреннюю работу базы данных и как оптимизировать запросы. Quassnoi, спасибо за отличный запрос и подсказку по первичному ключу; Билл тоже. Очень полезно.
Джошуа Берри,
спасибо, что показали мне, как получить MAX BY2 столбца!

Ответы:

90

В таблице с 158 тыс. Псевдослучайных строк (usr_id равномерно распределен между 0 и 10 тыс., trans_idРавномерно распределен между 0 и 30),

Под стоимостью запроса ниже я имею в виду оценку стоимости оптимизатора Postgres (со значениями Postgres по умолчанию xxx_cost), которая представляет собой взвешенную функциональную оценку требуемых ресурсов ввода-вывода и ЦП; вы можете получить это, запустив PgAdminIII и запустив «Query / Explain (F7)» по запросу с «Query / Explain options», установленным на «Analyze»

  • Запрос Quassnoy имеет оценку стоимости 745k (!), И завершает в 1,3 секунды ( с учетом соединения индекс ( usr_id, trans_id, time_stamp))
  • Запрос Билла оценивается в 93 тыс. И выполняется за 2,9 секунды (с учетом составного индекса на ( usr_id, trans_id)).
  • Запрос # 1 ниже имеет оценку стоимости 16k, и завершается в 800 мс ( с учетом составного индекса по ( usr_id, trans_id, time_stamp))
  • Запрос # 2 ниже имеет оценку стоимости 14k, и завершается в 800 мс ( с учетом составного индекса функции на ( usr_id, EXTRACT(EPOCH FROM time_stamp), trans_id))
    • это специфично для Postgres
  • Запрос # 3 ниже (Postgres 8.4+) имеет оценку стоимости и времени завершения , сравнимую с (или лучше , чем) запрос # 2 (учитывая соединение индекс ( usr_id, time_stamp, trans_id)); у него есть преимущество сканирования livesтаблицы только один раз, и, если вы временно увеличите (при необходимости) work_mem для размещения сортировки в памяти, это будет самый быстрый из всех запросов.

Все указанные выше моменты включают получение полного набора результатов из 10 тыс. Строк.

Ваша цель - минимальная оценка стоимости и минимальное время выполнения запроса с упором на оценочную стоимость. Выполнение запроса может существенно зависеть от условий выполнения (например, от того, полностью ли кэшированы соответствующие строки в памяти или нет), в то время как оценка стоимости - нет. С другой стороны, имейте в виду, что смета - это именно оценка.

Наилучшее время выполнения запроса достигается при работе с выделенной базой данных без нагрузки (например, игра с pgAdminIII на ПК для разработки). Время запроса будет варьироваться в производственной среде в зависимости от фактической нагрузки на машину / распределения доступа к данным. Когда один запрос появляется немного быстрее (<20%), чем другой, но имеет гораздо более высокую стоимость, обычно будет разумнее выбрать тот, у которого больше время выполнения, но ниже стоимость.

Если вы ожидаете, что не будет конкуренции за память на вашем производственном компьютере во время выполнения запроса (например, кеш СУБД и кеш файловой системы не будут перегружены параллельными запросами и / или активностью файловой системы), тогда полученное вами время запроса в автономном режиме (например, pgAdminIII на ПК разработки) будет репрезентативным. Если в производственной системе существует конкуренция, время запроса будет уменьшаться пропорционально расчетному соотношению затрат, поскольку запрос с более низкой стоимостью не так сильно зависит от кеша, тогда как запрос с более высокой стоимостью будет повторно обращаться к одним и тем же данным снова и снова (запуск дополнительный ввод-вывод при отсутствии стабильного кеша), например:

              cost | time (dedicated machine) |     time (under load) |
-------------------+--------------------------+-----------------------+
some query A:   5k | (all data cached)  900ms | (less i/o)     1000ms |
some query B:  50k | (all data cached)  900ms | (lots of i/o) 10000ms |

Не забудьте запустить ANALYZE livesодин раз после создания необходимых индексов.


Запрос №1

-- incrementally narrow down the result set via inner joins
--  the CBO may elect to perform one full index scan combined
--  with cascading index lookups, or as hash aggregates terminated
--  by one nested index lookup into lives - on my machine
--  the latter query plan was selected given my memory settings and
--  histogram
SELECT
  l1.*
 FROM
  lives AS l1
 INNER JOIN (
    SELECT
      usr_id,
      MAX(time_stamp) AS time_stamp_max
     FROM
      lives
     GROUP BY
      usr_id
  ) AS l2
 ON
  l1.usr_id     = l2.usr_id AND
  l1.time_stamp = l2.time_stamp_max
 INNER JOIN (
    SELECT
      usr_id,
      time_stamp,
      MAX(trans_id) AS trans_max
     FROM
      lives
     GROUP BY
      usr_id, time_stamp
  ) AS l3
 ON
  l1.usr_id     = l3.usr_id AND
  l1.time_stamp = l3.time_stamp AND
  l1.trans_id   = l3.trans_max

Запрос №2

-- cheat to obtain a max of the (time_stamp, trans_id) tuple in one pass
-- this results in a single table scan and one nested index lookup into lives,
--  by far the least I/O intensive operation even in case of great scarcity
--  of memory (least reliant on cache for the best performance)
SELECT
  l1.*
 FROM
  lives AS l1
 INNER JOIN (
   SELECT
     usr_id,
     MAX(ARRAY[EXTRACT(EPOCH FROM time_stamp),trans_id])
       AS compound_time_stamp
    FROM
     lives
    GROUP BY
     usr_id
  ) AS l2
ON
  l1.usr_id = l2.usr_id AND
  EXTRACT(EPOCH FROM l1.time_stamp) = l2.compound_time_stamp[1] AND
  l1.trans_id = l2.compound_time_stamp[2]

2013/01/29 обновление

Наконец, начиная с версии 8.4, Postgres поддерживает оконную функцию, что означает, что вы можете написать что-то настолько простое и эффективное, как:

Запрос №3

-- use Window Functions
-- performs a SINGLE scan of the table
SELECT DISTINCT ON (usr_id)
  last_value(time_stamp) OVER wnd,
  last_value(lives_remaining) OVER wnd,
  usr_id,
  last_value(trans_id) OVER wnd
 FROM lives
 WINDOW wnd AS (
   PARTITION BY usr_id ORDER BY time_stamp, trans_id
   ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING
 );
владр
источник
Под составным индексом на (usr_id, trans_id, times_tamp) вы имеете в виду что-то вроде «СОЗДАТЬ ИНДЕКС жизней_blah_idx НА жизнях (usr_id, trans_id, time_stamp)»? Или мне следует создать три отдельных индекса для каждого столбца? Я должен придерживаться значения по умолчанию «USING btree», верно?
Джошуа Берри,
1
Да, первый вариант: я имею в виду СОЗДАТЬ ИНДЕКС жизней_blah_idx НА жизнях (usr_id, trans_id, time_stamp). :) Ура.
vladr
Спасибо, что даже сравнили стоимость владр! Очень полный ответ!
Adam
@vladr Я только что наткнулся на ваш ответ. Я немного сбит с толку, поскольку вы говорите, что запрос 1 стоит 16 тысяч, а запрос 2 - 14 тысяч. Но ниже в таблице вы говорите, что запрос 1 стоит 5 тысяч, а запрос 2 - 50 тысяч. Итак, какой запрос лучше всего использовать? :) спасибо
Houman
1
@Kave, таблица предназначена для гипотетической пары запросов для иллюстрации примера, а не для двух запросов OP. Переименование, чтобы избежать путаницы.
владр
78

Я бы предложил чистую версию на основе DISTINCT ON(см. Документы ):

SELECT DISTINCT ON (usr_id)
    time_stamp,
    lives_remaining,
    usr_id,
    trans_id
FROM lives
ORDER BY usr_id, time_stamp DESC, trans_id DESC;
Марко
источник
6
Это очень короткий и здравый ответ. Также есть хорошая ссылка! Это должен быть принятый ответ.
Prakhar Agrawal
Похоже, это сработало для меня в моем немного другом приложении, где ничего другого не было. Определенно следует поднять для большей наглядности.
Джим Фактор
8

Вот еще один метод, в котором не используются коррелированные подзапросы или GROUP BY. Я не эксперт в настройке производительности PostgreSQL, поэтому предлагаю вам попробовать как это, так и решения, предоставленные другими людьми, чтобы увидеть, какое из них лучше работает для вас.

SELECT l1.*
FROM lives l1 LEFT OUTER JOIN lives l2
  ON (l1.usr_id = l2.usr_id AND (l1.time_stamp < l2.time_stamp 
   OR (l1.time_stamp = l2.time_stamp AND l1.trans_id < l2.trans_id)))
WHERE l2.usr_id IS NULL
ORDER BY l1.usr_id;

Я предполагаю, что trans_idэто уникально, по крайней мере, для любого заданного значения time_stamp.

Билл Карвин
источник
4

Мне нравится стиль ответа Майка Вудхауса на другой странице, которую вы упомянули. Это особенно лаконично, когда объект, который максимизируется, представляет собой только один столбец, и в этом случае подзапрос может просто использовать MAX(some_col)и GROUP BYдругие столбцы, но в вашем случае у вас есть количество из двух частей, которое нужно максимизировать, вы все равно можете сделать это, используя ORDER BYплюс LIMIT 1вместо этого (как это сделал Quassnoi):

SELECT * 
FROM lives outer
WHERE (usr_id, time_stamp, trans_id) IN (
    SELECT usr_id, time_stamp, trans_id
    FROM lives sq
    WHERE sq.usr_id = outer.usr_id
    ORDER BY trans_id, time_stamp
    LIMIT 1
)

Мне нравится использовать синтаксис конструктора строк, WHERE (a, b, c) IN (subquery)потому что он сокращает объем необходимой многословности.

j_random_hacker
источник
3

На самом деле есть хакерское решение этой проблемы. Допустим, вы хотите выбрать самое большое дерево каждого леса в регионе.

SELECT (array_agg(tree.id ORDER BY tree_size.size)))[1]
FROM tree JOIN forest ON (tree.forest = forest.id)
GROUP BY forest.id

Когда вы группируете деревья по лесам, вы получаете несортированный список деревьев, и вам нужно найти самое большое. Первое, что вам нужно сделать, это отсортировать строки по их размеру и выбрать первую из списка. Это может показаться неэффективным, но если у вас есть миллионы строк, это будет намного быстрее, чем решения, включающие JOINи WHEREусловия.

Кстати, обратите внимание, что ORDER_BYfor array_aggпредставлен в Postgresql 9.0

Бурак Эмре
источник
У вас ошибка. Вам нужно написать ORDER BY tree_size.size DESC. Также для авторского задания код будет выглядеть так: SELECT usr_id, (array_agg(time_stamp ORDER BY time_stamp DESC))[1] AS timestamp, (array_agg(lives_remaining ORDER BY time_stamp DESC))[1] AS lives_remaining, (array_agg(trans_id ORDER BY time_stamp DESC))[1] AS trans_id FROM lives GROUP BY usr_id
alexkovelsky
2

В Postgressql 9.5 появилась новая опция DISTINCT ON.

SELECT DISTINCT ON (location) location, time, report
    FROM weather_reports
    ORDER BY location, time DESC;

Он удаляет повторяющиеся строки и оставляет только первую строку, как определено в предложении ORDER BY.

см. официальную документацию

Eden
источник
1
SELECT  l.*
FROM    (
        SELECT DISTINCT usr_id
        FROM   lives
        ) lo, lives l
WHERE   l.ctid = (
        SELECT ctid
        FROM   lives li
        WHERE  li.usr_id = lo.usr_id
        ORDER BY
          time_stamp DESC, trans_id DESC
        LIMIT 1
        )

Создание индекса (usr_id, time_stamp, trans_id)значительно улучшит этот запрос.

У вас всегда должно быть что-то PRIMARY KEYв ваших таблицах.

Quassnoi
источник
0

Я думаю, у вас здесь одна серьезная проблема: нет монотонно увеличивающегося «счетчика», чтобы гарантировать, что данная строка возникла позже, чем другая. Вот пример:

timestamp   lives_remaining   user_id   trans_id
10:00       4                 3         5
10:00       5                 3         6
10:00       3                 3         1
10:00       2                 3         2

Вы не можете определить по этим данным, какая запись является самой последней. Это второй или последний? Нет функции sort или max (), которую вы можете применить к любым из этих данных, чтобы дать вам правильный ответ.

Увеличение разрешения отметки времени было бы огромным подспорьем. Поскольку ядро ​​базы данных сериализует запросы, при достаточном разрешении вы можете гарантировать, что никакие две метки времени не будут одинаковыми.

В качестве альтернативы используйте trans_id, который не будет переноситься очень и очень долго. Наличие trans_id, которое переключается, означает, что вы не можете сказать (для той же временной метки), является ли trans_id 6 более поздним, чем trans_id 1, если вы не выполните сложную математику.

Барри Браун
источник
Да, в идеале столбец последовательности (автоинкремента) был бы в порядке.
vladr
Приведенное выше предположение заключалось в том, что при малых временных приращениях trans_id не будет переноситься. Я согласен с тем, что таблице нужен уникальный первичный индекс - например, неповторяющийся trans_id. (PS Я рад, что теперь у меня достаточно очков кармы / репутации, чтобы комментировать!)
Джошуа Берри,
Влад утверждает, что у trans_id довольно короткий цикл, который часто переключается. Даже если вы рассматриваете только две средние строки из моей таблицы (trans_id = 6 и 1), вы все равно не можете сказать, какая из них самая последняя. Следовательно, использование max (trans_id) для данной отметки времени не сработает.
Барри Браун,
Да, я полагаюсь на гарантию автора приложения, что кортеж (time_stamp, trans_id) уникален для данного пользователя. Если это не так, тогда «SELECT l1.usr_id, l1.lives_left, ... FROM ... WHERE ...» должно стать «SELECT l1.usr_id, MAX / MIN (l1.lives_left), ... FROM. .. ГДЕ ... ГРУППА ПО l1.usr_id, ...
vladr
0

Другое решение, которое может оказаться полезным.

SELECT t.*
FROM
    (SELECT
        *,
        ROW_NUMBER() OVER(PARTITION BY usr_id ORDER BY time_stamp DESC) as r
    FROM lives) as t
WHERE t.r = 1
Turbcool
источник