Learning Platform
Глоссарий Troubleshooting
Урок 15.04 · 25 мин
Начальный
KimballDimensional modelingFactDimensionGrainStar schema

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 vs Dimension
Fact (Факт)Fact = событие, измерение, действие. Имеет числовую метрику (amount, count, duration). Имеет timestamp. Имеет FK на dimension'ы (customer_id, product_id). Append-only — каждая строка = одно событие.
Dimension (Измерение)Dimension = атрибуты сущности. Кто? Что? Где? Когда? Имеет PK (customer_id). Имеет описательные атрибуты (name, address, tier). Тысячи строк, не миллионы. Часто меняется (SCD1 или SCD2).

Простое правило: если строка таблицы отвечает на вопрос «что произошло?» -> fact. Если на вопрос «кто/что это?» -> dimension.

Таблицаfact или dimension?Почему
ordersfactЗаказ — это событие. Произошёл, имеет amount, дату.
customersdimensionКлиент — это сущность. Имеет имя, адрес, tier.
productsdimensionПродукт — это сущность. Имеет название, цену, категорию.
paymentsfactПлатёж — это событие. Имеет amount, дату, метод.
page_viewsfactПросмотр — это событие. Имеет timestamp.
date_calendardimensionДата — это сущность. Имеет день недели, квартал, год.

Star schema

Когда у вас несколько fact’ов и dimension’ов, они соединяются в star schema — звезду:

Star schema на примере orders
fct_ordersfct_orders в центре звезды. order_id PK. customer_id, product_id, store_id, date_id — FK на dimensions. amount, quantity — numeric metrics.

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_ordersgrain
Один заказ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. Это разные таблицы, отвечают на разные вопросы.

WARNING

Самая частая ошибка 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

DimensionSCD TypeПочему
customers — nameType 1Имя меняется редко, история мало кого волнует.
customers — tierType 2Tier нужен для атрибуции revenue на момент.
customers — emailType 1 или Type 2Зависит от use-case (для marketing — Type 2).
products — priceType 2Цена меняется, нужна для расчёта revenue на момент.
products — nameType 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

Зачем:

  1. Календарные группировки. GROUP BY year, quarter через JOIN, а не через date functions.
  2. Календарные фильтры. WHERE is_business_day = true понятнее, чем WHERE EXTRACT(dow FROM ...) BETWEEN 1 AND 5.
  3. Holidays. Можно пометить праздники бизнеса.
  4. 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:

  1. Type 2 dimension’ы. Одно customer_id может иметь несколько строк (история). Surrogate — для каждой версии уникальный.
  2. Source-independence. Если позже добавится второй source с customer_id-конфликтом — surrogate решает.
  3. Производительность. 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 так:

  1. fact-таблицы (fct_*) — для событий: orders, payments, page_views.
  2. dimension-таблицы (dim_*) — для сущностей: customers, products, dates.
  3. Документируйте grain каждой fact-таблицы в description.
  4. SCD Type 1 по умолчанию для dimensions — простой dim_* модель.
  5. SCD Type 2 только там, где история критична — через dbt snapshot.
  6. dim_dates в каждом проекте — типичный must-have.
  7. Natural keys по умолчанию. Surrogate — только когда нужен (Type 2).
  8. Wide vs narrow — компромисс по проекту. В современной практике fact-таблицы часто wide.

Попробуй сам

Возьмите простой бизнес-домен (например, e-commerce). Спроектируйте marts:

  1. Какие fact-таблицы? orders, payments, page_views.
  2. Какие dimension-таблицы? customers, products, stores, dates.
  3. Какой grain каждого fact’а? orders — один заказ. order_items — одна позиция.
  4. Какие атрибуты Type 2? customer.tier? product.price? Опишите rationale.
  5. Нужен ли 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 типы.


