Моделирование данных: нормализация, звезда, снежинка, 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. Порядок шагов важен, менять его нельзя.
«оформление заказа», а не «отдел продаж»"] --> B["2. Объявить грануляцию
одна строка = одна позиция чека"] B --> C["3. Определить измерения
кто/что/когда/где/как"] C --> D["4. Определить меры
qty, amount, discount"] D --> E{"Все меры
соответствуют
грануляции?"} E -- нет --> B E -- да --> F["DDL, загрузка, проверка
сверка сумм с источником"] F --> G{"Нужен новый
процесс?"} G -- да --> A G -- нет --> H["Шина измерений:
переиспользовать conformed dimensions"]
Шаг 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 из источника
В измерениях используют суррогатные ключи — бессмысленные целые (или хеши), сгенерированные хранилищем, а не источником. Причины:
- История. При SCD2 у одного бизнес-клиента несколько версий строк — натуральный ключ перестаёт быть уникальным, нужен отдельный ключ версии.
- Слияние источников. После покупки конкурента в системе два клиента с
id = 42; суррогатный ключ решает коллизию, натуральный — нет. - Компактность и скорость.
bigintсоединяется быстрее строкового UUID или составного ключа из трёх полей и лучше сжимается в колоночном хранилище. - Изоляция от источника. Источник поменял формат идентификатора — факты не трогаем.
- 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.
Жизненный цикл строки измерения в 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/.
Типичные ошибки
- Не объявлена грануляция. Самая дорогая ошибка: в фактах оказываются строки «на чек» и «на позицию», суммы задваиваются, доказать верную цифру никто не может.
- Меры-отношения в факте.
margin_pctскладывается поSUMи даёт бессмыслицу; хранитеrevenueиcostотдельно. - Натуральные ключи в фактах. Живёт до первого слияния систем или смены формата идентификатора.
NULLво внешних ключах фактов.INNER JOINтихо выкидывает строки, и выручка «немного не сходится». Заводите строки-1 (unknown)и-2 (not applicable).- SCD2 без нормализации
NULLи с включающимvalid_to. Первое даёт дырявую историю, второе — удвоение фактов ровно в дни изменений. - Смешение слоёв. Бизнес-логика («кто такой активный клиент») зашита в staging, и пересчитать её иначе нельзя без перезагрузки сырых данных.
- Data Vault там, где хватило бы звезды. 150 таблиц ради трёх источников — способ потратить полгода и не выпустить ни одного дашборда.
- Измерение-мусорка на 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 (звёзды). Спор «Кимбалл против Инмона» решается не выбором, а расстановкой по слоям.
Источники
- Ralph Kimball, Margy Ross. The Data Warehouse Toolkit, 3rd ed. — техники на kimballgroup.com
- E. F. Codd. A Relational Model of Data for Large Shared Data Banks, 1970 — dl.acm.org; William Kent. A Simple Guide to Five Normal Forms
- Dan Linstedt, Michael Olschimke. Building a Scalable Data Warehouse with Data Vault 2.0 — danlinstedt.com; Anchor Modeling — подход 6NF с генерацией схем
- dbt: How we structure our dbt projects и dbt snapshots
- ClickHouse: денормализация, BigQuery: вложенные поля, Martin Fowler. ReportingDatabase
Что дальше
Модель описывает, что лежит в хранилище. Дальше — как это эффективно посчитать на объёмах, которые не помещаются в одну машину: Пакетная обработка: MapReduce, Spark, партиционирование. Там же станет понятно, почему звезда так удачно ложится на broadcast join и как партиционирование фактов превращает часовой запрос в минутный.