Data Engineering и ETL Моделирование данных: нормализация, звезда, снежинка, Data Vault
0%

Моделирование данных: нормализация, звезда, снежинка, Data Vault

Моделирование данных: нормализация, звезда, снежинка, Data Vault

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

В прошлой статье — https://courses.digitable.life/post/data-engineering/01-etl-vs-elt/ — мы разобрали, как данные попадают в хранилище. Здесь разберём, в какой форме они там лежат и почему форма определяет и стоимость запроса, и скорость появления новой метрики.

Зачем вообще моделировать

Модель данных решает четыре конфликтующие задачи одновременно: целостность (факт хранится в одном месте и не может разойтись сам с собой), производительность (типичный запрос читает мало байт и делает мало соединений), понятность (аналитик за 10 минут находит, где выручка) и устойчивость к изменениям (завтра новый канал продаж, послезавтра покупка конкурента — схема не рассыпается).

Никакая одна модель не оптимизирует все четыре. Нормализация выигрывает целостность, проигрывая производительность и понятность. Звезда выигрывает понятность и скорость, проигрывая гибкость. Data Vault выигрывает устойчивость, проигрывая понятность. Отсюда главный вывод статьи: в зрелом хранилище одновременно живут несколько моделей, на разных слоях, и задача инженера — знать, какая где уместна.

Слои хранилища данных

Три уровня модели

Уровень Что описывает Артефакт
Концептуальный Сущности бизнеса и связи: «клиент делает заказ» Глоссарий, ER-эскиз
Логический Атрибуты, ключи, кардинальности, нормальные формы Логическая ER-схема
Физический Типы, партиционирование, сортировка, кодеки, индексы DDL под конкретную СУБД

Концептуальный уровень живёт дольше всего, физический меняется при каждой миграции движка. Ошибка «мы сразу пишем DDL» приводит к тому, что бизнес-смысл сущностей нигде не зафиксирован и через год никто не может ответить, что такое «активный клиент». Споры вида «а нужен ли нам суррогатный ключ» почти всегда возникают от смешения уровней.

Нормализация: как не хранить один факт дважды

Нормализация — формальная процедура декомпозиции таблиц, придуманная Коддом в 1970-х (A Relational Model of Data for Large Shared Data Banks). Её цель — устранить аномалии обновления. Возьмём «плоскую» таблицу заказов:

-- Ненормализованная таблица: каждый факт о клиенте продублирован в каждой строке
CREATE TABLE orders_flat (
    order_id      bigint,
    order_ts      timestamp,
    customer_id   bigint,
    customer_name text,          -- дублируется
    customer_city text,          -- дублируется
    product_sku   text,
    product_name  text,          -- дублируется
    qty           int,
    price         numeric(12,2)
);

Отсюда три вида аномалий:

  • Обновления. Клиент переехал — надо обновить город в миллионе строк. Если обновится не всё (упал коннект, часть строк в другой партиции), данные противоречивы.
  • Вставки. Нельзя завести нового клиента, пока он не сделал заказ: некуда положить строку.
  • Удаления. Удалили последний заказ клиента — потеряли самого клиента.

Функциональные зависимости

Формально всё держится на понятии функциональной зависимости: запись X → Y означает, что при совпадении значений X у двух строк обязательно совпадают и значения Y. В нашей таблице customer_id → customer_name, customer_city и product_sku → product_name, при этом ключ — (order_id, product_sku). Проблема ровно в том, что customer_name зависит не от ключа, а от неключевого атрибута. Нормальные формы — это правила, запрещающие определённые виды таких зависимостей.

Форма Требование Что чинит
1NF Атомарные значения, нет повторяющихся групп и массивов «в одной ячейке» Невозможность фильтровать и агрегировать
2NF 1NF + нет зависимости неключевого атрибута от части составного ключа Дубли, привязанные к части ключа
3NF 2NF + нет транзитивных зависимостей (неключевой → неключевой) Основную массу аномалий обновления
BCNF Любой детерминант — суперключ Случаи с пересекающимися кандидатными ключами
4NF/5NF Многозначные и соединительные зависимости Экзотика, на практике встречается редко

