Learning Platform
Глоссарий Troubleshooting
Урок 13.05 · 19 мин
Начальный
factless-factdegenerate-dimensionlate-arriving-factsfact-table

Factless fact tables, degenerate dimensions, late-arriving facts

Четыре прошлых урока модуля разобрали fact-таблицы в их типичном виде: три типа, классы measures. Этот завершающий урок собирает три специальных случая, с которыми вы столкнётесь в реальной работе и которые не укладываются в «обычную» картину: factless fact tables (таблицы фактов без фактов), degenerate dimensions (вырожденные измерения) и late-arriving facts (запоздавшие факты).

Это не экзотика — все три встречаются в production-системах постоянно. Factless fact table нужна, когда событие важно само по себе, без чисел. Degenerate dimension — почти в каждой fact-таблице продаж. А late-arriving facts — частая и болезненная проблема загрузки данных, которую обязан понимать любой data engineer.

Factless fact table: событие без measures

Прошлые уроки утверждали: fact-таблица состоит из foreign keys и measures. Но бывает fact-таблица без measures вообще — только foreign keys. Она называется factless fact table — таблица фактов без фактов.

Звучит как противоречие, но смысл прост: иногда сам факт того, что событие произошло, и есть вся информация. Измерять нечего — важно лишь, что определённое сочетание dimensions встретилось.

Классический пример — посещаемость занятий. Студент пришёл на занятие. Что тут измерять? Никакой суммы, никакого количества — событие либо было, либо нет. Fact-таблица посещений:

-- Factless fact table: событие посещения, measures нет
CREATE TABLE fct_attendance (
    date_key     INT NOT NULL REFERENCES dim_date(date_key),
    student_key  INT NOT NULL REFERENCES dim_student(student_key),
    course_key   INT NOT NULL REFERENCES dim_course(course_key),
    teacher_key  INT NOT NULL REFERENCES dim_teacher(teacher_key)
    -- measures нет: важен сам факт, что студент был на занятии
);

Каждая строка означает «такой-то студент был на таком-то занятии такого-то курса в такой-то день». Чисел нет. Но таблица отлично отвечает на аналитические вопросы — через COUNT строк вместо SUM measures:

-- посещаемость считается через COUNT строк
SELECT c.course_name, COUNT(*) AS visits
FROM fct_attendance f
JOIN dim_course c ON f.course_key = c.course_key
GROUP BY c.course_name ORDER BY visits DESC;

-- сколько разных студентов посетило курс
SELECT c.course_name, COUNT(DISTINCT f.student_key) AS unique_students
FROM fct_attendance f
JOIN dim_course c ON f.course_key = c.course_key
GROUP BY c.course_name;

В factless fact table роль measure играет сама строка: COUNT(*) — это «сколько раз событие произошло». Это первый вид factless-таблиц — фиксация события.

Есть и второй вид — факт-таблица покрытия (coverage). Она фиксирует не то, что произошло, а то, что могло произойти / было возможно. Пример: таблица «какие товары участвовали в какой промоакции». Строка означает «товар X был включён в промоакцию Y» — независимо от того, купили его по этой акции или нет. Зачем такая таблица? Чтобы отвечать на вопросы про отсутствие. Транзакционная fct_sales показывает проданные по акции товары; coverage-таблица показывает все товары акции; их разность — товары, которые были в акции, но не продались ни разу. Без coverage-таблицы «что не произошло» вычислить нельзя — отсутствие события не записано (вспомните разреженность transaction fact table из первого урока модуля).

Два вида factless fact table
Фиксация событияСтрока означает что событие произошло: студент посетил занятие. Анализ через COUNT строк
COUNT(*)
Сколько событийРоль measure играет сама строка; COUNT строк отвечает сколько раз событие случилось
Покрытие (coverage)Строка означает что событие было возможно: товар включён в промоакцию
разность с fct_sales
Что НЕ произошлоСравнение покрытия с фактическими продажами выявляет что было возможно, но не случилось

Degenerate dimension: dimension без таблицы

Второй специальный случай уже упоминался в модуле 11 — здесь разберём подробнее. Degenerate dimension (вырожденная dimension) — это dimension-атрибут, который хранится прямо в fact-таблице, без отдельной dimension-таблицы.

Вспомним устройство звезды: dimension-атрибуты живут в dimension-таблицах, fact ссылается на них foreign key. Но есть атрибуты, для которых отдельная таблица не нужна. Классический — номер чека / заказа / транзакции / накладной.

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

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

CREATE TABLE fct_sales (
    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),
    -- degenerate dimension: номер заказа прямо здесь, БЕЗ dim_order
    order_number  VARCHAR(20) NOT NULL,
    quantity         INT           NOT NULL,
    extended_amount  NUMERIC(12,2) NOT NULL
);

Зачем degenerate dimension вообще хранить, если у неё нет атрибутов? Она по-прежнему полезна для группировки. order_number позволяет собрать все позиции одного заказа: GROUP BY order_number отвечает на «сколько позиций в заказе», «какова сумма всего заказа», «сколько в среднем товаров на заказ». Degenerate dimension — это связующий идентификатор: он группирует строки fact-таблицы, относящиеся к одной операции.

