Как сделать DISTINCT ON быстрее в PostgreSQL?

13

У меня есть таблица station_logsв базе данных PostgreSQL 9.6:

    Column     |            Type             |    
---------------+-----------------------------+
 id            | bigint                      | bigserial
 station_id    | integer                     | not null
 submitted_at  | timestamp without time zone | 
 level_sensor  | double precision            | 
Indexes:
    "station_logs_pkey" PRIMARY KEY, btree (id)
    "uniq_sid_sat" UNIQUE CONSTRAINT, btree (station_id, submitted_at)

Я пытаюсь получить последнее level_sensorзначение на основе submitted_atкаждого station_id. Есть около 400 уникальных station_idзначений и около 20 тысяч строк в день station_id.

Перед созданием индекса:

EXPLAIN ANALYZE
SELECT DISTINCT ON(station_id) station_id, submitted_at, level_sensor
FROM station_logs ORDER BY station_id, submitted_at DESC;
 Уникальный (стоимость = 4347852.14..4450301.72 строки = 89 ширина = 20) (фактическое время = 22202.080..27619.167 строк = 98 циклов = 1)
   -> Сортировка (стоимость = 4347852.14..4399076.93 строки = 20489916 ширина = 20) (фактическое время = 22202.077..26540.827 строк = 20489812 циклов = 1)
         Ключ сортировки: station_id, submit_at DESC
         Метод сортировки: внешний диск слияния: 681040kB
         -> Seq Scan на station_logs (стоимость = 0.00..598895.16 строк = 20489916 ширина = 20) (фактическое время = 0.023..3443.587 строк = 20489812 циклов = $
 Время планирования: 0,072 мс
 Время выполнения: 27690,644 мс

Создание индекса:

CREATE INDEX station_id__submitted_at ON station_logs(station_id, submitted_at DESC);

После создания индекса для того же запроса:

 Уникальный (стоимость = 0.56..2156367.51 строк = 89 ширина = 20) (фактическое время = 0.184..16263.413 строк = 98 циклов = 1)
   -> Сканирование индекса с использованием station_id__submitted_at для station_logs (стоимость = 0,56..2105142.98 строк = 20489812 ширина = 20) (фактическое время = 0.181..1 $
 Время планирования: 0,206 мс
 Время выполнения: 16263,490 мс

Есть ли способ сделать этот запрос быстрее? Как например, 1 секунда, 16 секунд все еще слишком много.

Kokizzu
источник
2
Сколько существует различных идентификаторов станций, т. Е. Сколько строк возвращает запрос? А какая версия Postgres?
ypercubeᵀᴹ
Postgre 9.6, около 400 уникальных station_id и около 20 тыс. Записей в день на
station_id
Этот запрос возвращает в «последнее значение level_sensor основанное на submitted_at, для каждого station_id». DISTINCT ON включает случайный выбор, за исключением случаев, когда он вам не нужен.
Филипп

Ответы:

18

Только для 400 станций этот запрос будет значительно быстрее:

SELECT s.station_id, l.submitted_at, l.level_sensor
FROM   station s
CROSS  JOIN LATERAL (
   SELECT submitted_at, level_sensor
   FROM   station_logs
   WHERE  station_id = s.station_id
   ORDER  BY submitted_at DESC NULLS LAST
   LIMIT  1
   ) l;

dbfiddle здесь
(сравнение планов для этого запроса, альтернативы Abelisto и вашего оригинала)

В результате, EXPLAIN ANALYZEкак предусмотрено ФП:

 Вложенный цикл (стоимость = 0.56..356.65 строк = 102 ширины = 20) (фактическое время = 0.034..0.979 строк = 98 циклов = 1)
   -> Seq Scan на станциях s (стоимость = 0.00..3.02 строк = 102 ширины = 4) (фактическое время = 0.009..0.016 строк = 102 петли = 1)
   -> Лимит (стоимость = 0.56..3.45 строк = 1 ширина = 16) (фактическое время = 0.009..0.009 строк = 1 цикл = 102)
         -> Сканирование индекса с использованием station_id__submitted_at для station_logs (стоимость = 0,56..664062.38 строк = 230223 ширина = 16) (фактическое время = 0.009 $
               Индекс Cond: (station_id = s.id)
 Время планирования: 0,542 мс
 Время выполнения: 1,013 мс   - !!

Единственный индекс вам нужно , это один созданный Вами station_id__submitted_at. UNIQUEОграничение uniq_sid_satтакже делает работу, в основном. Поддержание обоих кажется пустой тратой дискового пространства и производительностью записи.

Я добавил NULLS LASTк ORDER BYв запросе , потому что submitted_atне определен NOT NULL. В идеале, если применимо !, добавьте NOT NULLограничение к столбцу submitted_at, удалите дополнительный индекс и удалите его NULLS LASTиз запроса.

Если submitted_atвозможно NULL, создайте этот UNIQUEиндекс, чтобы заменить ваш текущий индекс и ограничение уникальности:

CREATE UNIQUE INDEX station_logs_uni ON station_logs(station_id, submitted_at DESC NULLS LAST);

Рассматривать:

Это предполагает отдельную таблицуstation с одной строкой для каждого релевантного station_id(обычно PK) - который вы должны иметь в любом случае. Если у вас его нет, создайте его. Опять же, очень быстро с этой техникой rCTE:

CREATE TABLE station AS
WITH RECURSIVE cte AS (
   (
   SELECT station_id
   FROM   station_logs
   ORDER  BY station_id
   LIMIT  1
   )
   UNION ALL
   SELECT l.station_id
   FROM   cte c
   ,      LATERAL (   
      SELECT station_id
      FROM   station_logs
      WHERE  station_id > c.station_id
      ORDER  BY station_id
      LIMIT  1
      ) l
   )
TABLE cte;

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

Подробные инструкции, объяснения и альтернативы:

Оптимизировать индекс

Ваш запрос должен быть очень быстрым сейчас. Только если вам все еще нужно оптимизировать производительность чтения ...

Возможно, имеет смысл добавить level_sensorпоследний индекс в индекс, чтобы разрешить сканирование только по индексу , как прокомментировал joanolo .
Con: Это делает индекс больше - что добавляет небольшую стоимость для всех запросов, использующих его.
Pro: Если вы на самом деле получаете только сканы индекса, запросу не нужно вообще посещать страницы кучи, что делает его примерно в два раза быстрее. Но это может быть несущественным преимуществом для очень быстрого запроса сейчас.

Однако я не ожидаю, что это сработает для вашего случая. Ты упомянул:

... около 20k строк в день на одного station_id.

Как правило, это будет указывать на непрерывную загрузку записи (1 station_idраз в 5 секунд). И вас интересует последний ряд. Сканирование только по индексу работает только для страниц кучи, видимых для всех транзакций (бит в карте видимости установлен). Вам нужно будет запустить чрезвычайно агрессивные VACUUMнастройки для таблицы, чтобы не отставать от нагрузки записи, и это все равно не будет работать большую часть времени. Если мои предположения верны, сканирование только по индексу отсутствует, не добавляйте level_sensorк индексу.

OTOH, если мои предположения подтвердятся, а ваша таблица станет очень большой , индекс BRIN может помочь. Связанные с:

Или даже более специализированный и более эффективный: частичный индекс только для последних добавлений, чтобы отрезать большую часть ненужных строк:

CREATE INDEX station_id__submitted_at_recent_idx ON station_logs(station_id, submitted_at DESC NULLS LAST)
WHERE submitted_at > '2017-06-24 00:00';

Выберите временную метку, для которой вы знаете, что младшие строки должны существовать. Вы должны добавить WHEREусловие соответствия для всех запросов, например:

...
WHERE  station_id = s.station_id
AND    submitted_at > '2017-06-24 00:00'
...

Вы должны адаптировать индекс и запрос время от времени.
Связанные ответы с более подробной информацией:

Эрвин Брандштеттер
источник
В любое время, когда я знаю, что хочу вложенный цикл (часто), использование LATERAL повышает производительность в ряде ситуаций.
Пол Дрэйпер
6

Попробуйте классическим способом:

create index idx_station_logs__station_id on station_logs(station_id);
create index idx_station_logs__submitted_at on station_logs(submitted_at);

analyse station_logs;

with t as (
  select station_id, max(submitted_at) submitted_at 
  from station_logs 
  group by station_id)
select * 
from t join station_logs l on (
  l.station_id = t.station_id and l.submitted_at = t.submitted_at);

dbfiddle

ОБЪЯСНИТЬ АНАЛИЗ по ThreadStarter

 Nested Loop  (cost=701344.63..702110.58 rows=4 width=155) (actual time=6253.062..6253.544 rows=98 loops=1)
   CTE t
     ->  HashAggregate  (cost=701343.18..701344.07 rows=89 width=12) (actual time=6253.042..6253.069 rows=98 loops=1)
           Group Key: station_logs.station_id
           ->  Seq Scan on station_logs  (cost=0.00..598894.12 rows=20489812 width=12) (actual time=0.034..1841.848 rows=20489812 loop$
   ->  CTE Scan on t  (cost=0.00..1.78 rows=89 width=12) (actual time=6253.047..6253.085 rows=98 loops=1)
   ->  Index Scan using station_id__submitted_at on station_logs l  (cost=0.56..8.58 rows=1 width=143) (actual time=0.004..0.004 rows=$
         Index Cond: ((station_id = t.station_id) AND (submitted_at = t.submitted_at))
 Planning time: 0.542 ms
 Execution time: 6253.701 ms
Abelisto
источник