Неформальная мнемоника 3NF: каждый неключевой атрибут зависит от ключа, от всего ключа и ни от чего, кроме ключа — «the key, the whole key, and nothing but the key». Подробный разбор с примерами — классическая заметка Уильяма Кента A Simple Guide to Five Normal Forms.

Декомпозиция разносит зависимости по отдельным отношениям: CITY(city_id, name)CUSTOMER(customer_id, name, city_id)ORDERS(order_id, order_ts, customer_id)ORDER_ITEM(order_id, sku, qty, price)PRODUCT(sku, name, category_id). Каждый факт теперь хранится ровно один раз: переезд клиента — один UPDATE одной строки, а новый клиент заводится независимо от заказов.

Цена нормализации

За целостность платят соединениями. Запрос «выручка по странам за месяц» на 3NF-схеме требует пройти цепочку order_item → orders → customer → city: четыре таблицы и три join’а — и это на простом вопросе. На реальной 3NF-схеме ERP с 300 таблицами такой запрос легко превращается в двенадцать соединений, которые:

  • никто не может прочитать глазами и проверить на корректность;
  • оптимизатор перебирает с трудом — пространство планов растёт как O(n!) при полном переборе, поэтому планировщики переходят на эвристики (в PostgreSQL — генетический поиск после geqo_threshold, по умолчанию 12 таблиц);
  • дают разный результат при малейшей ошибке в связи (LEFT вместо INNER теряет или множит строки).

Стоимость самих соединений тоже не бесплатна: hash join — O(N + M) по времени и O(min(N, M)) по памяти на хеш-таблицу, sort-merge — O(N·logN + M·logM). Вроде бы линейно, но константы и сетевые перетасовки в распределённых движках превращают «десять линейных операций» в реальные минуты.

Вывод: 3NF отлично подходит для OLTP, где преобладают точечные записи и чтения по ключу, и плохо подходит для аналитики, где преобладают широкие сканы с агрегацией. Именно поэтому появился второй подход.

Размерное моделирование: факты и измерения

Ральф Кимбалл предложил смотреть на аналитические данные через призму бизнес-процесса: у процесса есть измеримые события (продажа, клик, отгрузка) и контекст, в котором они произошли (кто, что, когда, где).

  • Таблица фактов (fact) — события. Много строк (миллиарды), мало колонок: числовые меры и внешние ключи на измерения.
  • Таблица измерений (dimension) — контекст. Мало строк (тысячи–миллионы), много текстовых атрибутов, по которым режут и фильтруют.

Модель называется «звезда», потому что таблица фактов в центре, а измерения — лучи.

Звезда против снежинки

Четырёхшаговый процесс Кимбалла

Канонический рецепт из The Data Warehouse Toolkit. Порядок шагов важен, менять его нельзя.

Шаг 2 — самый важный. Грануляция (grain) — это ответ на вопрос «что означает одна строка таблицы фактов», сформулированный в бизнес-терминах до единственного толкования. «Одна строка = одна товарная позиция в чеке» — годится. «Одна строка = продажа» — не годится: непонятно, чек это или позиция. Если грануляция размыта, через полгода в таблице окажутся строки двух разных смыслов и все суммы поедут в два раза.

Правило: все меры в таблице фактов должны быть измеримы на объявленной грануляции. Скидка на весь чек не помещается в таблицу с грануляцией позиции — её либо аллоцируют пропорционально по позициям, либо выносят в отдельную таблицу фактов с грануляцией чека.

Аддитивность мер

Это то, на чём чаще всего горят дашборды.

Тип Определение Пример Как агрегировать
Аддитивная Складывается по всем измерениям amount, qty SUM везде
Полуаддитивная Складывается по всем, кроме времени остаток на складе, баланс счёта SUM по товарам, LAST_VALUE по датам
Неаддитивная Не складывается вообще цена за штуку, конверсия, % маржи Хранить числитель и знаменатель, делить после SUM

