Периодически сталкиваюсь с однотипными задачами вида "показать TOP-N позиций на каждом из вложенных интервалов некоторого периода".

Это может быть "5 лучших по успеваемости студентов в каждом семестре за последний учебный год", или "помесячная динамика позиции 10 наиболее продающихся товаров", или, как у нас в сервисе визуализации PostgreSQL-планов explain.tensor.ru, "3 наиболее активных страны за каждый день":

"Все флаги в гости будут к нам"
"Все флаги в гости будут к нам"

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

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

CREATE TABLE fact4agg AS
  SELECT
    now()::date - (random() * 365)::integer dt      -- дата "факта"
  , chr(ascii('a') + (random() * 26)::integer) code -- код агрегации
  FROM
    generate_series(1, 1e6);

Раз выборку мы будем делать "за период", то индекс по дате нам точно пригодится:

CREATE INDEX ON fact4agg(dt);

Генерация субинтервалов

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

Конечно, подобные пропуски можно будет устранить и на бизнес-логике, но она для этого все-таки приспособлена не слишком хорошо, поэтому сделаем это сразу на БД с помощью функции generate_series, получив серию дат:

WITH params(dtb, dte) AS (
  VALUES(now()::date - 30, now()::date)
)
SELECT
  dt::date
FROM
  params
, generate_series(dtb, dte, '1 day') dt;

Небольшой нюанс здесь заключается лишь в том, что эта функция выдает timestamp, а нам необходим тип date. Если вам требуется другой интервал - например, неделя или месяц - просто задайте другой шаг: '1 week' или '1 month'.

Первичный отбор данных

Ничего сильно хитрее, чем "прямо взять и посчитать", для вычисления агрегатов (если мы их нигде не храним в отдельной таблице, как это описано в серии статей про агрегаты в БД) применить не выйдет:

WITH params(dtb, dte) AS (
  VALUES(now()::date - 30, now()::date)
)
SELECT
	dt
,	code
,	count(*) qty
FROM
	params
,	fact4agg
WHERE
	dt BETWEEN dtb AND dte
GROUP BY
	1, 2;

К счастью, наличие индекса позволяет нам отобрать и сгруппировать все необходимые данные достаточно быстро - всего за 65ms:

Index Scan
Index Scan

Но если мы поменяем наш индекс на более подходящий для Index Only Scan, тот же запрос окажется вдвое быстрее и в 200 раз более эффективным по объему вычитываемых данных!

DROP INDEX fact4agg_dt_idx;

CREATE INDEX ON fact4agg(dt)
  INCLUDE(code);
Index Only Scan
Index Only Scan

Отбираем TOP-3

Поскольку мы условились, что делать на бизнес-логике несвойственные ей операции мы не хотим, то договоримся, что 3 "лидирующих" записи за каждый день мы отдадим одним json.

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

WITH params(dtb, dte) AS (
  VALUES(now()::date - 30, now()::date)
)
SELECT
  dt
, json_agg(
    json_build_object('code', code, 'qty', qty)
    ORDER BY qty DESC -- "сворачиваем" в json[] с нужной сортировкой
  )
FROM
  (
    SELECT
      *
    , row_number() OVER(PARTITION BY dt ORDER BY qty DESC) rn -- нумеруем в нужном порядке
    FROM
      (
        SELECT
          dt
        , code
        , count(*) qty
        FROM
          params
        , fact4agg
        WHERE
          dt BETWEEN dtb AND dte
        GROUP BY
          1, 2
      ) T
  ) T
WHERE
  rn <= 3 -- отбираем только 3 первых
GROUP BY
  1;

Но давайте обратим внимание, что мы сортировали данные тут, минимум, дважды - сначала при нумерации, а потом - при свертке в json. Нельзя ли этого избежать?..

Оказывается, можно, если сразу все "сворачивать в массив", и уже от него брать 3 первых элемента. Но не все так просто...

ERROR:  cannot subscript type json because it does not support subscripting

Нельзя просто так взять сегмент из json-массива, зато из массива json'ов - можно:

WITH params(dtb, dte) AS (
  VALUES(now()::date - 30, now()::date)
)
SELECT
  dt
