Learning Platform
Глоссарий Troubleshooting
Урок 12.04 · 17 мин
Начальный
fact-tablemeasuresforeign-keyskimball

Fact table: foreign keys + measures

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

Fact-таблица — рабочая лошадь аналитической модели. Она хранит сами события бизнеса, она самая большая по объёму, и почти каждый аналитический запрос начинается с неё. Понять её анатомию точно — значит понять, как читается и считается аналитика.

Два сорта столбцов

Главная мысль урока проста: в правильной fact-таблице есть столбцы ровно двух сортов — foreign keys и measures. Третьего сорта в норме нет (одно исключение — degenerate dimension, о нём дальше).

  • Foreign keys — ссылки на dimension-таблицы. Они отвечают на вопросы «когда, что, кто, где» — то есть несут контекст события.
  • Measures — числовые показатели события: «сколько, на какую сумму, как долго». То, что измеряют и суммируют.

Эти два сорта — прямое следствие разделения «измерять и описывать» из первого урока модуля. Контекст события вынесен в dimensions, и fact ссылается на него foreign keys. Числа события живут прямо в fact как measures. Всё, что описывает событие словами, — в dimensions; всё, что измеряет его числами, — в fact.

CREATE TABLE fct_sales (
    -- СОРТ 1: foreign keys — ссылки на dimensions (контекст)
    date_key      INT NOT NULL REFERENCES dim_date(date_key),
    product_key   INT NOT NULL REFERENCES dim_product(product_key),
    customer_key  INT NOT NULL REFERENCES dim_customer(customer_key),
    store_key     INT NOT NULL REFERENCES dim_store(store_key),
    promotion_key INT NOT NULL REFERENCES dim_promotion(promotion_key),

    -- degenerate dimension: номер чека хранится прямо здесь
    receipt_number VARCHAR(20) NOT NULL,

    -- СОРТ 2: measures — числа события
    quantity         INT           NOT NULL,
    unit_price       NUMERIC(10,2) NOT NULL,
    extended_amount  NUMERIC(12,2) NOT NULL,
    discount_amount  NUMERIC(12,2) NOT NULL
);
Анатомия fact-таблицы: два сорта столбцов
Foreign keysСсылки на dimensions: дата, товар, клиент, магазин, промоакция. Несут контекст события
MeasuresЧисловые показатели события: количество, цена, сумма, скидка. То, что суммируют

Foreign keys: сборка foreign keys

Набор foreign keys fact-таблицы выбирается на третьем шаге процесса — это и есть выбранные dimensions. Каждая dimension, которой можно описать событие выбранного grain, даёт один foreign key.

Несколько важных свойств foreign keys в fact-таблице:

Это surrogate keys, а не natural keys. Курс уже разбирал surrogate keys в модуле про ключи. В размерной модели правило строгое: fact ссылается на dimension через surrogate key — узкий бессмысленный integer, а не через natural key вроде артикула товара или email клиента. Причины — те же, что обсуждались: surrogate key узкий и фиксированной ширины, поэтому JOIN по нему быстрее и индекс компактнее; он стабилен и изолирует fact от изменений в источнике; и, как покажет модуль про SCD, surrogate key — это то, что связывает строку fact с конкретной исторической версией dimension.

Они NOT NULL. У каждого события есть контекст по каждому разрезу. У продажи есть дата, товар, магазин — всегда. Поэтому foreign keys в fact-таблице объявляют NOT NULL.

А что, если контекст по какому-то разрезу неизвестен — товар без указанной промоакции, например? Здесь действует важный приём: не NULL, а специальная строка-заглушка в dimension. В dim_promotion заводят строку с ключом, означающим «без промоакции» (часто promotion_key = -1 или 0), и fact ссылается на неё. Так foreign key остаётся NOT NULL, а JOIN не теряет строки (NULL в ключе соединения отбрасывает строку при INNER JOIN). Это стандартный паттерн: неизвестный контекст — это не отсутствие ссылки, а ссылка на dimension-строку «неизвестно».

NOTE

Почему NULL в foreign key fact-таблицы вреден: INNER JOIN со строкой, где ключ NULL, не находит совпадения и молча выбрасывает строку из результата. Событие пропадает из отчёта, и сумма занижается незаметно. Строка-заглушка «неизвестно» в dimension решает это: foreign key всегда указывает на реальную строку, ни одно событие не теряется, а в отчёте честно видна категория «неизвестно».

