Оптимизировать запрос GROUP BY для получения последней строки для пользователя

У меня есть следующая таблица журнала для пользовательских сообщений (упрощенная форма) в Postgres 9.2:

CREATE TABLE log (
    log_date DATE,
    user_id  INTEGER,
    payload  INTEGER
);

Он содержит до одной записи на пользователя и в день. Будет около 500 тысяч записей в день в течение 300 дней. Полезная нагрузка постоянно увеличивается для каждого пользователя (если это имеет значение).

Я хочу эффективно получить последнюю запись для каждого пользователя до определенной даты. Мой запрос:

SELECT user_id, max(log_date), max(payload) 
FROM log 
WHERE log_date <= :mydate 
GROUP BY user_id

что очень медленно. Я также попробовал:

SELECT DISTINCT ON(user_id), log_date, payload
FROM log
WHERE log_date <= :mydate
ORDER BY user_id, log_date DESC;

который имеет тот же план и одинаково медленно.

Пока у меня есть один индекс в log(log_date), но это мало помогает.

И у меня есть таблица users со всеми включенными пользователями. Я также хочу получить результат для некоторых пользователей (пользователей с payload > :value).

Есть ли какой-то другой индекс, который я должен использовать, чтобы ускорить это, или любой другой способ достичь того, чего я хочу?

Ответ 1

Для лучшей производительности чтения вам нужен многоколонный индекс:

CREATE INDEX log_combo_idx
ON log (user_id, log_date DESC NULLS LAST);

Чтобы сделать просмотр только индекса возможным, добавьте столбец payload, который в противном случае не нужен, в "покрывающий" индекс с помощью предложения INCLUDE (Postgres 11 или более поздняя версия):

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST) INCLUDE (payload);

См:

Откат для более старых версий:

CREATE INDEX log_combo_covering_idx
ON log (user_id, log_date DESC NULLS LAST, payload);

Почему DESC NULLS LAST?

Для нескольких строк в user_id или небольших таблиц DISTINCT ON обычно самый быстрый и простой:

Для большого количества строк в user_id сканирование с пропуском индекса (или сканирование свободного индекса) (намного) более эффективно. Это не реализовано до Postgres 12 - работа продолжается для Postgres 13. Но есть способы эффективно имитировать.

Стандартные табличные выражения требуют Postgres 8. 4+.
LATERAL требуется Postgres 9. 3+.
Следующие решения выходят за рамки того, что описано в Postgres Wiki.

1. Нет отдельной таблицы с уникальными пользователями

С отдельной таблицей users решения в 2. ниже, как правило, проще и быстрее. Пропустить.

1a. Рекурсивный CTE с соединением LATERAL