Типичные degenerate dimensions: номер заказа, номер чека, номер транзакции, номер накладной, номер билета, номер полиса. Все — идентификаторы операций из source-системы, без собственных атрибутов. Наличие такого столбца прямо в fact-таблице — не нарушение дизайна, а штатный приём.

Late-arriving facts: запоздавшие факты

Третий специальный случай — самый практически важный, и именно ему стоит уделить больше всего внимания. Late-arriving fact (запоздавший факт) — это факт о событии, который приходит в warehouse с опозданием: уже после того, как контекст этого события, нужный для его загрузки, оказался обработан. Это частая и болезненная проблема production-пайплайнов.

В чём проблема

Чтобы понять late-arriving facts, нужно вспомнить нормальный порядок загрузки. Идеальный мир: сначала в warehouse попадает контекст события (строки dimensions), потом — само событие (строка fact), которое на этот контекст ссылается. Пайплайн каждый день грузит новые dimensions, затем новые facts; каждый факт находит свои dimension-строки и ставит на них foreign keys.

Реальный мир ломает этот порядок двумя способами.

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

Чем это опасно. Если в fact-таблицу строки добавляются по дате загрузки, запоздавшая продажа за 3 июля попадёт в данные 8 июля. Отчёт «выручка за 3 июля», построенный 5 июля, не увидит её — и будет занижен. А отчёт, перестроенный 9 июля, покажет за 3 июля уже другое число. Цифры за закрытый период «едут» задним числом.

Способ второй — факт пришёл раньше своего dimension (late-arriving dimension). Это зеркальная и более коварная проблема. Событие ссылается на dimension-сущность, которой в warehouse ещё нет. Пример: первая в истории покупка нового клиента. Транзакция уже в потоке продаж, а строка этого клиента в dim_customer ещё не загружена — пайплайн dimension отстал. Факт прибыл, а контекста, на который он обязан сослаться foreign key, нет.

Чем это опасно. Foreign key fact-таблицы — NOT NULL (модуль 11). Сослаться не на что — и у пайплайна загрузки три плохих варианта: упасть с ошибкой нарушения целостности; отбросить строку (продажа потеряна, выручка занижена); поставить NULL (нарушение NOT NULL, а при INNER JOIN строка молча выпадет из отчётов). Все три — порча данных.

Late-arriving fact: нарушенный порядок загрузки
НормаСначала dimension-контекст, потом факт, который на него ссылается foreign key
порядок соблюдён
Факт находит контекстForeign key fact-таблицы указывает на уже существующую строку dimension
Late-arrivingФакт приходит, а его dimension-контекст ещё не загружен или период уже закрыт
порядок нарушен
Не на что сослатьсяForeign key NOT NULL, а строки dimension нет — пайплайн вынужден решать проблему

Как это решают

Зрелые пайплайны не позволяют ни ронять загрузку, ни терять строки. Применяют стандартный набор приёмов.

Inferred member (placeholder-строка в dimension). Главный приём для случая «факт раньше dimension». Когда факт ссылается на ещё не существующую dimension-сущность, пайплайн на лету создаёт в dimension строку-заглушку — inferred member («выведенный участник»): новый surrogate key, natural key из факта, а описательные атрибуты — пустые или помеченные «неизвестно». Факт сразу ставит foreign key на эту заглушку — целостность сохранена, строка не потеряна, загрузка не падает. Когда настоящие данные о сущности позже приходят обычным пайплайном dimension, заглушка дополняется реальными атрибутами по natural key (часто как SCD Type 1 — перезапись). Факт всё это время указывает на тот же surrogate key — связь не рвётся.

Датирование по дате события, а не по дате загрузки. Главный приём против «едущих» цифр. Запоздавшая продажа за 3 июля должна получить date_key, соответствующий 3 июля — дате, когда событие реально произошло, а не дате загрузки. Тогда после загрузки запоздавшего факта SUM за 3 июля честно учитывает и его. Цифра за период не «едет» произвольно — она просто становится полнее и корректнее, когда наверстываются опоздавшие события. Дата события — часть самого факта, дата загрузки — нет.

Пересчёт затронутого периода. Раз запоздавший факт меняет уже посчитанные итоги прошлых дней, отчёты и агрегаты за затронутый период нужно пересчитать. Здесь окупается ELT-архитектура из модуля 10: сырьё сохранено, поэтому пересчёт периода — это просто повторный запуск трансформации; данные прошлых дней пересобираются из сырья с учётом догрузки.

Окно поздних данных (late-arriving window). Практический компромисс. Пайплайн не считает период «окончательно закрытым» сразу: для типичных задержек держат окно (например, 3-7 дней), в течение которого данные дня ещё могут дополняться запоздавшими фактами и пересчитываться. По истечении окна период фиксируют. Окно подбирают под реальный профиль опозданий конкретных источников.