Классическая ошибка: положить в факт колонку conversion_rate и получить дашборд, где «конверсия по стране» = сумма конверсий по городам = 340 %. Правило простое: в таблице фактов хранятся только слагаемые, отношения вычисляются в момент запроса.

Три вида таблиц фактов

  • Transaction fact — строка на событие. Самый частый и самый гибкий вид.
  • Periodic snapshot — строка на сущность на конец периода (остаток на складе на конец дня). Даёт дешёвый ответ на вопрос «сколько было на такую-то дату», который на transaction-таблице требует дорогого нарастающего итога.
  • Accumulating snapshot — строка на экземпляр процесса с несколькими датами-вехами, строка обновляется по мере продвижения (заказ: создан → оплачен → собран → отгружен → доставлен). Удобно считать длительности этапов, но требует UPDATE, что болезненно для колоночных хранилищ и иммутабельных форматов — подробности в https://courses.digitable.life/post/data-engineering/06-storage-and-formats/.
-- Физическая схема transaction-факта: узкая, числовая, партиционированная по дате события
CREATE TABLE fact_sales (
    date_key     int    NOT NULL,   -- FK на dim_date, вида 20260716
    customer_key bigint NOT NULL,   -- суррогатный ключ ВЕРСИИ клиента (SCD2), не id источника
    product_key  bigint NOT NULL,
    store_key    int    NOT NULL,
    order_id     bigint NOT NULL,   -- degenerate dimension: номер чека без своей таблицы
    qty          int    NOT NULL,
    amount numeric(14,2) NOT NULL, discount numeric(14,2) NOT NULL DEFAULT 0,
    cost   numeric(14,2) NOT NULL
) PARTITION BY RANGE (date_key);    -- партиция = месяц или день

Обратите внимание на order_id: это degenerate dimension — идентификатор без собственных атрибутов, поэтому отдельная таблица dim_order была бы пустой обёрткой. Такие ключи оставляют прямо в факте.

Звезда против снежинки

Снежинка — это нормализованные измерения: dim_product → dim_category → dim_department.

Критерий Звезда Снежинка
Join’ов на запрос 1 на измерение 2–4 на измерение
Объём измерений Больше (текст дублируется) Меньше
Понятность для BI Высокая, запросы генерятся автоматически Ниже, нужны настроенные связи
Согласованность иерархий Требует контроля при загрузке Гарантируется структурой
Когда оправдана Почти всегда Огромные измерения, регуляторные иерархии

Экономия места от снежинки обычно иллюзорна: измерение на 5 млн строк с дублирующимся текстом категории в колоночном хранилище со словарным кодированием занимает на десятки мегабайт больше — на фоне терабайтной таблицы фактов это шум. А дополнительный join платится на каждом запросе. Поэтому дефолт — звезда, снежинка — исключение (см. ClickHouse по денормализации и BigQuery про вложенные структуры).

Conformed dimensions и шина измерений

Идея, из-за которой размерное моделирование масштабируется на всю компанию: одно и то же dim_customer используется фактами продаж, обращений в поддержку и маркетинговых касаний. Тогда вопрос «как выручка коррелирует с числом тикетов по сегментам» решается без согласований — обе метрики режутся одними и теми же атрибутами. Матрица «бизнес-процессы × измерения» (Kimball bus matrix) — самый полезный одностраничный артефакт архитектуры хранилища:

Процесс \ Измерение Дата Клиент Продукт Магазин
Продажи
Обращения в поддержку
Складские остатки

Если два процесса используют «клиента», но с разными определениями — это уже не conformed dimension, и любые сравнения между ними некорректны. Именно здесь моделирование смыкается с data governance: см. https://courses.digitable.life/post/data-engineering/07-data-quality-and-governance/.

Ключи: почему нельзя брать customer_id из источника