Ключевые выводы

  1. Fact = событие (заказ, платёж, просмотр). Метрики + FK. Append-only.
  2. Dimension = сущность (клиент, продукт, дата). PK + descriptive attributes. Меняется медленно.
  3. Star schema — fact в центре, dimensions вокруг. JOIN-ы по FK.
  4. Grain — что означает одна строка fact’а. Самое важное понятие. Документируйте в description.
  5. SCD Type 1 (overwrite) для атрибутов без истории. SCD Type 2 (history rows) — для атрибутов с историей, через dbt snapshot.
  6. Wide vs narrow — компромисс. Современная практика часто использует wide fact-таблицы (one big table).
  7. dim_dates — must-have. Генерируется через dbt_utils.date_spine.
  8. Natural keys — по умолчанию. Surrogate keys — когда нужен SCD2 или multi-source.
  9. Kimball-light в junior-проекте: префиксы fct_/dim_, документ grain, SCD1 по умолчанию, SCD2 для критичных атрибутов.
Kimball: star schema — полный курс размерного моделирования Роль dbt в современном моделировании: staging, intermediate, marts
Проверка знанийKnowledge check
Аналитик пишет запрос `SELECT SUM(amount) FROM fct_orders WHERE order_date >= '2026-01-01'`. Получает в 3 раза больше, чем ожидал. В чём проблема?
ОтветAnswer
Это классический **fan-out trap**. Скорее всего `fct_orders` имеет grain «одна позиция заказа» (т.е. `order_id + line_id`), а не «один заказ». Тогда:\n\n- Один order = несколько строк в fct_orders.\n- amount — это **сумма позиции**, не **сумма заказа**.\n- SUM(amount) корректно даёт revenue, но если аналитик думает что считает СУММУ заказов — он ошибается на коэффициент avg(positions per order).\n\nКак расследовать:\n\n1. `SELECT COUNT(*), COUNT(DISTINCT order_id) FROM fct_orders`. Если первое > второго — есть fan-out.\n2. Открыть description модели — там должен быть **grain**. Если нет — это bug документации.\n\nКак исправить:\n\n**Вариант A.** Переименовать модель: `fct_order_items` или `fct_orders__line_grain`. Создать отдельную `fct_orders` с grain «один заказ» — агрегацией по order_id.\n\n**Вариант B.** Не менять модель, но добавить в YAML явный warning: \n```yaml\n- name: fct_orders\n description: |\n GRAIN: ОДНА ПОЗИЦИЯ ЗАКАЗА (order_id, line_id).\n Для суммы по заказу: SUM(amount) GROUP BY order_id.\n Для среднего чека: SUM(amount) / COUNT(DISTINCT order_id).\n```\n\nПравильный путь — A. Имя должно отражать grain.
Проверка знанийKnowledge check
Junior дизайнит dim_customers. Включает: customer_id, name, email, address, tier, signup_date. Какие из этих атрибутов должны быть SCD Type 2, а какие Type 1?
ОтветAnswer
Решение зависит от **use-case** для каждого атрибута.\n\nТипичное решение:\n\n| Атрибут | SCD Type | Почему |\n|---------------|----------|-------------------------------------------------------------------------------------------------------|\n| customer_id | — | Это PK, не атрибут. Не меняется. |\n| name | Type 1 | История имени редко нужна. Опечатки исправляются, рекомендуется перезапись. |\n| email | Type 1 или Type 2 | Depends. Если для retention-analyses нужно знать email на момент кампании — Type 2. Иначе Type 1. |\n| address | Type 2 | Адрес нужен для shipping-аналитики на момент сделки. |\n| tier | Type 2 | **Самый частый Type 2**. Revenue attribution по tier на момент покупки. |\n| signup_date | — | Не меняется (это immutable атрибут). |\n\nПрактика в dbt:\n\n1. `dim_customers` — SCD Type 1 (просто model). Содержит **текущие** значения name, email и т.д. Используется для drill-down «кто сейчас этот клиент».\n2. `snapshots/customers_snapshot.yml` — SCD Type 2 на критичные колонки (`tier`, `address`). Используется для **атрибуции на момент** в fact-JOIN.\n\nЭто гибридный паттерн: simple dim для типичных запросов + snapshot для исторической аналитики.

Проверьте понимание

Результат: 0 из 0
Аналитический
Вопрос 1 из 5. В fct_orders с grain 'один заказ': SUM(amount) = $1.5M. После того как junior случайно поменял grain на 'одна позиция заказа' (один заказ может иметь несколько позиций), SUM(amount) теперь $4.5M. Что произошло?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4