Measures: числа события

Measures выбираются на четвёртом шаге процесса. Это числовые показатели, измеримые на уровне одной строки grain.

Какими бывают measures fact-таблицы продаж:

  • quantity — количество единиц товара.
  • unit_price — цена за единицу.
  • extended_amount — сумма по позиции (количество умножить на цену).
  • discount_amount — сумма скидки.

Главное правило measures, которое уже звучало и которое разовьёт следующий модуль: в fact кладут сырые аддитивные числа, а не предвычисленные средние и проценты. quantity, extended_amount, discount_amount — это слагаемые, их можно корректно суммировать по любым разрезам. А вот средний чек или процент скидки в fact не хранят — их вычисляет запрос из аддитивных слагаемых: SUM(discount_amount) / SUM(extended_amount). Причина: среднее средних не равно общему среднему, отношение отношений не равно общему отношению. Сохранённый процент сложить нельзя — а сохранённую сумму можно. Подробная классификация measures на additive, semi-additive и non-additive — тема следующего модуля; пока держите принцип: в fact — аддитивные слагаемые.

Бывает и fact-таблица вообще без measures — она называется factless fact table и фиксирует сам факт события (студент посетил занятие). Это валидный частный случай, и ему тоже посвящён отдельный урок следующего модуля.

Degenerate dimension: единственное исключение

Выше сказано: в fact-таблице столбцы двух сортов. Есть одно узаконенное исключение — degenerate dimension (вырожденное измерение).

Посмотрите на receipt_number в DDL выше — номер чека. Это идентификатор операции из source-системы. По смыслу он dimension-атрибут: он описывает событие, по нему можно группировать («сколько позиций в одном чеке»). Но у него нет собственных описательных атрибутов — у номера чека нет ни «названия», ни «категории», ни «типа». Заводить ради него отдельную таблицу dim_receipt бессмысленно: эта таблица состояла бы из одного столбца, дублирующего ключ.

Поэтому такой атрибут хранят прямо в fact-таблице, без отдельной dimension-таблицы. Он называется degenerate dimension — «вырожденное», потому что это dimension, выродившаяся до одного только ключа, которому не нужна таблица. Типичные degenerate dimensions: номер чека, номер заказа, номер транзакции, номер накладной. Подробнее — в модуле про fact-таблицы; здесь важно знать, что наличие receipt_number прямо в fact — это не нарушение правила «два сорта столбцов», а его законное расширение.

Что попадает в fact, а что выносится в dimension
Числа событияКоличество, сумма, скидка — measures
прямо в fact
Fact-таблицаХранит measures и foreign keys, плюс degenerate dimensions
Описания контекстаНазвание товара, категория, имя клиента — описательные атрибуты
в dimension, ссылка по FK
Dimension-таблицыХранят описательные атрибуты; fact ссылается на них surrogate-ключами
ID операции без атрибутовНомер чека: dimension-по-смыслу, но без собственных атрибутов
прямо в fact, без таблицы
Fact-таблицаDegenerate dimension хранится прямо в fact — отдельная таблица для неё не нужна

Чего в fact-таблице быть не должно

Полезно явно перечислить, что в правильную fact-таблицу не кладут. Это и есть типичные ошибки джуниоров.

Описательные текстовые атрибуты. Название товара, имя клиента, название города в самой fact-таблице — ошибка. Это место dimensions. Положив product_name прямо в fct_sales, вы дублируете длинную строку в каждой из миллиардов строк fact — раздувание объёма — и теряете единое место для управления этим атрибутом. Описания — в dimension, fact ссылается ключом.

Предвычисленные средние и проценты. Как разобрано выше: в fact аддитивные слагаемые, отношения считает запрос.

Агрегаты не того grain. «Сумма всего чека» в строке-позиции чека — нарушение grain (прошлый урок): при суммировании задвоится. В fact только числа своего зерна.

Строки разного grain вперемешку. Смешанный grain — критическая ошибка из прошлого урока. Все строки fact-таблицы одного зерна.

Сорт столбцаКласть в fact?Почему
Foreign key (surrogate) на dimensionданесёт контекст события
Measure — аддитивное числодато, что суммируют
Degenerate dimension (номер чека/заказа)даdimension без своих атрибутов, таблица не нужна
Описательный текст (название, имя)нетместо dimension; иначе дублирование
Предвычисленное среднее или процентнетнеаддитивно; считает запрос
Число чужого grainнетзадвоится при суммировании