WARNING

Опаснее всего — тихий вариант обработки late-arriving fact: отбросить строку без контекста или поставить NULL в foreign key. Запоздавшая продажа просто исчезает из отчётов, выручка занижена, и никакой ошибки нет — данные молча неполны. Inferred member решает это: факт всегда получает валидную ссылку (пусть пока на заглушку), ни одно событие не теряется, а позже заглушка дополняется реальными атрибутами.

Как dbt-тесты обнаруживают поздние факты без inferred member
СлучайЧто произошлоСтандартное решение
Факт опоздал относительно dimensionсобытие случилось давно, данные дошли сейчасдатировать по дате события, пересчитать период, окно поздних данных
Факт пришёл раньше dimensiondimension-контекст ещё не загруженinferred member: строка-заглушка, позже дополняется
Период уже считался закрытымзапоздавший факт меняет прошлые итогиокно late-arriving, пересчёт затронутых агрегатов из сохранённого сырья

Late-arriving facts — не редкая аномалия, а повседневная реальность распределённых источников: офлайн-устройства, пакетные интеграции, сбои связи гарантируют опоздания. Зрелость пайплайна во многом и определяется тем, корректно ли он их обрабатывает: датирует по событию, создаёт inferred members, держит окно поздних данных и пересчитывает затронутые периоды — вместо того чтобы ронять загрузку или молча терять строки.

Попробуй сам

Часть первая, factless fact table. Спроектируйте factless-таблицу для онлайн-курса: событие — «студент открыл урок». Выпишите foreign keys, объясните, почему measures нет, и напишите два запроса через COUNT (сколько открытий у урока; сколько уникальных студентов). Затем спроектируйте coverage-таблицу «какие уроки входят в курс» и объясните, как сравнением с таблицей открытий найти уроки, которые не открыл ни один студент.

Часть вторая, degenerate dimension. В fact-таблице бронирований авиабилетов есть номер бронирования. Объясните, почему он degenerate dimension, почему для него не нужна dim_booking, и напишите запрос с GROUP BY по номеру бронирования (среднее число билетов в одном бронировании).

Часть третья, late-arriving facts. Опишите конкретный сценарий: офлайн-касса магазина три дня не имела связи и затем выгрузила все продажи пакетом. Ответьте: какой date_key должны получить эти продажи и почему именно дату события; что произойдёт с отчётом «выручка по дням», уже построенным за эти три дня, и как это починить; и что делать, если среди запоздавших продаж есть покупка нового клиента, которого ещё нет в dim_customer, — опишите механику inferred member по шагам.


Проверка знанийKnowledge check
Что такое late-arriving fact, какие две формы у этой проблемы и как её правильно решают?
ОтветAnswer
Late-arriving fact (запоздавший факт) — это факт о событии, который приходит в warehouse с опозданием, уже после того как нужный для его загрузки контекст оказался обработан. Это частая проблема production-пайплайнов, потому что распределённые источники неизбежно опаздывают: офлайн-кассы синхронизируются с задержкой, партнёрские системы шлют данные пакетами, интеграции ломаются и наверстывают задним числом, мобильные приложения копят события офлайн. У проблемы две формы. Первая — факт опоздал относительно своего dimension-контекста: событие физически произошло несколько дней назад, а данные дошли только сейчас; если строки fact-таблицы датировать по дате загрузки, запоздавшая продажа за 3 июля попадёт в данные 8 июля, и отчёт за 3 июля будет занижен, а потом цифра за закрытый период поедет задним числом. Вторая, более коварная форма — late-arriving dimension: факт пришёл раньше своего dimension, ссылается на сущность, которой в warehouse ещё нет (первая покупка нового клиента, которого ещё нет в dim_customer); foreign key fact-таблицы NOT NULL, сослаться не на что, и у пайплайна три плохих варианта — упасть с ошибкой целостности, отбросить строку (продажа потеряна), поставить NULL (строка молча выпадет при INNER JOIN). Решают это набором приёмов. Inferred member — главный приём для случая "факт раньше dimension": пайплайн на лету создаёт в dimension строку-заглушку с новым surrogate key, natural key из факта и пустыми атрибутами; факт сразу ставит foreign key на неё, целостность сохранена и строка не потеряна; когда настоящие данные о сущности позже приходят, заглушка дополняется реальными атрибутами по natural key, а факт всё время указывает на тот же surrogate key. Датирование по дате события, а не дате загрузки — запоздавший факт получает date_key реальной даты события, поэтому SUM за период честно его учитывает и цифра становится полнее, а не едет произвольно. Пересчёт затронутого периода из сохранённого сырья (окупается ELT-архитектура) и окно поздних данных — период не считают окончательно закрытым сразу, держат окно в несколько дней для догрузки и пересчёта. Опаснее всего тихий вариант — отбросить строку или поставить NULL: данные молча неполны без всякой ошибки.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Что такое factless fact table и как по ней проводят анализ?

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

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

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

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