Современный подход к DWH пестрит различными шаблонами проектирования. Но увы, большинство людей, кто занимается непосредственной разработкой и внедрением этих подходов едут по рельсам и боятся сделать шаг в сторону. Не все - в конце концов в ИТ огромное число креативщиков - но многие.
В этой статье, я в полу шуточной манере, хотел бы поразмышлять на тему того, что шаблон - это лишь шаблон, а не руководство к действию.
Data Vault выбран абсолютно случайно и только ради демонстрации, что любой подход переносим и адаптируем. Главное правильно определять ограничения, сильные и слабые стороны реализаций.
Давайте перейдем к терминам программирования. Что такое Data Vault? Инструмент, технология или паттерн? Правильный ответ конечно же - паттерн. Ведь авторы данного термина не принесли на рынок готового продукта или инструментов для реализации подхода - они появились позже - а подарили миру свод правил, парадигм.
до какой степени нужно нормализовать данные, чтобы вы могли считать свою модель Data Vault
определили жесткие ограничения по построению модели
выделили основные элементы модели и возможные связи между ними
А уже тот факт, что большинство строят эти модели на реляционных БД со строчной структурой хранения данных - это выбор специалиста, реализующего шаблон. Но никак не строгое правило или ограничение. Ну и плюс не было такого обилия иных типов баз, когда подход зарождался.
Бизнес модель
Никогда не мечтали заняться экзотическим бизнесом? А мы вот сейчас и займемся.
Волей судьбы вам досталась в наследство успешная франшиза по разведению кроликов по всему миру. Сотни тысяч ферм разного масштаба. Миллиарды животных. Огромная реферальная сеть. Ваш дед был настолько продвинутым, что смог запустить цепную реакцию и теперь система растет сама по себе, привлекая все новых и новых участников. Но увы, дед, помимо любви к животным, был заядлым фронт-end разработчиком и БД для него была лишь средством сохранения данных о своей сети. И не более.
Но вы. Вы - другое дело. Посмотрев на все это богатство вы сразу поняли, что перед вами открываются новые горизонты. Как например - открытие маркетплейса для торговли мясом или живыми животными. Вы можете интегрировать рынок сбыта в свою модель и сделать еще много и много чего. А что для этого нужно? Правильно, для этого нужна аналитика. Мощная и точная.
Поэтому мы постараемся начать строить на базе ИТ-инфраструктуры, доставшейся от деда, систему данных, как продукта. Который сможем сами же и использовать для нового этапа роста всей компании.
Задача
Что нужно для начала. Выделить основную цель. Итак - первым делом мы хотим начать торговать животными или их мясом. Продукт специфичный и реализовать его можно только на внутреннем городском рынке. Дальше возить не хочется. Да и незачем, ведь наша сеть настолько масштабна, что есть практически везде. Зачем заморачиваться еще и с логистикой, которая сама по себе является крайне сложным вопросом.
Ну и чтобы не превращать статью в огромное скучное чтиво, ставим себе задачу с нулевым приоритетом:
Построить масштабируемую модель данных, которая будет способна максимально быстро принимать информацию из нашей процессинговой системы и фиксировать ее в хранилище.
Первым этапом аналитики, мы должны иметь возможность быстро (~ 10 секунд) понять, какие животные и в каком количестве есть в каждом конкретном городе.
Также быстро (~10 секунд) найти фермы, которые привлекли больше всего участников. Либо их рефералы наиболее эффективны - имеют наибольшее поголовье животных.
Построение модели
Так как вы любите порядок и терпеть не можете большой избыточности данных, выбор пал на концепцию Data Vault. Первым делом конечно же сядем и нарисуем модель, которую всегда можно будет расширить и которая позволит нам выполнить задачу минимум.
Немного поразмыслив мы выделили следующие Хабы:
Животные. Пусть будут именно животные. А вдруг мы решим разводить ондатр, где то слышал, что их шкурки в цене. Добавим атрибут тип в будущем и будем радоваться гибкости системы.
Фермы. Ну куда же без них.
Города. Мы же решили строить районно-ориентированный бизнес. А регионы и прочие структуры выше по уровню, всегда можно будет добавить позже.
Теперь нужно связать данные Хабы и накинуть минимум атрибутов. Перед этим выясним ответы на ряд вопросов у бизнеса (внимание, далее - сферический конь в вакууме):
Кролики в системе идентифицированы? - Конечно, нам с каждой фермы отправляют данные о рождении, смерти и продаже животного. Это условие франчайзинга.
А как идентифицируются кролики в системе? - Ну это номер фермы + номер кролика на этой ферме. Мы присваиваем его каждому животному, когда пользователь вносит данные в личном кабинете.
Имеются ли сведения о родстве? - Конечно. Всегда указывается мать - они живут отдельно до родов. А отец - ну фермеры не следят за половой жизнью своих животных.
В реферальной подсистеме тоже хранится информация о том, кто кого привлек? - Ну не то, чтобы это подсистема. Но когда мы фиксируем новый договор, то всегда указывается ферма, привлёкшая нового участника.
Немого порисовав в первом подвернувшемся средстве, мы получили нечто похожее на точку роста для нашего Data Vault.
Из основных аспектов выделим следующие:
рекурсивные родственные связи кроликов и реферальную зависимость ферм связали через Линки
в атрибуты животных и городов добавили пару неизменяемых параметров (название города условно неизменяемое), чтобы можно было провести минимальный пилотный пуск
цвет кролика вынесли как атрибут для упрощения, в реальных условиях это был бы конечно отдельный Хаб, а может быть даже несколько, так как цвета - это тоже целая наука. Ну и по хорошему цвет должен быть слабо изменяемым атрибутом, так как некоторые особи могут сменить его после первой линьки, и не каждый фермер может сразу определить цвет точно.
статус животного - это его актуальное состояние, причем изменяемое - продан/продан живым/жив/умер естественной смертью. Поэтому выносим в отдельный сателлит. Разницу 'продан' и 'продан живым' пояснять не будем. Чтобы не выглядеть слишком жестокими. Статус - это тоже в идеале отдельный Хаб. Но мы упрощаем ради статьи.
хэш-ключи, время загрузки и источник - исключительно для того, чтобы иметь возможность масштабироваться и менять техническую базу. Ведь модель это универсальная сущность и ее можно реализовать где угодно. Даже уехать в Data Lake при лавинообразном росте данных. У деда там столько всего интересного в его БД - годами разбираться. Даже фотографии с конкурса 'Кролик года' имеются.
Реализация модели
Засучив рукава вы стали думать на каких технологиях строить свою модель. Волею судьбы выбор пал на ClickHouse.
Колоночная структура позволяет осуществлять молниеносную аналитику по огромным массивам данных
Быстрое добавление данных с использованием различных форматов - от прямых вставок до json
Достаточно удобные и быстрые механизмы обновления по партициям, все таки данные в прошлом обновляются и неплохо было бы перегружать последние пару месяцев хотя бы раз в сутки. Более подробно можно почитать тут: Партицирование как средство быстрого обновления данных
Итак. Остается раздобыть себе машину на Ubuntu, выбить доступы к процессинговой базе и можно приступать. Сказано - сделано. Через пару часов беготни по офису и общения с админами, вы сидите перед терминалом и устанавливаете ClickHouse для своего пилота. Готово.
Приступаем. Для начала создадим нашу маленькую модель физически - в виде реальных таблиц. Неплохо было бы конечно ее перенести в какое-то средство моделирования с возможностью генерации кода под разные БД. Но на этапе первичной проработки лучше все сделать руками, чтобы учесть нюансы, которые могут всплыть, когда наше детище уйдет на прод.
SQL. Создание таблиц
-- drop table h_animals
CREATE TABLE h_animals
(
animal_num String,
animal_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table l_animal_tree
CREATE TABLE l_animal_tree
(
animal_hsh FixedString(64),
animal_mother_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table s_animal_lifecycle
CREATE TABLE s_animal_lifecycle
(
animal_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
status String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table s_animal_attrs
CREATE TABLE s_animal_attrs
(
animal_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
sex String,
color String,
birthdate date,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table h_farms
CREATE TABLE h_farms
(
farm_num String,
farm_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table l_animal_farms
CREATE TABLE l_animal_farms
(
animal_hsh FixedString(64),
farm_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table l_farm_referals
CREATE TABLE l_farm_referals
(
attract_farm_hsh FixedString(64),
ref_farm_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table h_cities
CREATE TABLE h_cities
(
city_code String,
city_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table l_city_farms
CREATE TABLE l_city_farms
(
city_hsh FixedString(64),
farm_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
-- drop table s_city_attrs
CREATE TABLE s_city_attrs
(
city_hsh FixedString(64),
timestamp_ DateTime,
source_ String,
name_ String,
) ENGINE=MergeTree()
PARTITION BY (toYYYYMM(timestamp_))
ORDER BY (timestamp_);
Ну и далее вы наполняете физическую модель данными. По легенде, их вы выгружаете с прода, преобразуете в любой удобный формат и разово импортируете в ClickHouse. ETL на базе Airflow или любой другой технологии можно будет реализовывать позже, когда модель пройдет первичные испытания и будет представлена бизнесу на оценку.
Тут уже будет потрачено немного больше времени. Но вы любите свою работу, поэтому с первыми лучами солнца с радостью выполняете первые Select из таблиц.
Ох и народилось же ушастых у деда.
SQL. Проверяем данные
select count(1) as cnt from h_farms;
select count(1) as cnt from h_animals;
select * from h_animals;
На самом деле, данные я конечно же генерировал сам. Причем ровно столько, сколько позволил мой маленький SSD на домашней машине. Процесс данный оказался настолько занимательным, что параллельно родилась еще одна статья - Golang-генератор TSV для загрузки в ClickHouse. Также данную статью будет интересно почитать тем, кто захочет понять, откуда взялись города Суровый Хряк и Томный Тапок (см. скриншоты ниже).
JOIN IT!
Я не просто так дал такое странное название данной главе. Уверен что многие, кто имел дело с Data Vault, ещё с первых строк думали - ну что за псих. Данный концепт в принципе создан для первичного слоя данных, а не для аналитики, так как выборки не очень быстрые и требуют огромного количества операций соединения. Так он ещё умудрился засунуть хранилище в колоночную СУБД. Ну бред же.
И тут мы вернёмся к началу статьи. Data Vault - это паттерн. И в нем не написано - пиши join чтобы делать выборки из хранилища.
Давайте на мгновенье подумаем в контексте DWH? Как часто вы видите - NESTED LOOP и прочие соединения с непрямым доступом к данным по индексам? Я думаю редко, вы же не OLTP-шник какой-нибудь (сарказм). Чаще вы все таки работаете с большими выборками, которые порождают хэш-таблицы для связки. Даже в классической звезде - вы всегда вынуждены думать о том, как бы не вязать много больших таблиц. Работаете так - много измерений и ограниченное число таблиц фактов. Иначе можем попасть.
А теперь перевернем все с ног на голову:
Остается теперь понять, а какая разница между двумя схемами? Разница в том, что во втором случае мы выберем обе таблицы, где можно отрежем по партициям, а далее объединим их через GROUP BY.
Также помним, что мы работаем в контексте колоночной БД, поэтому агрегации по колонкам и группировки нас вообще не пугают. Можете посмотреть скорости на сотнях миллионов записей.
В конструкции GROUP BY используем поля связок, агрегатными функциями выбираем значения, которых нет в одной из таблиц. Ведь если в таблицах h_cities и s_city_attr есть Хэш, то сгруппировав по нему и выбрав максимальное название города, мы получим название города для конкретного Хэша. Ну и развивая эту мысль и работая с подзапросами, вот так можно посчитать количество кроликов в каждом городе.
--select sum(rabbit_cnt) from (
select max(t.city_name) as city_name,
sum(t.rabbit_cnt) as rabbit_cnt
from (
select ca.city_hsh,
ca.name_ as city_name,
null as farm_hash,
0 as rabbit_cnt
from s_city_attrs ca
union all
select max(t1.city_hsh) as city_hsh,
null as city_name,
t1.farm_hsh,
sum(t1.rabbit_cnt) as rabbit_cnt
from (
select cf.city_hsh,
cf.farm_hsh,
null as animal_hsh,
0 as rabbit_cnt
from l_city_farms cf
union all
select null as city_hsh,
max(t2.farm_hsh) as farm_hsh,
t2.animal_hsh,
sum(t2.rabbit_cnt) as rabbit_cnt
from (
select af.farm_hsh,
af.animal_hsh,
0 as rabbit_cnt
from l_animal_farms af
union all
select null as farm_hsh,
t3.animal_hsh,
sum(t3.rabbit_cnt) as rabbit_cnt
from (
select null as farm_hsh,
a.animal_hsh,
0 as rabbit_cnt,
a.timestamp_ as actual_timestamp,
a.timestamp_
from h_animals a
union all
select null as farm_hsh,
al.animal_hsh,
1 as rabbit_cnt,
null as actual_timestamp,
al.timestamp_
from s_animal_lifecycle al
where al.status = 'Жив'
) t3
group by t3.animal_hsh,
t3.timestamp_
having max(t3.actual_timestamp) = t3.timestamp_
) t2
group by t2.animal_hsh
) t1
group by t1.farm_hsh
) t
group by t.city_hsh
--)
Что нужно помнить:
поля группировки - это аналоги связок JOIN
чем больше таблиц мы вяжем, тем больше колонок будет с null, так как общий набор колонок возрастает, а есть они не в каждой таблице
чтобы проводить промежуточные связки с новыми Хабами придется писать подзапросы все глубже и глубже, группируя по полю связки. В примере выше - это подзапрос l_city_farms и l_animal_farms, который написан, чтобы прокинуть на уровень выше хэш города для каждой фермы
Также я не делал упора на один момент. Когда вязались таблица s_animal_lifecycle, с помощью конструкции having и двух полей timestamp мы выбрали запись, строго соответствующую Хабу. Тем самым обошлись без Point In Time таблиц, а использовали агрегации.
Можно также добавлять условия вне подзапросов. ClickHouse прекрасно поймет, что нужно выборки делать частично. Однако стоит помнить, что тут придется очень внимательно следить - данные из каких колонок у вас поступают наружу из подзапросов и всегда тестировать нагрузки. Поэтому условие where при нашем подходе становится бомбой замедленного действия, с которой сложно обращаться:
select farm_hsh,
max(city_hsh) as city_hsh
from (
SELECT farm_hsh,
null as city_hsh
from h_farms
union all
SELECT farm_hsh,
city_hsh
from h_city_farms
)
-- выборку мы ограничим, но при этом сделаем запрос чувствительным к ошибкам
-- при внесении правок, особенно если он будет сложным
where farm_hsh = '<hash_value>'
group by farm_hsh
Вопрос с поиском самых эффективных рефералов вообще можно решить запросом из одной таблицы l_farm_referal. Но чтобы получить номер фермы пришлось привязать h_farms.
Самые эффективные фермы по привлечению
select max(t.farm_num) as farm_num,
sum(t.cnt) as cnt
from (
select fr.attract_farm_hsh as farm_hsh,
count(1) as cnt,
null as farm_num
from l_farm_referals fr
group by fr.attract_farm_hsh
union all
select f.farm_hsh,
0 as cnt,
f.farm_num
from h_farms f
) t
group by t.farm_hsh
order by cnt desc
Заключение
Бизнес аналитики, после того как годами приходилось получать подобные данные по 10-20 минут из процессинговой базы, остались довольны и готовы дальше считать кроликов в вашем DWH.
Я конечно понимаю, что подход с GROUP BY не новый. И его часто применяют даже в БД со строчным хранением в случаях, когда все-равно нужно вычитать большой объем данных. Но в колоночных БД подход является более эффективным в связи с архитектурой таких СУБД.
Из плюсов подхода:
можно проводить первичную аналитику на самом Data Vault - скорости позволяют. А витрины формировать по мере необходимости. Считаться в любом случае они будут быстрее чем в строковой БД
очень быстрое получение фактов по одному Хабу и связанным с ним Линкам и Сателлитам
Совместимость с организацией хранения данных СУБД - группировки и агрегации являются первоочередной задачей колоночных БД.
Можно обойтись без Point in time таблиц. Так как мы все равно агрегируем данные в подзапросах
Из минусов:
непривычные правила написания запросов
большое число подзапросов, когда нужно вязать новые Хабы
большое количество null-колонок в сложных запросах
мне показалось, что использовать Хэши для агрегации - это как стрелять из гранатомета по тараканам. Наверное все таки в Data Vault 1.0 подход будет работать намного быстрее и по памяти будет значительно лучше. В общем для прода, я бы включил фантазию для уменьшения размера ключей агрегации. Тут все зависит от вашей модели и предметной области. Если вам не надо общих для всех слоев хранилища ключей - замените хэши числовыми идентификаторами.
ну и главный минус: трудности с написанием условий where, поэтому на базе подобного хранилища можно сделать агрегации общие. Ну или на наборе партиций по timestamp_. Но когда речь пойдет про условия - найди что-то в таком-то городе, то придется все таки строить витрины
Как итог, я бы сказал, что подобный подход будет крайне удачным для малых Data Vault с небольшим числом Хабов и огромным числом Стеллитов. Если в сателлитах еще и содержится числовая информация - то подход будет идеальным.
Ну либо для больших хранилищ, в которых аналитика проводится по ограниченному числу Хабов.
Как пример - Продажи по Клиентам. Продажи и Клиентов разносим в два Хаба. Линкуем их. Обкладываем Хабы и Линк Сателлитами. И не важно сколько будет таблиц - главное, что в наших агрегациях достаточно будет одного уровня вложенности.
Со сложными хранилищами, где множество Хабов - я бы не стал такое делать. Опыты показали, что получается полная ерунда. Для этого статья и писалась. А не для того, чтобы сказать всем - делай так, а не иначе.
Как самый рабочий кейс, который может помочь и в проде - копирование части Data Vault из иной СУБД 'AS IS'. Так сказать ETL - T = EL. Для построения витрин путем агрегации или прямого сбора статистики за большие периоды прям в ClickHouse.
Комментарии (6)
SSukharev
02.10.2023 04:01CH - это худшее решение из того многообразия баз данных, которое можно взять для построения ХД по методологии DV. Банально но на больших джойнах с кучей таблиц ему не хватит памяти.
zmiik Автор
02.10.2023 04:01Ну формально - это статья эксперимент, чтобы выявить ограничения и найти кейсы. Специально и генератор писался, для того, чтобы нагрузка была серьезной.
На 1-2 Хабах - это реально работает. А связка пары таблиц фактов с агрегацией в витрину работает крайне быстро и памяти потребляет не больше чем HASH JOIN на тех же таблицах в каком-нибудь Оракл. А если ключи числовые, а не строковые - то и вовсе проблем нет.
Ну и понятное дело никаких Join делать в CH не планировалось от слова 'совсем'. Только агрегации с группировкой по ключам.
Ну и никто не говорит - стройте такой Vault. Можно тягать для узконаправленных задач 1-2 Хаба из другой СУБД и строить по ним агрегации AS IS. При любом раскладе в колоночной СУБД группировка с агрегацией работает шустрее чем JOIN монстроидальных таблиц в строчной СУБД. Даже те же витрины построятся быстрее.
Eremeev009
Не плохо! Сможете реализовать подобную статью на примере БД провайдера связи?
zmiik Автор
Боюсь что если даже начать делать что-то из области телекоммуникаций, то статья превратится в Войну и Мир. Предметная область не для философских размышлений и экспериментов.
Да и одной СУБД там явно не обойтись - с форматами данных подобных компаний.
Но а если речь про базу абонентов - то просто замените в статье Кроликов на абонентов, Фермы, скажем на тарифы :) И вот статья про телеком
MAXH0
Статья про телеком?
Тогда это должно быть здесь
Мужик на перекрёстке въехал на своей старенькой «шестёрке» прямо в новенький «Мерседес». Из иномарки неторопливо вылезли два огромных бугая и сказали мужику:
— Значит так, придурок, с тебя — 30 штук баксов за ущерб и ещё десятка сверху за моральный ущерб! Иначе живым домой вряд ли вернёшься!
Мужичок спокойно открыл багажник своей машины, а он доверху набит купюрами по сто долларов. Амбалы удивились, а мужичок спокойно отсчитал и отдал им 40 000 баксов.
— Слышь, мужик, ты где работаешь, что у тебя машина баблом доверху забита?
— Да у меня хозяйство своё, кроликов развожу.
— Да ладно?
— Серьёзно. Хотите покажу вам свою ферму?
Амбалы согласились. Мужик привёз их на какую-то ферму за городом, заехал во двор, массивные ворота закрылись, а повсюду суровые братки с автоматами стоят. Мужичок вылез из авто и сказал им:
— Ребята, я кроликов вам привёз! Разводите.
NIKEtoS1989
Баян, который я слышал в средней школе - лет 20 назад))
Но посмеялся))