Как стать автором
Обновить
383.11
Тензор
Разработчик системы СБИС

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

Уровень сложностиПростой
Время на прочтение3 мин
Количество просмотров4.5K

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

Теги:
Хабы:
Всего голосов 11: ↑11 и ↓0+11
Комментарии14

Публикации

Информация

Сайт
sbis.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия