Kimball-light: dimensional modeling basics
В предыдущих уроках слой marts — это «финальные таблицы для BI». Но как их структурировать? Один большой wide-таблица со всеми полями? Несколько узких таблиц? Какая логика?
Здесь приходит Kimball methodology — методология dimensional modeling от Ральфа Кимбалла, классика data warehousing с 1996 года. Это набор паттернов, как организовать marts.
Полный Kimball — это толстая книга. В этом уроке — Kimball-light: 80% пользы за 20% теории. Достаточно, чтобы строить marts по индустриальному стандарту и говорить с аналитиками на их языке.
Две сущности: fact и dimension
В Kimball-таблицах данных два типа:
Простое правило: если строка таблицы отвечает на вопрос «что произошло?» -> fact. Если на вопрос «кто/что это?» -> dimension.
| Таблица | fact или dimension? | Почему |
|---|---|---|
| orders | fact | Заказ — это событие. Произошёл, имеет amount, дату. |
| customers | dimension | Клиент — это сущность. Имеет имя, адрес, tier. |
| products | dimension | Продукт — это сущность. Имеет название, цену, категорию. |
| payments | fact | Платёж — это событие. Имеет amount, дату, метод. |
| page_views | fact | Просмотр — это событие. Имеет timestamp. |
| date_calendar | dimension | Дата — это сущность. Имеет день недели, квартал, год. |
Star schema
Когда у вас несколько fact’ов и dimension’ов, они соединяются в star schema — звезду:
Fact в центре. Dimension’ы вокруг — отсюда «звезда». Каждый dimension соединён с fact через FK.
Запросы в star schema просты:
-- Revenue по регионам и категориям продуктов
SELECT
s.region,
p.category,
SUM(f.amount) AS revenue
FROM fct_orders f
JOIN dim_customers c ON f.customer_id = c.customer_id
JOIN dim_products p ON f.product_id = p.product_id
JOIN dim_stores s ON f.store_id = s.store_id
WHERE f.order_date >= '2026-01-01'
GROUP BY s.region, p.category
Аналитики читают такие запросы быстро. Каждый JOIN — это «добавить контекст». Это и есть смысл dimensional modeling.
Grain — самое важное понятие в Kimball
Grain (зерно) фактической таблицы — это что означает одна строка.
| fct_orders | grain |
|---|---|
| Один заказ | grain = “один заказ” |
| Одна строка заказа | grain = “одна позиция заказа” |
| Одна строка + день | grain = “позиция заказа в конкретный день” |
Это критично. Если grain неоднозначен — все агрегаты сломаются.
Пример. Допустим, fct_orders с grain “один заказ”:
order_id | customer_id | product_id | order_date | amount
1 | 5 | 10 | 2026-05-01 | 100
2 | 5 | 11 | 2026-05-02 | 50
SUM(amount) = 150. Корректно.
Теперь представьте, что вместо этого fct_orders с grain “одна позиция заказа”:
order_id | line_id | customer_id | product_id | order_date | amount
1 | 1 | 5 | 10 | 2026-05-01 | 60
1 | 2 | 5 | 11 | 2026-05-01 | 40
2 | 1 | 5 | 11 | 2026-05-02 | 50
SUM(amount) = 150. Тоже корректно. Но: COUNT(DISTINCT order_id) = 2, COUNT(*) = 3. Это разные таблицы, отвечают на разные вопросы.
Самая частая ошибка junior’ов: написать SUM на fact-таблицу с фан-аутом (один заказ занимает несколько строк) и не заметить, что amount теперь означает «сумма строки», а не «сумма заказа». Результат — двойной/тройной счёт. Это называется fan-out trap.
Документируйте grain в description:
models:
- name: fct_orders
description: |
Grain: один заказ (order_id уникален).
Если нужны позиции — используйте fct_order_items (grain = order_id + line_id).
SCD Types
Когда dimensions меняются во времени, есть несколько стратегий:
SCD Type 1 — overwrite (no history)
Старое значение перезаписывается. История потеряна.
До: customer_id=1, tier=standard
После: customer_id=1, tier=premium (tier перезаписан)
Прост в реализации (UPDATE), но не отвечает на вопросы «какой tier был в момент заказа?».
В dbt: модель dim_customers с materialized='table' и просто SELECT * FROM stg_customers — каждый run перезаписывает. Это SCD1.
SCD Type 2 — history rows (full history)
Каждое изменение — новая строка с valid_from / valid_to. История полная.
customer_id=1, tier=standard, valid_from=2026-01-01, valid_to=2026-03-15
customer_id=1, tier=premium, valid_from=2026-03-15, valid_to=NULL (текущая)
В dbt: реализуется через dbt snapshot (см. уроки 12.3-12.4). Полная история.
Когда какой Type
| Dimension | SCD Type | Почему |
|---|---|---|
| customers — name | Type 1 | Имя меняется редко, история мало кого волнует. |
| customers — tier | Type 2 | Tier нужен для атрибуции revenue на момент. |
| customers — email | Type 1 или Type 2 | Зависит от use-case (для marketing — Type 2). |
| products — price | Type 2 | Цена меняется, нужна для расчёта revenue на момент. |
| products — name | Type 1 | Название обычно стабильно. |
Часто в одном dimension разные атрибуты имеют разный SCD-тип. Решение в Kimball — mini-dimensions или bridge tables. В junior-курсе достаточно знать, что Type 1 и Type 2 — это не на всю таблицу, а на отдельные атрибуты.
Wide vs narrow в marts
Есть две философии mart’ов:
Narrow (классический Kimball)
fct_orders содержит только метрики + FK. Атрибуты — в dim-таблицах.
-- fct_orders
order_id, customer_id, product_id, order_date, amount
-- dim_customers
customer_id, first_name, last_name, email, tier
-- В BI:
SELECT c.tier, SUM(f.amount)
FROM fct_orders f JOIN dim_customers c ...
Преимущества:
- Компактные fact-таблицы.
- Меньше redundancy.
- Изменение атрибута клиента — в одном месте.
Недостатки:
- Каждый запрос — JOIN.
- BI-инструменты должны уметь делать join (большинство умеет).
Wide (modern data stack)
В fct_orders денормализуем атрибуты dim прямо в fact. Получается «one big table».
-- orders (wide)
order_id, customer_id, customer_name, customer_tier, product_id, product_name, ...
Преимущества:
- Один SELECT без JOIN — BI быстрее.
- Аналитики не задумываются о JOIN’ах.
- Современные warehouse (DuckDB, Snowflake, BigQuery) хорошо жмут wide таблицы.
Недостатки:
- Дублирование данных.
- Изменение атрибута клиента — нужно пересчитать всю историю.
- Размер таблицы больше.
В современной практике (2026) часто комбинируют: fact-таблицы — wide (для прямого SELECT), dimensions — отдельно (для drill-down и истории).
Date dimension
Особый dimension — dim_dates. Это календарь на годы вперёд / назад:
date | year | quarter | month | month_name | week | day_of_week | is_weekend | is_holiday
2026-05-19 | 2026 | Q2 | 5 | May | 21 | Tuesday | false | false
2026-05-20 | 2026 | Q2 | 5 | May | 21 | Wednesday | false | false
Зачем:
- Календарные группировки.
GROUP BY year, quarterчерез JOIN, а не через date functions. - Календарные фильтры.
WHERE is_business_day = trueпонятнее, чемWHERE EXTRACT(dow FROM ...) BETWEEN 1 AND 5. - Holidays. Можно пометить праздники бизнеса.
- Fiscal calendars. Если бизнес работает не по календарному году — кастомный fiscal_quarter.
В dbt date dimension часто генерируется через dbt_utils.date_spine:
-- models/marts/core/dim_dates.sql
WITH dates AS (
{{ dbt_utils.date_spine(
datepart="day",
start_date="cast('2020-01-01' as date)",
end_date="cast('2030-12-31' as date)"
) }}
)
SELECT
date_day AS date,
EXTRACT(year FROM date_day) AS year,
EXTRACT(quarter FROM date_day) AS quarter,
EXTRACT(month FROM date_day) AS month,
STRFTIME(date_day, '%A') AS day_of_week_name,
EXTRACT(dow FROM date_day) IN (0, 6) AS is_weekend
FROM dates
Это de facto стандарт. Любой data warehouse имеет dim_dates.
Surrogate vs natural keys
В Kimball-теории часто используют surrogate keys — синтетические PK, не привязанные к бизнес-данным:
dim_customers
customer_sk (surrogate) | customer_id (natural) | name | tier
1 | C00042 | Alice | premium
2 | C00043 | Bob | standard
customer_sk — это auto-increment integer. customer_id — это business-id (например, из CRM).
Зачем surrogate:
- Type 2 dimension’ы. Одно customer_id может иметь несколько строк (история). Surrogate — для каждой версии уникальный.
- Source-independence. Если позже добавится второй source с customer_id-конфликтом — surrogate решает.
- Производительность. Integer JOIN быстрее string JOIN.
В современной практике (особенно с DuckDB/Snowflake/BigQuery, где JOIN-производительность не критична) — surrogate keys часто не нужны. Используют natural keys + Type 2 через dbt snapshot с встроенным dbt_scd_id.
В junior-курсе: используйте natural keys по умолчанию. Surrogate — когда явно нужен (Type 2 history).
Kimball в junior-проекте: чек-лист
Для маленького/среднего проекта применяйте Kimball-light так:
- fact-таблицы (
fct_*) — для событий: orders, payments, page_views. - dimension-таблицы (
dim_*) — для сущностей: customers, products, dates. - Документируйте grain каждой fact-таблицы в description.
- SCD Type 1 по умолчанию для dimensions — простой
dim_*модель. - SCD Type 2 только там, где история критична — через
dbt snapshot. - dim_dates в каждом проекте — типичный must-have.
- Natural keys по умолчанию. Surrogate — только когда нужен (Type 2).
- Wide vs narrow — компромисс по проекту. В современной практике fact-таблицы часто wide.
Попробуй сам
Возьмите простой бизнес-домен (например, e-commerce). Спроектируйте marts:
- Какие fact-таблицы? orders, payments, page_views.
- Какие dimension-таблицы? customers, products, stores, dates.
- Какой grain каждого fact’а? orders — один заказ. order_items — одна позиция.
- Какие атрибуты Type 2? customer.tier? product.price? Опишите rationale.
- Нужен ли dim_dates? Какой диапазон?
Нарисуйте star schema на бумаге. FK ↔ PK. Какие JOIN-ы возможны.
Бонус: реализуйте в dbt:
models/marts/core/
fct_orders.sql ← grain: один заказ
dim_customers.sql ← SCD1 dimension
dim_products.sql ← SCD1 dimension
dim_dates.sql ← через dbt_utils.date_spine
snapshots/
customers_tier_history.yml ← SCD2 только для tier
В YAML опишите grain и SCD типы.
Ключевые выводы
- Fact = событие (заказ, платёж, просмотр). Метрики + FK. Append-only.
- Dimension = сущность (клиент, продукт, дата). PK + descriptive attributes. Меняется медленно.
- Star schema — fact в центре, dimensions вокруг. JOIN-ы по FK.
- Grain — что означает одна строка fact’а. Самое важное понятие. Документируйте в description.
- SCD Type 1 (overwrite) для атрибутов без истории. SCD Type 2 (history rows) — для атрибутов с историей, через
dbt snapshot. - Wide vs narrow — компромисс. Современная практика часто использует wide fact-таблицы (one big table).
- dim_dates — must-have. Генерируется через
dbt_utils.date_spine. - Natural keys — по умолчанию. Surrogate keys — когда нужен SCD2 или multi-source.
- Kimball-light в junior-проекте: префиксы
fct_/dim_, документ grain, SCD1 по умолчанию, SCD2 для критичных атрибутов.