, to_json(
    (
      array_agg(
        to_jsonb(T) - 'dt' -- свернули всю строку в jsonb и удалили лишний ключ
        ORDER BY qty DESC  -- сортируем в нужном порядке
      )                    -- свернули в jsonb[]
    )[:3]                  -- взяли 3 первых элемента
  ) json                   -- сконвертировали в "обычный" json
FROM
  (
    SELECT
      dt
    , code
    , count(*) qty
    FROM
      params
    , fact4agg
    WHERE
      dt BETWEEN dtb AND dte
    GROUP BY
      1, 2
  ) T
GROUP BY
  1;

Тут мы вместо перечисления всех полей в результирующем json просто "свернули" всю строку в jsonb и удалили в нем избыточный ключ dt.

Затем "свернули" все в jsonb[], взяли от него первые 3 элемента и превратили в "обычный" json-массив.

Итоговый запрос

Остается лишь собрать генерацию субинтервалов и агрегаты в единый запрос:

WITH params(dtb, dte) AS (
  VALUES(now()::date - 30, now()::date)
)
SELECT
  *
FROM
  (
    SELECT
      dt::date
    FROM
      params
    , generate_series(dtb, dte, '1 day') dt
  ) X
LEFT JOIN
  (
    SELECT
      dt
    , to_json(
        (
          array_agg(
            to_jsonb(T) - 'dt'
            ORDER BY qty DESC
          )
        )[:3]
      ) json
    FROM
      (
        SELECT
          dt
        , code
        , count(*) qty
        FROM
          params
        , fact4agg
        WHERE
          dt BETWEEN dtb AND dte
        GROUP BY
          1, 2
      ) T
    GROUP BY
      1
  ) Y
    USING(dt)
ORDER BY
  dt;

Убедимся фактическим планом, что мы своими агрегатами ничего сильно не попортили:

Всего 42ms (даже меньше, чем весь отбор данных при Index Scan!), да и план не слишком страшный получился.