В измерениях используют суррогатные ключи — бессмысленные целые (или хеши), сгенерированные хранилищем, а не источником. Причины:

  1. История. При SCD2 у одного бизнес-клиента несколько версий строк — натуральный ключ перестаёт быть уникальным, нужен отдельный ключ версии.
  2. Слияние источников. После покупки конкурента в системе два клиента с id = 42; суррогатный ключ решает коллизию, натуральный — нет.
  3. Компактность и скорость. bigint соединяется быстрее строкового UUID или составного ключа из трёх полей и лучше сжимается в колоночном хранилище.
  4. Изоляция от источника. Источник поменял формат идентификатора — факты не трогаем.
  5. Unknown-строки. Нужен ключ -1 для «неизвестного» клиента, чтобы у фактов никогда не было NULL в FK и INNER JOIN не терял выручку.

Натуральный ключ (customer_bk, business key) при этом обязательно сохраняется в измерении — по нему делают загрузку и сверку с источником.

Генерируют ключи двумя способами. Последовательность (GENERATED ALWAYS AS IDENTITY) компактна — 8 байт, — но требует централизованной выдачи и делает загрузку зависимой от порядка. Хеш-ключ (md5(upper(trim(customer_bk)) || '|' || valid_from)) детерминирован и вычисляется независимо в любом потоке — это стандарт Data Vault 2.0 и распределённых движков, но занимает 16–32 байта и не сортируется осмысленно. Нормализация входа перед хешированием (trim, upper, единый разделитель, единое представление NULL) обязательна, иначе " ACME" и "acme" дадут разные ключи для одного контрагента.

Медленно меняющиеся измерения (SCD)

Атрибуты клиента меняются. Вопрос — что делать со старыми фактами: пересчитывать их в новом разрезе или сохранять исторический? Типология Кимбалла (SCD techniques):

Тип Поведение Когда применять
0 Атрибут никогда не меняется Дата рождения, дата первого заказа
1 Перезапись, история теряется Исправления опечаток, атрибуты без аналитической ценности
2 Новая строка-версия с интервалом действия Дефолт для всего, что режет отчётность: сегмент, регион, тариф
3 Отдельная колонка previous_value Ровно одна реорганизация, надо смотреть «до и после»
4 Быстро меняющиеся атрибуты выносятся в mini-dimension Скоринг, возрастная группа — меняются ежедневно
6 Гибрид 1+2+3: и версии, и «текущее значение» в каждой строке Нужны оба взгляда одним запросом

На практике 90 % случаев — это типы 1 и 2. Тип 6 удобен тем, что позволяет одним запросом получить и «как было тогда», и «как есть сейчас»: в строке-версии лежат city_historical и city_current.

Временная шкала SCD Type 2

Жизненный цикл строки измерения в SCD2:

Реализация SCD2

Ключевой инвариант: интервалы [valid_from, valid_to) для одного бизнес-ключа не пересекаются и не имеют дыр. Полуоткрытость критична — если хранить valid_to включительно, то BETWEEN на границе даст две строки и удвоит сумму.

-- Двухпроходная загрузка: один MERGE не умеет и закрыть, и вставить строку по одному
-- и тому же ключу, поэтому шаги разделены.

-- Шаг 1: закрыть версии, у которых отслеживаемые атрибуты изменились
UPDATE dim_customer d
SET    valid_to = s.effective_ts, is_current = false
FROM   stg_customer s
WHERE  d.customer_bk = s.customer_bk AND d.is_current
  AND  d.attr_hash <> s.attr_hash;         -- сравнение по хешу всех отслеживаемых полей

-- Шаг 2: вставить новые версии (и совсем новых клиентов)
INSERT INTO dim_customer (customer_bk, name, city, segment, attr_hash, valid_from, valid_to, is_current)
SELECT s.customer_bk, s.name, s.city, s.segment,
       s.attr_hash, s.effective_ts, TIMESTAMP '9999-12-31 00:00:00', true
FROM   stg_customer s
LEFT   JOIN dim_customer d ON d.customer_bk = s.customer_bk AND d.is_current
WHERE  d.customer_bk IS NULL               -- новый бизнес-ключ
   OR  d.attr_hash <> s.attr_hash;         -- изменение отслеживаемых атрибутов

