Dimension table: денормализованные описательные атрибуты
Прошлый урок разобрал fact-таблицу — центр звезды, где живут числа. Этот урок — про лучи звезды, про dimension-таблицы, где живёт описательный контекст. Завершающий урок модуля об основах размерного моделирования.
Dimension-таблица — это то, что превращает голые числа fact-таблицы в осмысленную аналитику. Сам по себе product_key = 4471 ничего не говорит. Через dimension он становится «беспроводные наушники, категория Аудио, бренд SoundMax, ценовой сегмент премиум» — и вот по этим атрибутам аналитик режет, фильтрует и группирует. Этот урок объясняет, как dimension устроена, почему её намеренно денормализуют и почему атрибутов в ней делают щедро.
Роль dimension-таблицы
Dimension-таблица хранит описательные атрибуты одной сущности — одного «разреза» бизнеса. Сущности типичных dimensions: товар, клиент, магазин, время, сотрудник, поставщик. Каждая — своя dimension-таблица.
У dimension-таблицы две роли в аналитике, и обе видны в обычном запросе:
- Её атрибуты дают разрезы для группировки — попадают в
GROUP BY. «Выручка по категории товара» —categoryизdim_productвGROUP BY. - Её атрибуты дают условия для фильтрации — попадают в
WHERE. «Продажи только премиум-сегмента» —WHERE price_tier = 'premium'.
SELECT p.category, p.brand, SUM(f.sales_amount) AS revenue
FROM fct_sales f
JOIN dim_product p ON f.product_key = p.product_key
WHERE p.price_tier = 'premium' -- атрибут dimension как фильтр
GROUP BY p.category, p.brand; -- атрибуты dimension как разрезы
Fact-таблица отвечает «сколько», dimension отвечает «в каком разрезе». Поэтому говорят: dimensions дают аналитике язык. Богатство dimension описательными атрибутами прямо определяет, сколько разных вопросов сможет задать аналитик.
Денормализация: главное свойство dimension
Ключевое и поначалу контринтуитивное свойство dimension-таблицы: она намеренно денормализована. Это прямая противоположность тому, чему учили модули про нормализацию, — и так задумано.
Покажем на примере товара. В нормализованной OLTP-схеме (модуль 10) данные о товаре были разбиты на цепочку: products -> categories -> departments, плюс отдельно brands. Чтобы узнать департамент товара, нужно пройти соединениями products -> categories -> departments.
В размерной модели всё это схлопывается в одну плоскую таблицу dim_product:
CREATE TABLE dim_product (
product_key INT PRIMARY KEY, -- surrogate key
product_id VARCHAR(40) NOT NULL, -- natural key из источника
product_name VARCHAR(200),
-- вся цепочка категорий — плоско, в одной таблице:
subcategory VARCHAR(80),
category VARCHAR(80),
department VARCHAR(80),
-- атрибуты бренда — тоже сюда, не отдельной таблицей:
brand VARCHAR(80),
brand_country VARCHAR(80),
-- прочие описательные атрибуты:
price_tier VARCHAR(20),
color VARCHAR(40),
size VARCHAR(20),
is_active BOOLEAN
);
subcategory, category, department лежат тремя столбцами в одной строке — никаких справочных таблиц. brand и brand_country — тоже здесь, а не в отдельной dim_brand. Цепочка нормализованных справочников превращена в один плоский ряд атрибутов.
Почему так — две причины, и обе про чтение.
Один JOIN вместо цепочки. Это та самая выгода star schema из первого урока. Описание товара денормализовано — значит до любого его атрибута (категория, департамент, бренд) от fact-таблицы ровно один JOIN. Если бы dimension была нормализована в цепочку, аналитический запрос снова собирал бы каскад соединений. Денормализация dimension — это то, что физически обеспечивает обещанное «один JOIN на разрез».
Update anomaly не страшен. Возражение из модулей про нормализацию: денормализация порождает дублирование (department = 'Электроника' повторится в тысячах строк dim_product), а дублирование грозит update anomaly. Но рассуждение из модуля 10 действует и здесь: dimension не обновляется хаотичными точечными транзакциями вразнобой, она наполняется управляемым пайплайном загрузки. Нет хаотичной точечной записи — нет рассинхронизации копий — нет update anomaly. Цена нормализации в аналитике почти не нужна, а её минус (каскад JOIN) бьёт по главному. Денормализация dimension — осознанный, правильный выбор.
Анатомия dimension-таблицы
Разберём dimension по столбцам.
Surrogate key — первичный ключ. У dimension-таблицы первичный ключ — surrogate key (product_key), узкий бессмысленный integer, сгенерированный warehouse. Именно на него ссылаются foreign keys fact-таблицы. Surrogate key для PK dimension берут не случайно: он узкий (быстрый JOIN, компактный индекс), он стабилен (изолирует от изменений в источнике), и — забегая в модуль про SCD — он позволит хранить несколько исторических версий одной сущности, каждую своим ключом.
Natural key — отдельным столбцом. Идентификатор сущности из source-системы (product_id — артикул) хранят в dimension рядом с surrogate key, отдельным столбцом. Он нужен, чтобы связывать строку dimension с источником при загрузке и для прослеживаемости. Но fact ссылается на surrogate key, не на natural.
Описательные атрибуты — много. Всё остальное — описательные атрибуты: category, department, brand, price_tier, color и так далее. Их в dimension делают щедро — десятки, иногда под сотню столбцов. Логика простая: каждый атрибут dimension — это потенциальный разрез или фильтр для будущего отчёта. Чем богаче dimension атрибутами, тем больше вопросов сможет задать аналитик, не переделывая модель. Скупая dimension из трёх столбцов ограничивает аналитику тремя разрезами. Поэтому в dimension добавляют атрибуты впрок — это дёшево (dimension-таблица маленькая, лишний столбец почти ничего не весит) и расширяет возможности анализа.
Dimension короткая и широкая
Геометрия dimension-таблицы — зеркальная противоположность fact-таблицы, и это прямо следует из роли.
Короткая — строк относительно мало. Товаров десятки тысяч, магазинов сотни, стран пара сотен. Число строк dimension — это число сущностей этого разреза, а сущностей на порядки меньше, чем событий.
Широкая — столбцов много. Dimension денормализована и собирает всю описательную цепочку плюс атрибуты впрок — отсюда десятки и сотни столбцов.
Сравните с fact-таблицей — длинной и узкой. Асимметрия полная:
| Свойство | Fact-таблица | Dimension-таблица |
|---|---|---|
| Хранит | числовые measures | описательные атрибуты |
| Строк | очень много (миллиарды) | мало (число сущностей) |
| Столбцов | мало (FK + measures) | много (атрибуты, денормализовано) |
| Геометрия | длинная и узкая | короткая и широкая |
| Первичный ключ | составной (набор FK) | один surrogate key |
| В запросе | то, что суммируют | разрезы (GROUP BY) и фильтры (WHERE) |
| Нормализация | — | намеренно денормализована |
Из «короткая» следует практическое: dimension мала, поэтому экономить на ней столбцы незачем. Лишний описательный атрибут в таблице на 50 000 строк почти не весит, а аналитику даёт новый разрез. Это и есть причина, по которой dimensions делают щедрыми на атрибуты.
Качество dimension-атрибутов прямо определяет качество аналитики. Атрибуты должны быть человекочитаемыми: вместо кода status_cd = 3 — текст ‘Доставлен’; вместо флага 0/1 — осмысленное ‘Премиум’/‘Стандарт’. Аналитик увидит эти значения в отчёте напрямую. Расшифровка кодов в понятные слова — часть работы dimension: dimension говорит на языке бизнеса, а не на языке source-системы.
Date dimension — особый и обязательный случай
Одна dimension присутствует почти в каждой звезде — date dimension (dim_date). Время — самый частый разрез аналитики («по месяцам», «по кварталам», «выходные против будней»), и его всегда выносят в отдельную dimension со строкой на каждый день.
Почему не хранить дату прямо в fact-таблице обычным типом DATE? Потому что у даты огромное количество полезных разрезов, которые из голого DATE каждый раз пришлось бы вычислять: день недели, номер месяца, название месяца, квартал, год, флаг выходного, флаг праздника, флаг конца месяца, номер недели, фискальный период. dim_date хранит всё это готовыми столбцами:
CREATE TABLE dim_date (
date_key INT PRIMARY KEY, -- "умный" ключ: 20250704
full_date DATE NOT NULL,
day_of_week VARCHAR(12), -- 'Friday'
day_num INT,
month_num INT,
month_name VARCHAR(12), -- 'July'
quarter INT,
year INT,
is_weekend BOOLEAN,
is_holiday BOOLEAN,
fiscal_period VARCHAR(10)
);
Date dimension заполняется заранее, на годы вперёд, одним скриптом — даты предсказуемы. Любой временной разрез становится простым GROUP BY по готовому столбцу dim_date, без вычислений в запросе.
И одна узаконенная странность. У dim_date ключ обычно «умный» — integer вида 20250704 (год-месяц-день), а не бессмысленный surrogate. Это сознательное исключение из общего правила «surrogate key без смысла»: такой ключ читаем, естественно сортируется по хронологии, и по нему удобно партиционировать fact-таблицу. Date dimension — единственное место, где Кимбалл допускает осмысленный ключ. Подробнее date dimension и role-playing (одна dim_date в ролях order_date, ship_date) — в следующем модуле.
Попробуй сам
Возьмите dimension клиента для интернет-магазина. Спроектируйте dim_customer как следует.
Сначала ключи: surrogate key как PK и natural key из источника отдельным столбцом — выпишите оба и объясните роль каждого. Затем атрибуты — и здесь будьте щедры: выпишите минимум 12 описательных атрибутов клиента (сегмент, тип, город, регион, страна, возрастная группа, дата регистрации, канал привлечения, статус лояльности и так далее). Для каждого атрибута укажите, какой разрез или фильтр он даёт аналитику.
Затем сравните: вы только что денормализовали в одну таблицу то, что в OLTP-схеме было цепочкой customers -> cities -> regions -> countries плюс customer_segments. Объясните, почему это денормализация и почему здесь она не порождает update anomaly. И последнее: возьмите два «плохих» атрибута — код вместо текста (segment_cd = 2) и непонятный флаг (flag_7 = 1) — и перепишите их в человекочитаемый вид, объяснив, почему dimension обязана говорить на языке бизнеса.