Перевод: CYBERTEC. Настройка производительности: max и group by

PostgreSQLЕщё одно статья от ребят из Cybertec. Пару раз пришлось прочитаь чтобы понять смысл (нет, это я не про сложность языка сейчас). И ещё несколько экспериментов провёл с рекурсивным запросом.

Ссылка на оригинал: Performance tuning: max and group by


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

Оглавление:

  1. Общие вопросы о временных рядах
  2. Поиск наибольшего значения для каждой группы
    1. Итог…

 

Посмотрим на следующие данные:

CREATE TABLE t_sample (
     tstamp           timestamptz, 
     sensor_id        int, 
     measurement      numeric
);                                                                                                                                                                                                                                CREATE TABLE
CREATE TABLE
 
 
INSERT INTO t_sample SELECT x, y, random() * 10000 
     FROM  generate_series('2025-01-01', 
                           '2025-01-06', 
                           '1 second'::interval) AS x,
           generate_series(1, 100) AS y 
     ORDER BY random();
INSERT 0 43200100

Общие вопросы о временных рядах

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

  • Найдите наибольшее значение для каждого датчика

В этой статье мы обсудим, как это можно сделать наиболее эффективно.

Поиск наибольшего значения для каждой группы

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

test=# explain analyze SELECT sensor_id, max(measurement) 
             FROM   t_sample 
             GROUP BY 1 
             ORDER BY 1;
                              QUERY PLAN                                                                        
--------------------------------------------------------------------------
 Finalize GroupAggregate  (cost=546553.97..546579.31 rows=100 width=36) 
       (actual time=3620.266..3621.127 rows=100 loops=1)
   Group Key: sensor_id
   ->  Gather Merge  (cost=546553.97..546577.31 rows=200 width=36) 
              (actual time=3620.260..3621.079 rows=300 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Sort  (cost=545553.95..545554.20 rows=100 width=36) 
                    (actual time=3616.385..3616.388 rows=100 loops=3)
               Sort Key: sensor_id
               Sort Method: quicksort  Memory: 28kB
               Worker 0:  Sort Method: quicksort  Memory: 28kB
               Worker 1:  Sort Method: quicksort  Memory: 28kB
               ->  Partial HashAggregate  (cost=545549.63..545550.63 
                           rows=100 width=36) 
                    (actual time=3616.322..<strong>3616.328</strong> rows=100 loops=3)
                     Group Key: sensor_id
                     Batches: 1  Memory Usage: 32kB
                     Worker 0:  Batches: 1  Memory Usage: 32kB
                     Worker 1:  Batches: 1  Memory Usage: 32kB
                     ->  Parallel Seq Scan on t_sample  
                          (cost=0.00..455549.42 rows=18000042 width=15) 
                          (actual time=0.545..1873.428 rows=14400033 loops=3)
 Planning Time: 0.154 ms
 Execution Time: 3621.199 ms
(18 rows)

А в запросе происходит то, что PostgreSQL прочитает ВСЕ данные и запустит агрегацию. Чтобы ускорить процесс, сервер строит параллельный запрос, использующий несколько ядер процессора одновременно. Однако основная проблема заключается в следующем: нам нужно прочитать все данные. Конечно, это приводит к увеличению времени выполнения, которое продолжает расти по мере увеличения объема данных.

Давайте попробуем создать индекс и посмотрим, изменится ли что-нибудь:

test=# CREATE INDEX ON t_sample (sensor_id, measurement);
CREATE INDEX
 
test=# \d t_sample
                         Table "public.t_sample"
   Column    |           Type           | Collation | Nullable | Default
-------------+--------------------------+-----------+----------+---------
 tstamp      | timestamp with time zone |           |          | 
 sensor_id   | integer                  |           |          | 
 measurement | numeric                  |           |          | 
Indexes:
    "t_sample_sensor_id_measurement_idx" btree (sensor_id, measurement)

Повторный запуск запроса не приведет к улучшению плана выполнения. Способ решения этой проблемы — смоделировать то, что обычно называют «skip scan» (пропуском сканирования). Что это значит? Для каждого значения датчика находим максимум. Система может довольно быстро найти максимальное значение для одного датчика.

test=# explain analyze SELECT max(measurement) 
             FROM   t_sample 
             WHERE  sensor_id = 10;
                                   QUERY PLAN    
-----------------------------------------------------------------------------
 Result  (cost=0.60..0.61 rows=1 width=32) 
       (actual time=0.111..0.112 rows=1 loops=1)
   InitPlan 1 (returns $0)
     ->  Limit  (cost=0.56..0.60 rows=1 width=11) 
                (actual time=0.106..0.107 rows=1 loops=1)
           ->  Index  Only Scan Backward 
                    using t_sample_sensor_id_measurement_idx 
                    on t_sample  (cost=0.56..13321.39 rows=375841 width=11) 
                    (actual time=0.105..0.105 rows=1 loops=1)
                 Index Cond: ((sensor_id = 10) AND (measurement IS NOT NULL))
                 Heap Fetches: 0
 Planning Time: 0.269 ms
 Execution Time: 0.143 ms
(8 rows)

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

В следующем примере показано, как мы можем использовать lateral join для решения проблемы:

test=# explain analyze SELECT * FROM generate_series(1, 100) AS x, 
              LATERAL (SELECT max(measurement) FROM t_sample WHERE sensor_id = x);
                               QUERY PLAN    
---------------------------------------------------------------------------
 Nested Loop  (cost=0.60..63.05 rows=100 width=36) 
              (actual time=2.656..5.002 rows=100 loops=1)
   ->  Function Scan on generate_series x  
             (cost=0.00..1.00 rows=100 width=4) 
             (actual time=2.520..2.532 rows=100 loops=1)
   ->  Result  (cost=0.60..0.61 rows=1 width=32) 
               (actual time=0.024..0.024 rows=1 loops=100)
         InitPlan 1 (returns $1)
           ->  Limit  (cost=0.56..0.60 rows=1 width=11) 
                      (actual time=0.023..0.023 rows=1 loops=100)
                 ->   Index Only Scan Backward 
                           using t_sample_sensor_id_measurement_idx 
                           on t_sample  (cost=0.56..15312.59 rows=432001 width=11)
                           (actual time=0.023..0.023 rows=1 loops=100)
                       Index Cond: ((sensor_id = x.x) 
                          AND (measurement IS NOT NULL))
                       Heap Fetches: 0
 Planning Time: 0.481 ms
 Execution Time: 5.124 ms
(10 rows)

Что такое LATERAL? Давайте отвлечёмся и посмотрим на SQL с философской точки зрения:

SELECT whatever FROM foo;

В процедурном коде это будет иметь вид:

foreach x in foo
loop
       do_whatever
end loop;

Предложение FROM можно рассматривать как своего рода цикл. Итак: если мы хотим найти максимум для списка значений, нам нужен цикл вокруг этого цикла. Это именно то, что LATERAL и делает: для каждого значения, возвращаемого вызовом gener_series (= генерация списка датчиков), мы запускаем LATERAL-часть запроса. Проблема в том, что мы генерируем список датчиков, что делает запрос слишком «статичным», поэтому лучше получить список датчиков из второго отношения (нормализации).

Однако мы можем добиться того же, используя простую рекурсию:

test=# explain analyze 
WITH RECURSIVE x AS ( 
       SELECT       min(sensor_id) AS s
       FROM         t_sample
       UNION ALL
       SELECT       (SELECT min(sensor_id)
       FROM         t_sample
       WHERE  sensor_id > x.s)                                                                                                                                                                                                                                                                                        
       FROM x                                                                                                                                                                                                                                                                                                                
       WHERE x.s IS NOT NULL
)
SELECT s AS sensor_id,
               (SELECT        max(measurement)
               FROM     t_sample
               WHERE          t_sample.sensor_id = x.s) AS measurement
FROM          x
WHERE  s IS NOT NULL;
                               QUERY PLAN    
----------------------------------------------------------------- 
 CTE Scan on x  (cost=64.66..127.73 rows=100 width=36) 
       (actual time=0.103..2.410 rows=100 loops=1)
   Filter: (s IS NOT NULL)
   Rows Removed by Filter: 1
   CTE x
    ->  Recursive Union (cost=0.60..64.66 rows=101 width=4)
                        (actual time=0.060..0.838 rows=101 loops=1)
           ->  Result (cost=0.60..0.61 rows=1 width=4) 
                      (actual time=0.058..0.059 rows=1 loops=1)
                 InitPlan 3 (returns $1)
                   ->  Limit  (cost=0.56..0.60 rows=1 width=4) 
                              (actual time=0.051..0.052 rows=1 loops=1)
                         ->  Index Only Scan 
                                using t_sample_sensor_id_measurement_idx 
                                on t_sample t_sample_1  
                                   (cost=0.56..1423086.31 rows=43200100 width=4)
                                   (actual time=0.049..0.050 rows=1 loops=1)
                               Index Cond: (sensor_id IS NOT NULL)
                               Heap Fetches: 0
           ->  WorkTable Scan on x x_1  
                    (cost=0.00..6.30 rows=10 width=4) 
                    (actual time=0.007..0.007 rows=1 loops=101)
                 Filter: (s IS NOT NULL)
                 Rows Removed by Filter: 0
                 SubPlan 2
                   ->  Result (cost=0.60..0.61 rows=1 width=4) 
                                (actual time=0.007..0.007 rows=1 loops=100)
                         InitPlan 1 (returns $3)
                           ->  Limit  
                                    (cost=0.56..0.60 rows=1 width=4) 
                                    (actual time=0.006..0.006 rows=1 loops=100)
                                 -> Index Only Scan 
                                     using t_sample_sensor_id_measurement_idx 
                                     on t_sample  
                                     (cost=0.56..510365.23 rows=14400033 width=4)
                                     (actual time=0.006..0.006 rows=1 loops=100)
                                     Index Cond: ((sensor_id IS NOT NULL) 
                                        AND (sensor_id > x_1.s))
                                       Heap Fetches: 0
   SubPlan 6
     ->  Result  (cost=0.60..0.61 rows=1 width=32) 
                 (actual time=0.015..0.015 rows=1 loops=100)
           InitPlan 5 (returns $6)
             ->  Limit  (cost=0.56..0.60 rows=1 width=11) 
                        (actual time=0.015..0.015 rows=1 loops=100)
                   ->  Index Only Scan Backward 
                           using t_sample_sensor_id_measurement_idx 
                           on t_sample t_sample_2  
                         (cost=0.56..15312.59 rows=432001 width=11) 
                         (actual time=0.014..0.014 rows=1 loops=100)
                        Index Cond: ((sensor_id = x.s) 
                                  AND (measurement IS NOT NULL))
                         Heap Fetches: 0
 Planning Time: 0.446 ms
 Execution Time: 2.495 ms
(30 rows)

Основная идея: мы находим наименьший sensor_id — это что очень быстро. Затем мы ищем следующее наименьшее значение, больше, чем то, которое мы нашли ранее. Мы продолжаем делать это в рекурсии, пока быстро не составим уникальный список sensor_id. Рекурсия является самым быстрым способом найти уникальный список sensor_id без необходимости читать всю таблицу. По сути, это и есть имитация «skip scan», упомянутого ранее. Наконец, мы можем использовать этот список уникальных IDS датчиков («x»), чтобы найти максимальное значение для каждого из них, что, конечно же, представляет собой быстрый поиск по индексу.

Итог…

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

Hans-Jürgen Schönig 2024

Ханс-Юрген Шёниг (Hans-Jürgen Schönig)

Ханс-Юрген Шениг имеет опыт работы с PostgreSQL с 90-х годов. Он является генеральным директором и техническим руководителем компании CYBERTEC, которая является одним из лидеров рынка в этой области и с 2000 года обслуживает бесчисленное количество клиентов по всему миру.


Ещё раз ссылка на оригинал: Performance tuning: max and group by

Кстати, для интереса — можете сравнить эти два варианта с помощью вывода прочитанных страниц (BUFFERS).


Be the first to comment

Leave a Reply

Ваш Mail не будет опубликован.


*