SQL HowTo: TOP-N на субинтервалах

от автора

Периодически сталкиваюсь с однотипными задачами вида «показать 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!), да и план не слишком страшный получился.


ссылка на оригинал статьи https://habr.com/ru/articles/776834/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *