В прошлой статье мини-цикла о работе с агрегатами я рассказывал, как организовать эффективное многопоточное преобразование потока первичных данных в данные агрегированные. Там мы рассматривали задачу "свертки" продаж в агрегаты вида товар/дата/кол-во.
Сегодня мы рассмотрим более сложный вариант, который зачастую начинается со слов "А заказчик захотел…" и приводит нас к иерархичным агрегатам в нескольких одновременных разрезах, которые позволяют нам в СБИС практически мгновенно строить оперативные отчеты в подсистемах организации торговли, бухгалтерского учета и даже управления активными продажами.
Бизнес-требования
уметь быстро получить информацию не только по товарам, но и по складам
в том числе и сводка-TOP продаж товаров на интервале
в том числе с фильтром по складу... или без
а еще график динамики продаж за месяц по дням... и за год по месяцам... и за все время по годам
... и с любым из фильтров склад/товар
... и чтобы все быстро работало!
Итак, вычленяем ключевое для нас относительно предыдущей задачи:
появляется новый разрез агрегации - по складу
необходима агрегация "без учета" или "по всем" - например, сводные продажи всех товаров склада или продажи товара по всем складам
нужны агрегаты для динамики (дневные/месячные/годовые) в разрезе любого фильтра
Структура новых агрегатов
Добавим в таблицу агрегатов новое поле - тип интервала агрегации (D/M/Y) и новый аналитический разрез - склад.
CREATE TABLE agg(
it -- товар
integer
, wh -- склад
integer
, dt -- дата продажи/начала кванта
date
, quant -- тип кванта D/M/Y
"char"
, qty -- количество
double precision
);
Замечу, что тут для экономии размера данных мы использовали однобайтный спецтип "char". Например, такой тип имеет поле relkind (тип объекта) в системной таблице pg_class.
Неудобный NULL и удобный ноль
Для аналитики "по всем" используем значение = 0 (не NULL) соответствующего разреза. Это позволит нам всегда передавать значения в один и тот же запрос, без изменения его модели на … IS [NOT] NULL
.
То есть для запроса "какие товары продавались лучше всего в таком-то месяце" будем использовать запрос вида:
SELECT
it
, qty
FROM
agg
WHERE
wh = $1::integer AND -- передадим 0 для разреза "по всем складам"
(quant, dt) = ($2::"char", $3::date) -- передадим 'M' для обращения к "месячному" агрегату
ORDER BY
qty DESC
LIMIT ...;
А если нам понадобится этот же рейтинг по конкретному складу, то просто передадим сюда же ID склада! Очевидно, для такого запроса подходящим будет индекс (quant, dt, wh, qty DESC)
.
В результате, за единственный Index Scan мы получим сразу все, что хотим, без какой-либо динамической агрегации на моменте получения данных.
Динамика в разрезе фильтра
Давайте теперь сконструируем запрос, который поможет нам нарисовать красивый график по динамике:
SELECT
dt
, qty
FROM
agg
WHERE
(quant, it, wh) = ($1::"char", $2::integer, $3::integer) AND
dt BETWEEN $4::date AND $5::date -- период графика
ORDER BY
dt;
Индекс под него - (quant, it, wh, dt)
. Почему на первом месте именно quant
? Потому что у него очень маленькая селективность, и индекс будет занимать меньше места.
Сборка агрегатов
Итак, вернемся к предыдущей статье и проблемам, которые мы успешно решали там - разделению кросс-блокировок между параллельно работающими потоками. В нынешней ситуации, разделив обработку по ключу (it, wh)
, мы заведомо устраним конфликты между обработчиками.
Но возникнет небольшая проблема - давайте посмотрим, как именно эффективнее всего добиться формирования агрегатов:
При проходе по курсору над flow-таблицей мы формируем в памяти "дифф" для инкремента записей соответствующих агрегатов по обрабатываемому этим потоком ключу (it, wh)
- сразу для каждого из типов интервалов.
Вместе с этим мы вставляем во flow новую "первичку" для последующих "надагрегатов", заменяя нулем каждый из вариантов разрезов анализа.
Задача: "Вскипятить воду в чайнике"
И физик и математик: налить воду в чайник, зажечь плиту, поставить чайник на огонь и подогреть до 100*С.
Новая задача: "Вскипятить воду в чайнике. Чайник уже налит, огонь горит"
Физик: поставить чайник на огонь и подогреть.
Математик: выливаем воду из чайника на плиту - чайник пуст, огонь не горит - задача сведена к предыдущей!
(c) народный анекдот
Понятно, что при последующей обработке такого ключа, содержащего хотя бы один ноль, записи "надагрегатов" формировать уже не нужно.
Обходим блокировки
Единственная точка, в которой у нас могут пересечься два параллельно работающих потока, это формирование/обновление записи queue, соответствующей этому ключу "надагрегата" - например, (0, 0)
.
К счастью, это достаточно просто обходится вставкой новой записи в queue, если pg_try_advisory_xact_lock(it, wh)
для такого ключа вернула нам FALSE
. То есть да, в разрезе ключа распределения записи в очереди могут быть неуникальны. Но в этом нет ничего страшного, потому что они всего лишь выполняют функцию сигнализатора "во flow что-то может быть по этому ключу". И если нет - не страшно, при обработке этой записи очереди мы заглянем во flow, ничего не найдем, и спокойно завершим обработку.
Итого - мы получили в БД все нужные агрегаты во всех требуемых разрезах, которые помогут нам обеспечить быстрый показ отчета/графика в любой комбинации фильтров:
akhkmed
Подскажите, это действительно так, что размер индекса действительно зависит от того, более или менее селективный столбец стоит на 1 месте в списке столбцов индекса?
Контрпример ниже.
То же самое (по размеру), хотя селективность первого столбца отличается в 3333333.[3] раза.
rrrad
Плохой пример. Индексы создаются после заливки данных в таблицу только при загрузке дампов. В остальных случаях, индексы, как правило, живут при вставке и модификации.
dbfiddle.uk/?rdbms=postgres_12&fiddle=c275004b738850408225c20f348df32f
Из личного опыта на 9.6, размер индекса на больших (десятки миллионов строк) таблицах, в которых каждая строка может быть изменена неоднократно, достаточно сильно зависит от порядка полей, при чём выгоднее первым разместить более селективное поле. Вроде, в более поздних версиях что-то делали для оптимизации занимаемого пространства слабоселективных индексов, но не факт, что это нивелирует эффект эксплуатационных накладных расходов.
akhkmed
В Вашем примере есть небольшая неточность, в нём не селективность влияет на размер, а порядок добавления элементов.
Вот контрпример со случайным порядком добавления
Селективность первого столбца отличается в 3325487.[6] раза, но картина вышла противоположная Вашему примеру.
Kilor Автор
PostgreSQL 10:
Как-то так… Замечу, что после
VACUUM FULL
размеры сравняются за счет перебалансировки деревьев — 22487040 (-23%!), но до этого момента BA-дерево явно содержит больше узлов.