Комментарии (14)


  1. Akina
    28.11.2023 09:13
    +1

    Если Вы хотите показать пути решения задачи и его оптимизации, то сначала надо как минимум зафиксировать исходные данные и конечный результат. Причём для конечного результата следует зафиксировать и набор полей, и типы данных. Произвольно "договариваться" на середине пути о смене формата вывода - неправильно.


    1. Kilor Автор
      28.11.2023 09:13
      +1

      Если мы "рулим" обеими сторонами взаимодействия BL <--> DB, то "договориться" можем в любой момент.

      Можно ли не сворачивать в json? Конечно, и "отсыпать" TOP-3 за одну дату несколькими записями. Но тогда "агрегировать" в рамках дня придется уже на стороне бизнес-логики, а оно там надо?..


  1. ptr128
    28.11.2023 09:13
    +1

    В случае, когда code всего 27, а записей - миллионы, эффективней идти полностью по индексу. Пример сделал на 10 миллионах, так как на миллионе он не столь явно различается:

    DROP TABLE IF EXISTS fact4agg;
    CREATE TABLE fact4agg AS
      SELECT
        now()::date - (random() * 365)::integer dt      -- дата "факта"
      , chr(ascii('a') + (random() * 26)::integer) code -- код агрегации
      FROM
        generate_series(1, 1e7);
    CREATE INDEX fact4agg_dt_code_Idx ON fact4agg(dt) INCLUDE(code);
    CREATE INDEX fact4agg_code_dt_Idx ON fact4agg(code, dt);

    Сначала Ваш запрос:

    DROP TABLE IF EXISTS tmp_tmp;
    EXPLAIN ANALYZE
    CREATE TEMP TABLE tmp_tmp AS
    WITH params(dtb, dte) AS (
      VALUES(now()::date - 30, now()::date)
    )
    SELECT
      dt
    , code
    , count(*) qty
    FROM
      params
    , fact4agg
    WHERE
      dt BETWEEN dtb AND dte
    GROUP BY
      1, 2;
    
    HashAggregate  (cost=25677.39..25776.21 rows=9882 width=14) (actual time=219.549..219.739 rows=837 loops=1)
      Group Key: fact4agg.dt, fact4agg.code
      Batches: 1  Memory Usage: 529kB
      ->  Index Only Scan using fact4agg_dt_code_idx on fact4agg  (cost=0.45..19367.89 rows=841267 width=6) (actual time=0.025..89.172 rows=836228 loops=1)
            Index Cond: ((dt >= ((now())::date - 30)) AND (dt <= (now())::date))
            Heap Fetches: 0
    Planning Time: 0.128 ms
    Execution Time: 221.081 ms

    А теперь через code:

    DROP TABLE IF EXISTS tmp_tmp;
    EXPLAIN ANALYZE
    CREATE TEMP TABLE tmp_tmp AS
    WITH Codes AS (
      SELECT chr(ascii('a') + V.n) AS code
      FROM generate_series(0, 26) V(n) ),
    params(dtb, dte) AS (
      VALUES(now()::date - 30, now()::date)
    )
    SELECT Q.dt, C.code, Q.qty
    FROM Codes C
    CROSS JOIN params P
    CROSS JOIN LATERAL (
      SELECT F.dt, COUNT(1) AS qty
      FROM fact4agg F
      WHERE F.code=C.code AND F.dt BETWEEN P.dtb AND P.dte
      GROUP BY F.dt ) Q;
    
    Nested Loop  (cost=0.46..24324.77 rows=9882 width=44) (actual time=0.114..98.558 rows=837 loops=1)
      ->  Function Scan on generate_series v  (cost=0.00..0.27 rows=27 width=4) (actual time=0.005..0.010 rows=27 loops=1)
      ->  GroupAggregate  (cost=0.45..891.76 rows=366 width=12) (actual time=0.129..3.644 rows=31 loops=27)
            Group Key: f.dt
            ->  Index Only Scan using fact4agg_code_dt_idx on fact4agg f  (cost=0.45..732.31 rows=31158 width=4) (actual time=0.008..1.876 rows=30971 loops=27)
                  Index Cond: ((code = chr((97 + v.n))) AND (dt >= ((now())::date - 30)) AND (dt <= (now())::date))
                  Heap Fetches: 0
    Planning Time: 0.193 ms
    Execution Time: 99.835 ms


    1. Kilor Автор
      28.11.2023 09:13
      +1

      Вполне правильный подход при заведомо малом количестве вариантов - чем-то напоминает "рекурсивный DISTINCT", хотя в общем случае закладываться на такое не выйдет.


      1. ptr128
        28.11.2023 09:13

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

        На практике такой подход хорошо работает даже для 100 тыс. полувагонов и полутора миллиардах событий для них. То есть, при отношении 1/10000 этот подход еще остается эффективным.

        Все же, с точки зрения БД, один уровень группировки из двух ей преподносится уже готовым - не требуется считать хеши для HashAggregate.

        в общем случае закладываться на такое не выйдет

        В общем случае лучше ни на что не закладываться. "Золотого молотка" не бывает )


  1. ptr128
    28.11.2023 09:13

    Обратил внимание, что у Вас не честные TOP 3. Если у каких-то двух code одинаковый qty в какой-то день и оба на третьем месте, то Вы случайным образом одного из них выкидываете. В реальной жизни люди так могут и обидеться.

    Корректней выводить с учётом того, что какое-то место могут поделить. То есть делать выборку через DENSE_RANK():

    WITH Codes AS (
      SELECT chr(ascii('a') + V.n) AS code
      FROM generate_series(0, 26) V(n) ),
    params(dtb, dte) AS (
      VALUES(now()::date - 30, now()::date) ),
    Aggregated AS (
      SELECT Q.dt, C.code, Q.qty,
        DENSE_RANK() OVER (PARTITION BY Q.dt ORDER BY Q.qty DESC) AS dr
      FROM Codes C
      CROSS JOIN params P
      CROSS JOIN LATERAL (
        SELECT F.dt, COUNT(1) AS qty
        FROM fact4agg F
        WHERE F.code=C.code AND F.dt BETWEEN P.dtb AND P.dte
        GROUP BY F.dt ) Q )
    SELECT
      A.dt,
      json_agg(
        json_build_object('rank', A.dr, 'code', A.code, 'qty', A.qty)
        ORDER BY A.qty DESC ) json
    FROM Aggregated A
    WHERE A.dr<=3
    GROUP BY A.dt;

    Но давайте обратим внимание, что мы сортировали данные тут, минимум, дважды - сначала при нумерации, а потом - при свертке в json. Нельзя ли этого избежать?..

    Эту проблему PostgreSQL решил самостоятельно:

    GroupAggregate  (cost=24931.06..25353.55 rows=200 width=36) (actual time=98.812..99.057 rows=31 loops=1)
      Group Key: f.dt
      ->  WindowAgg  (cost=24931.06..25178.11 rows=9882 width=52) (actual time=98.774..98.913 rows=96 loops=1)
            Run Condition: (dense_rank() OVER (?) <= 3)
            ->  Sort  (cost=24931.06..24955.77 rows=9882 width=16) (actual time=98.761..98.799 rows=837 loops=1)
                  Sort Key: f.dt, (count(1)) DESC
                  Sort Method: quicksort  Memory: 77kB
                  ->  Nested Loop  (cost=0.46..24275.36 rows=9882 width=16) (actual time=0.122..98.513 rows=837 loops=1)
                        ->  Function Scan on generate_series v  (cost=0.00..0.27 rows=27 width=4) (actual time=0.006..0.011 rows=27 loops=1)
                        ->  GroupAggregate  (cost=0.45..891.76 rows=366 width=12) (actual time=0.133..3.645 rows=31 loops=27)
                              Group Key: f.dt
                              ->  Index Only Scan using fact4agg_code_dt_idx on fact4agg f  (cost=0.45..732.31 rows=31158 width=4) (actual time=0.011..1.881 rows=30971 loops=27)
                                    Index Cond: ((code = chr((97 + v.n))) AND (dt >= ((now())::date - 30)) AND (dt <= (now())::date))
                                    Heap Fetches: 0
    Planning Time: 0.284 ms
    Execution Time: 99.110 ms

    Это я опять на 10 миллионах развлекался.


    1. Kilor Автор
      28.11.2023 09:13

      Таки да, поэтому в реальности для обеспечения стабильности порядка сортировки приходится сортировать по ФИО, коду страны, дополнительным очкам... но если очень хочется "не обидеть", можно использовать FETCH WITH TIES.


      1. ptr128
        28.11.2023 09:13

        можно использовать FETCH WITH TIES

        Можно. Но лучше все же не выбирать в БД записи, которые потом точно не собираетесь фетчить. DENSE_RANK() у меня даже стабильно несколько миллисекунд выигрывал, относительно Вашего решения через массив.


        1. Kilor Автор
          28.11.2023 09:13

          Они фетчатся уже в узле Index Only Scan, если мы считаем агрегаты динамически, а не храним. А если храним - то вычитаем лишь на 1 запись больше указанного лимита.


          1. ptr128
            28.11.2023 09:13

            Посмотрите внимательней на мой запрос. Даже первое место могут поделить не только двое, но и трое, и четверо. Аналогично и со вторым местом. А WITH TIES добавит дубли только одного последнего в выборке места. Что получится, если у Вас первое место поделили четверо? А DENSE_RANK() это отрабатывает честно.


            1. Kilor Автор
              28.11.2023 09:13

              В этом смысле есть разные прикладные задачи.

              "10 дипломов первой степени, 20 - второй и 30 - третьей" - это как раз про dense_rank.

              "Иванов и Петров поделили 1/2 место, а Сидоров с Васечкиным - 3/4" - про WITH TIES.


              1. ptr128
                28.11.2023 09:13

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


                1. Kilor Автор
                  28.11.2023 09:13

                  Первый вариант - это что-то типа олимпиады по информатике: "кто решил максимум задач - диплом первой степени, на одну меньше - второй".

                  А второй вариант - это спортивные соревнования "за место" обычно.


                  1. ptr128
                    28.11.2023 09:13

                    А второй вариант - это спортивные соревнования "за место" обычно.

                    Обычно, так не делается. Например, в легкой атлетике:

                    Если два (или более) участника показали одинаковый результат, то рассматривается второй результат, независимо от попытки, если и он одинаков, то — третий результат и так до выявления преимущества одного из участников. Если все показатели у них одинаковы, то им дается дополнительная попытка для выявления победителя.