Современный подход к 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)


  1. Eremeev009
    02.10.2023 04:01

    Не плохо! Сможете реализовать подобную статью на примере БД провайдера связи?


    1. zmiik Автор
      02.10.2023 04:01

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

      Да и одной СУБД там явно не обойтись - с форматами данных подобных компаний.

      Но а если речь про базу абонентов - то просто замените в статье Кроликов на абонентов, Фермы, скажем на тарифы :) И вот статья про телеком


      1. MAXH0
        02.10.2023 04:01
        +1

        Статья про телеком?

        Тогда это должно быть здесь

        Мужик на перекрёстке въехал на своей старенькой «шестёрке» прямо в новенький «Мерседес». Из иномарки неторопливо вылезли два огромных бугая и сказали мужику:
        — Значит так, придурок, с тебя — 30 штук баксов за ущерб и ещё десятка сверху за моральный ущерб! Иначе живым домой вряд ли вернёшься!

        Мужичок спокойно открыл багажник своей машины, а он доверху набит купюрами по сто долларов. Амбалы удивились, а мужичок спокойно отсчитал и отдал им 40 000 баксов.

        — Слышь, мужик, ты где работаешь, что у тебя машина баблом доверху забита?
        — Да у меня хозяйство своё, кроликов развожу.
        — Да ладно?
        — Серьёзно. Хотите покажу вам свою ферму?

        Амбалы согласились. Мужик привёз их на какую-то ферму за городом, заехал во двор, массивные ворота закрылись, а повсюду суровые братки с автоматами стоят. Мужичок вылез из авто и сказал им:
        — Ребята, я кроликов вам привёз! Разводите.


        1. NIKEtoS1989
          02.10.2023 04:01

          Баян, который я слышал в средней школе - лет 20 назад))

          Но посмеялся))


  1. SSukharev
    02.10.2023 04:01

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


    1. zmiik Автор
      02.10.2023 04:01

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

      На 1-2 Хабах - это реально работает. А связка пары таблиц фактов с агрегацией в витрину работает крайне быстро и памяти потребляет не больше чем HASH JOIN на тех же таблицах в каком-нибудь Оракл. А если ключи числовые, а не строковые - то и вовсе проблем нет.

      Ну и понятное дело никаких Join делать в CH не планировалось от слова 'совсем'. Только агрегации с группировкой по ключам.

      Ну и никто не говорит - стройте такой Vault. Можно тягать для узконаправленных задач 1-2 Хаба из другой СУБД и строить по ним агрегации AS IS. При любом раскладе в колоночной СУБД группировка с агрегацией работает шустрее чем JOIN монстроидальных таблиц в строчной СУБД. Даже те же витрины построятся быстрее.