Физический портрет: длинная и узкая

Соберём анатомию в физический образ. Fact-таблица состоит из горстки foreign keys (узкие integer) плюс горстки measures (числа) плюс изредка degenerate dimension. Это десяток-другой столбцов — таблица узкая. И одна строка на каждое событие бизнеса — событий миллионы и миллиарды, таблица длинная.

Длинная и узкая — снова идеальный профиль для колоночного хранилища из прошлого модуля. Foreign keys — это узкие integer с большим повторением значений (тот же store_key встречается в миллионах продаж), и dictionary с RLE сжимают их в разы. Measures — числовые столбцы, тоже хорошо сжимаются. Аналитический запрос читает немного столбцов fact с агрегацией — ровно то, в чём column-store силён. Анатомия fact-таблицы (мало столбцов, много строк, всё числовое) и физика колоночного warehouse подогнаны друг к другу: одно объясняет, почему другое работает.

Parquet и физика хранения узкой fact-таблицы Первичный ключ в ClickHouse и физика fact-таблицы
TIP

Быстрая проверка fact-таблицы на собеседовании или код-ревью: пройдитесь по столбцам. Каждый обязан быть либо foreign key на dimension, либо числовым measure, либо degenerate dimension. Нашли текстовое описание (название, имя, адрес) — оно должно быть в dimension. Нашли предвычисленный процент — он должен считаться запросом. Два сорта столбцов плюс изредка третий — таков чистый дизайн.

Попробуй сам

Возьмите бизнес-процесс «отгрузка заказа со склада», grain «одна позиция в одной отгрузке». Спроектируйте fact-таблицу fct_shipments.

Сначала выпишите foreign keys: на какие dimensions ссылается отгрузка позиции (дата отгрузки, товар, склад, клиент, способ доставки). Все ли они NOT NULL — и что делать, если способ доставки для какой-то строки неизвестен? Затем выпишите measures: что числового измеримо на уровне одной позиции отгрузки (количество, вес, объём, стоимость доставки). Подумайте, есть ли тут degenerate dimension — например, номер накладной.

Затем намеренно добавьте в таблицу три «неправильных» столбца — описательный текст, предвычисленное среднее и число чужого grain — и для каждого объясните, почему он не должен быть в fact и куда должен переехать. Это упражнение на распознавание самых частых ошибок проектирования fact-таблицы.


Проверка знанийKnowledge check
Из каких сортов столбцов состоит правильная fact-таблица, почему её foreign keys — это surrogate keys и NOT NULL, и что такое degenerate dimension?
ОтветAnswer
Правильная fact-таблица состоит из столбцов ровно двух сортов: foreign keys — ссылки на dimension-таблицы, несущие контекст события (когда, что, кто, где); и measures — числовые показатели события (сколько, на какую сумму, как долго), то есть то, что суммируют. Это прямое следствие разделения "измерять и описывать": описания контекста вынесены в dimensions, числа живут в fact. Foreign keys в fact-таблице — это surrogate keys, а не natural keys, потому что surrogate key узкий и фиксированной ширины (JOIN по нему быстрее, индекс компактнее), стабилен (изолирует fact от изменений в источнике) и связывает строку fact с конкретной исторической версией dimension. Они объявляются NOT NULL, потому что у каждого события есть контекст по каждому разрезу; если контекст по разрезу неизвестен, в fact кладут не NULL, а ссылку на специальную строку-заглушку "неизвестно" в dimension — иначе NULL в ключе соединения молча выбросит строку при INNER JOIN и занизит сумму. Degenerate dimension — единственное узаконенное исключение из правила двух сортов: это идентификатор операции из источника (номер чека, заказа, накладной), который по смыслу dimension-атрибут, но не имеет собственных описательных атрибутов, поэтому отдельная таблица для него бессмысленна и он хранится прямо в fact-таблице. В fact не кладут описательный текст (место dimension), предвычисленные средние и проценты (их считает запрос из аддитивных слагаемых) и числа чужого grain (задвоятся при суммировании). Получается длинная и узкая таблица — идеальный профиль для колоночного хранилища.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Из каких сортов столбцов состоит правильная fact-таблица?

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

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

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

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