attr_hash — хеш конкатенации отслеживаемых атрибутов. Он превращает сравнение двадцати колонок с аккуратной обработкой NULL в одно сравнение строк и защищает от классической ошибки col <> col, которая в SQL возвращает NULL (то есть «не истина»), если одно из значений NULL, — из-за этого переход 'Москва' → NULL молча не регистрируется как изменение. Считать хеш нужно тоже аккуратно: md5(coalesce(name,'<NULL>') || U&'\001e' || ...), с явной заменой NULL на маркер и разделителем, которого нет в данных. Без разделителя ('AB','C') и ('A','BC') дадут одинаковый хеш — редкая, но абсолютно реальная коллизия, которую потом ищут неделями.

Проверка инварианта интервалов — обязательный тест качества данных:

-- Тест: перекрытие или дыра в истории версий. Должен возвращать 0 строк.
WITH v AS (
    SELECT customer_bk, valid_to,
           lead(valid_from) OVER (PARTITION BY customer_bk ORDER BY valid_from) AS next_from
    FROM   dim_customer
)
SELECT * FROM v WHERE next_from IS NOT NULL AND next_from <> valid_to;

В dbt то же самое описывается декларативно — snapshots со стратегией timestamp (по колонке updated_at) или check (по списку отслеживаемых колонок), плюс флаг invalidate_hard_deletes, закрывающий версии, исчезнувшие из источника.

Сложность загрузки. Оба шага — это соединение стейджа (M строк) с текущим срезом измерения (N строк): O(N + M) по времени при hash join и O(N) памяти. Важно, что сравнивается только срез is_current = true, а не вся история; без фильтра сложность растёт линейно по числу накопленных версий, и через два года загрузка начинает деградировать без видимой причины.

Late arriving dimensions

