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 из первого урока модуля).
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 строка молча выпадет из отчётов). Все три — порча данных.
Как это решают
Зрелые пайплайны не позволяют ни ронять загрузку, ни терять строки. Применяют стандартный набор приёмов.
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 дней), в течение которого данные дня ещё могут дополняться запоздавшими фактами и пересчитываться. По истечении окна период фиксируют. Окно подбирают под реальный профиль опозданий конкретных источников.
Опаснее всего — тихий вариант обработки late-arriving fact: отбросить строку без контекста или поставить NULL в foreign key. Запоздавшая продажа просто исчезает из отчётов, выручка занижена, и никакой ошибки нет — данные молча неполны. Inferred member решает это: факт всегда получает валидную ссылку (пусть пока на заглушку), ни одно событие не теряется, а позже заглушка дополняется реальными атрибутами.
| Случай | Что произошло | Стандартное решение |
|---|---|---|
| Факт опоздал относительно dimension | событие случилось давно, данные дошли сейчас | датировать по дате события, пересчитать период, окно поздних данных |
| Факт пришёл раньше dimension | dimension-контекст ещё не загружен | 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 по шагам.