WITH RECURSIVE cte AS (
   (                                -- parentheses required
   SELECT user_id, log_date, payload
   FROM   log
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT l.*
   FROM   cte c
   CROSS  JOIN LATERAL (
      SELECT l.user_id, l.log_date, l.payload
      FROM   log l
      WHERE  l.user_id > c.user_id  -- lateral reference
      AND    log_date <= :mydate    -- repeat condition
      ORDER  BY l.user_id, l.log_date DESC NULLS LAST
      LIMIT  1
      ) l
   )
TABLE  cte
ORDER  BY user_id;

Это просто для извлечения произвольных столбцов и, вероятно, лучший в нынешних Postgres. Больше объяснения в главе 2а. ниже.

1б. Рекурсивный CTE с коррелированным подзапросом

WITH RECURSIVE cte AS (
   (                                           -- parentheses required
   SELECT l AS my_row                          -- whole row
   FROM   log l
   WHERE  log_date <= :mydate
   ORDER  BY user_id, log_date DESC NULLS LAST
   LIMIT  1
   )
   UNION ALL
   SELECT (SELECT l                            -- whole row
           FROM   log l
           WHERE  l.user_id > (c.my_row).user_id
           AND    l.log_date <= :mydate        -- repeat condition
           ORDER  BY l.user_id, l.log_date DESC NULLS LAST
           LIMIT  1)
   FROM   cte c
   WHERE  (c.my_row).user_id IS NOT NULL       -- note parentheses
   )
SELECT (my_row).*                              -- decompose row
FROM   cte
WHERE  (my_row).user_id IS NOT NULL
ORDER  BY (my_row).user_id;

Удобно получать один столбец или всю строку. В примере используется весь тип строки таблицы. Возможны и другие варианты.

Чтобы утвердить строку, найденную в предыдущей итерации, протестируйте один столбец NOT NULL (например, первичный ключ).

Более подробное объяснение этого запроса в главе 2b. ниже.

Связанный:

2. С отдельной таблицей users

Расположение таблицы вряд ли имеет значение, поскольку гарантируется ровно одна строка на соответствующий user_id. Пример:

CREATE TABLE users (
   user_id  serial PRIMARY KEY
 , username text NOT NULL
);

В идеале таблица физически сортируется синхронно с таблицей log. См:

Или он достаточно мал (низкая мощность), что вряд ли имеет значение. В противном случае сортировка строк в запросе может помочь в дальнейшей оптимизации производительности. См. дополнение Gang Liang. Если физический порядок сортировки таблицы users совпадает с индексом в log, это может быть неактуально.

2а. LATERAL присоединиться

SELECT u.user_id, l.log_date, l.payload
FROM   users u
CROSS  JOIN LATERAL (
   SELECT l.log_date, l.payload
   FROM   log l
   WHERE  l.user_id = u.user_id         -- lateral reference
   AND    l.log_date <= :mydate
   ORDER  BY l.log_date DESC NULLS LAST
   LIMIT  1
   ) l;

JOIN LATERAL позволяет ссылаться на предыдущие элементы FROM на одном уровне запроса. См:

Результаты поиска по одному индексу (-only) на пользователя.

Не возвращает строки для пользователей, отсутствующих в таблице users. Как правило, ограничение внешнего ключа, обеспечивающее ссылочную целостность, исключает это.

Также, нет строки для пользователей без соответствующей записи в log - в соответствии с исходным вопросом. Чтобы сохранить этих пользователей в результате, используйте LEFT JOIN LATERAL ... ON true вместо CROSS JOIN LATERAL:

Используйте LIMIT n вместо LIMIT 1, чтобы получить более одной строки (но не все) для пользователя.

По сути, все они делают то же самое:

JOIN LATERAL ... ON true
CROSS JOIN LATERAL ...
, LATERAL ...

Последний имеет более низкий приоритет. Явный JOIN связывает перед запятой. Эта тонкая разница может иметь значение при большем количестве таблиц соединений. См:

2b. Коррелированный подзапрос

Хороший выбор для извлечения одного столбца из одной строки. Пример кода:

То же самое возможно для нескольких столбцов, но вам нужно больше умов:

CREATE TEMP TABLE combo (log_date date, payload int);

SELECT user_id, (combo1).*              -- note parentheses
FROM (
   SELECT u.user_id
        , (SELECT (l.log_date, l.payload)::combo
           FROM   log l
           WHERE  l.user_id = u.user_id
           AND    l.log_date <= :mydate
           ORDER  BY l.log_date DESC NULLS LAST
           LIMIT  1) AS combo1
   FROM   users u
   ) sub;
  • Как и LEFT JOIN LATERAL выше, этот вариант включает в себя всех пользователей, даже без записей в log. Вы получаете NULL для combo1, который при необходимости можно легко отфильтровать с помощью предложения WHERE во внешнем запросе.
    Nitpick: во внешнем запросе вы не можете различить, не найден ли подзапрос строкой или все значения столбца оказываются NULL - тот же результат. Вам нужен столбец NOT NULL в подзапросе, чтобы избежать этой неоднозначности.

  • Коррелированный подзапрос может возвращать только одно значение. Вы можете заключить несколько столбцов в составной тип. Но чтобы разложить его позже, Postgres требует хорошо известного составного типа. Анонимные записи могут быть разложены только с помощью списка определений столбцов.
    Используйте зарегистрированный тип, такой как тип строки существующей таблицы. Или зарегистрируйте составной тип явно (и постоянно) с помощью CREATE TYPE. Или создайте временную таблицу (автоматически удаляемую в конце сеанса), чтобы временно зарегистрировать ее тип строки. Синтаксис приведения: (log_date, payload)::combo

  • Наконец, мы не хотим декомпозировать combo1 на одном уровне запроса. Из-за слабости в планировщике запросов это оценило бы подзапрос один раз для каждого столбца (все еще верно в Postgres 12). Вместо этого сделайте его подзапросом и разложите во внешнем запросе.

Связанный:

Демонстрация всех 4 запросов с записями в журнале 100k и пользователями 1k:
db & lt;> скрипка здесь - стр. 11
Старый sqlfiddle - стр. 9,6

Ответ 2

Это не отдельный ответ, а комментарий к ответу @Erwin. Для примера 2a, бокового соединения, запрос может быть улучшен путем сортировки таблицы users чтобы использовать локальность индекса в log.

SELECT u.user_id, l.log_date, l.payload
  FROM (SELECT user_id FROM users ORDER BY user_id) u,
       LATERAL (SELECT log_date, payload
                  FROM log
                 WHERE user_id = u.user_id -- lateral reference
                   AND log_date <= :mydate
              ORDER BY log_date DESC NULLS LAST
                 LIMIT 1) l;

Обоснование состоит в том, что поиск индекса стоит дорого, если значения user_id являются случайными. user_id сначала отсортировать user_id, последующее боковое соединение будет похоже на простое сканирование индекса log. Хотя оба плана запросов выглядят одинаково, время выполнения может сильно отличаться, особенно для больших таблиц.

Стоимость сортировки минимальна, особенно если в поле user_id есть индекс.

Ответ 3

Возможно, поможет другой индекс в таблице. Попробуйте это: log(user_id, log_date). Я не уверен, что Postgres сделает оптимальное использование с distinct on.

Итак, я бы придерживался этого индекса и попробовал эту версию:

select *
from log l
where not exists (select 1
                  from log l2
                  where l2.user_id = l.user_id and
                        l2.log_date <= :mydate and
                        l2.log_date > l.log_date
                 );

Это должно заменить сортировку/группировку поиском по индексу. Это может быть быстрее.