Отдельная головная боль: факт приехал, а строки измерения ещё нет (событие из Kafka опередило суточную выгрузку справочника). Либо inferred member — «заглушка» с бизнес-ключом, NULL в атрибутах и флагом is_inferred, которая при появлении настоящих данных дозаполняется по правилам SCD1 (факты не теряются, ключ не меняется); либо карантинная таблица с повторной попыткой — надёжнее по качеству, но метрика «мигает» и требует backfill (см. https://courses.digitable.life/post/data-engineering/05-orchestration/). Правило: никогда не отправлять факт в unknown навсегда, если бизнес-ключ известен, — иначе выручка «неизвестного клиента» превращается в мусорную корзину, куда стекает 15 % оборота.

Data Vault: модель для источников, которые меняются

Звезда прекрасна для витрин, но у неё есть слабость: она отражает согласованное понимание бизнеса. Когда источников двадцать, они противоречат друг другу, а требования меняются ежеквартально, каждая переработка звезды означает переписывание истории.

Data Vault 2.0 (Дэн Линстедт, danlinstedt.com) — модель для слоя сырого хранилища, спроектированная так, чтобы добавление нового источника или атрибута никогда не требовало менять существующие таблицы. Достигается это разделением на три типа сущностей:

  • Hub — список уникальных бизнес-ключей одной сущности: ключ, хеш-ключ, время загрузки и источник. Никаких описательных атрибутов.
  • Link — факт связи между двумя и более хабами (клиент ↔ заказ), только хеш-ключи. Все связи по определению many-to-many, поэтому изменение кардинальности в бизнесе не ломает схему.
  • Satellite — описательные атрибуты хаба или линка, всегда с историей (по сути встроенный SCD2). У одного хаба много сателлитов — по одному на источник и/или на скорость изменения.

Обратите внимание: биллинг присылает свои атрибуты клиента, CRM — свои. В звезде их пришлось бы мержить в одну строку dim_customer и решать конфликты в момент загрузки. В Data Vault они лежат в разных сателлитах, конфликт разрешается позже — в business vault или на слое витрин, и правило разрешения можно поменять без перезагрузки сырых данных. Загрузка сателлита при этом insert-only и потому идемпотентна: берём последнюю по load_ts версию для каждого customer_hk и вставляем новую строку, только если hash_diff отличается, — повторный запуск того же батча просто ничего не добавит.

Плюсы. Все загрузки insert-only, параллельные и идемпотентные — хабы, линки и сателлиты грузятся независимо, потому что хеш-ключ вычисляется из бизнес-ключа, а не выдаётся последовательностью. Полный аудит: видно, какой источник и когда прислал каждое значение. Новый источник — это новые таблицы, существующие не трогаем: нулевой риск регрессии.

Минусы. Взрывной рост числа таблиц: 30 сущностей источника превращаются в 150+ объектов. Запросы «в лоб» невозможны — чтобы собрать привычную строку клиента, надо соединить хаб с четырьмя сателлитами и взять из каждого актуальную версию на дату; отсюда вспомогательные PIT-таблицы (point-in-time: заранее посчитанные ключи актуальных версий всех сателлитов на каждую дату) и bridge-таблицы (предсоединённые цепочки линков). Аналитикам Data Vault показывать нельзя — поверх него всё равно строится звезда.

Как выбирать: сравнение подходов

Практическая рекомендация по стадиям зрелости: стартапу с 1–3 источниками — staging и сразу звезда, без отдельного сырого слоя-модели; средней компании с 5–20 источниками и требованием историчности — staging → «тонкий core» в 3NF → звёзды-витрины; enterprise с 50+ источниками, слияниями и аудитом — staging → Data Vault → business vault → звёзды; продуктовой аналитике событий — плоская событийная таблица, а звезда только для агрегатов.

Отдельно про One Big Table: в колоночных движках (ClickHouse, BigQuery, Druid) широкая денормализованная таблица иногда обгоняет звезду — нет join’ов, отличное сжатие повторяющихся значений, векторизованное чтение только нужных колонок. Цена — изменение одного атрибута измерения требует переписать миллиарды строк. Это осознанный обмен «дёшево читать» на «дорого менять»: оправдан для событийных логов и append-only-данных, но не для финансовой отчётности с ретроспективными корректировками.

Оценка размера и стоимости

Арифметика, которую полезно делать до создания таблицы:

def fact_size_tb(rows_per_day: int, days: int, cols: int,
                 bytes_per_col: float = 4.0, compression: float = 5.0) -> float:
    """Размер таблицы фактов на диске, ТБ.

    bytes_per_col — средний размер значения ДО сжатия (int-ключ ~4, текст ~18)
    compression   — коэффициент сжатия колоночного формата (Parquet/ORC: 3–10x)
    """
    return rows_per_day * days * cols * bytes_per_col / compression / 1024**4

# Звезда: 8 млн позиций чеков в день, 3 года, 12 целочисленных колонок
print(f"{fact_size_tb(8_000_000, 365 * 3, 12):.2f} ТБ")            # ~0.08 ТБ
# Тот же объём как One Big Table: 60 колонок, много текста
print(f"{fact_size_tb(8_000_000, 365*3, 60, 18.0, 8.0):.2f} ТБ")   # ~2.2 ТБ, в 25 раз больше

Вывод, который регулярно удивляет: таблица фактов в звезде почти всегда компактнее, чем кажется, потому что состоит из целочисленных ключей, сжимающихся в разы лучше текста. Основной объём хранилища съедают сырые слои и денормализованные витрины, а не факты.

Эвристика для планирования запроса: если измерение помещается в память воркера (обычно до сотен мегабайт), распределённый движок сделает broadcast join — разошлёт измерение всем воркерам, и join станет локальным, O(N) без сетевой перетасовки. Именно поэтому звезда так хорошо ложится на Spark и его аналоги: «маленькие измерения × одна большая таблица фактов» — идеальный для broadcast профиль. Подробнее о механике — в https://courses.digitable.life/post/data-engineering/03-batch-processing/.

Типичные ошибки

  1. Не объявлена грануляция. Самая дорогая ошибка: в фактах оказываются строки «на чек» и «на позицию», суммы задваиваются, доказать верную цифру никто не может.
  2. Меры-отношения в факте. margin_pct складывается по SUM и даёт бессмыслицу; храните revenue и cost отдельно.
  3. Натуральные ключи в фактах. Живёт до первого слияния систем или смены формата идентификатора.
  4. NULL во внешних ключах фактов. INNER JOIN тихо выкидывает строки, и выручка «немного не сходится». Заводите строки -1 (unknown) и -2 (not applicable).
  5. SCD2 без нормализации NULL и с включающим valid_to. Первое даёт дырявую историю, второе — удвоение фактов ровно в дни изменений.
  6. Смешение слоёв. Бизнес-логика («кто такой активный клиент») зашита в staging, и пересчитать её иначе нельзя без перезагрузки сырых данных.
  7. Data Vault там, где хватило бы звезды. 150 таблиц ради трёх источников — способ потратить полгода и не выпустить ни одного дашборда.
  8. Измерение-мусорка на 400 колонок и отсутствие dim_date: первое лечится разделением по скорости изменения (mini-dimension) и по источнику, второе — тем, что фискальные периоды, праздники и признак рабочего дня живут в одной таблице, а не размазаны по сотне SQL-запросов.

Как это выглядит в проде

Современная практика — модель, выраженная кодом в dbt или аналоге, с чёткими слоями (dbt: how we structure our projects):

models/
  staging/      stg_crm__customers.sql     # 1:1 с источником: типы, имена, дедупликация; без логики
  intermediate/ int_orders_joined.sql      # промежуточные соединения, наружу не публикуются
  marts/sales/  dim_customer.sql fct_sales.sql   # звёзды по доменам: то, что видят аналитики

Модель в проде обязательно сопровождают: тесты на ключи (unique + not_null на суррогатном ключе, relationships на каждом FK факта — машинной проверки целостности в колоночных СУБД нет, ограничения FK там не проверяются, даже если синтаксис их принимает); тест на грануляцию (unique по колонкам, определяющим строку факта, — одна строка кода ловит 80 % дублей); сверка сумм с источником отдельной моделью reconciliation; описание грануляции прямо в schema.yml и владелец у каждой модели; соглашения об именовании (dim_/fct_/stg_/int_, суффиксы _key — суррогатный, _bk — натуральный, _ts, _at).

models:
  - name: fct_sales
    description: "Грануляция: строка = позиция в чеке. Возвраты — в fct_returns, из amount НЕ вычтены."
    tests:
      - unique: {column_name: "order_id || '-' || line_no"}    # тест грануляции
    columns:
      - name: customer_key
        tests: [not_null, {relationships: {to: ref('dim_customer'), field: customer_key}}]

Физический слой не менее важен: партиционирование по дате события (не по дате загрузки!), сортировка и кластеризация по самым частым фильтрам, статистики для оптимизатора. Модель, логически безупречная, но с партиционированием по load_date, заставит каждый отчёт «за март» читать все партиции — детали в https://courses.digitable.life/post/data-engineering/06-storage-and-formats/.

Мини-итог

  • Модель данных — контракт между источниками и потребителями; она переживает и код пайплайнов, и сами инструменты.
  • Нормализация (3NF/BCNF) убирает аномалии обновления и правит бал в OLTP; в аналитике её цена — лавина соединений.
  • Звезда — дефолт аналитического слоя, а главное решение в ней — грануляция фактов, объявленная словами и одинаково понимаемая всеми. Снежинка оправдана редко.
  • Суррогатные ключи и SCD2 позволяют отвечать на вопрос «как было на тот момент», а не только «как есть сейчас»; инвариант полуоткрытых интервалов проверяйте тестом, а не надеждой.
  • Data Vault покупает устойчивость к изменениям и аудит ценой числа таблиц; это модель сырого слоя, поверх которой всё равно строится звезда.
  • Реальное хранилище многослойно: staging → core (3NF или Data Vault) → marts (звёзды). Спор «Кимбалл против Инмона» решается не выбором, а расстановкой по слоям.

Источники

Что дальше

Модель описывает, что лежит в хранилище. Дальше — как это эффективно посчитать на объёмах, которые не помещаются в одну машину: Пакетная обработка: MapReduce, Spark, партиционирование. Там же станет понятно, почему звезда так удачно ложится на broadcast join и как партиционирование фактов превращает часовой запрос в минутный.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов