Junk dimensions: схлопывание флагов низкой кардинальности
При проектировании fact-таблицы заказов всегда всплывает горстка мелких признаков: оплачен заказ или нет, был ли применён промокод, канал заказа (сайт/приложение/телефон), тип доставки (курьер/самовывоз/почта). Каждый признак — это два-три-четыре возможных значения. Куда их девать?
Соблазн первый: положить каждый признак прямо в fact-таблицу отдельным столбцом. Соблазн второй: завести под каждый отдельную dimension — dim_payment_status, dim_order_channel и так далее. Оба решения плохи. Junk dimension — третий путь: собрать все эти мелкие признаки в одну компактную dimension.
Кардинальность: ключевое понятие
Кардинальность атрибута — это число различных значений, которые он принимает. У атрибута payment_status кардинальность 2 (оплачен/не оплачен). У order_channel — 3. У customer_name кардинальность огромна — миллионы. Именно низкая кардинальность делает атрибут кандидатом в junk dimension.
Почему отдельная dimension под каждый низкокардинальный флаг — плохо? Dimension с двумя строками — это вырожденная таблица: накладные расходы на саму таблицу, на её ключ, на JOIN несоразмерны двум строкам полезных данных. Десяток таких флагов даёт десяток крошечных dimensions и десяток foreign keys в fact-таблице. Fact-таблица распухает по ширине, число JOIN в каждом аналитическом запросе растёт.
Почему держать флаги прямо в fact-таблице — тоже плохо? Fact-таблица должна быть узкой: foreign keys плюс measures, и ничего лишнего. Текстовые описательные столбцы ('Курьер', 'Самовывоз') раздувают каждую строку fact-таблицы, которых миллиарды. Описательные атрибуты по природе принадлежат dimensions, а не fact-таблице.
Разберём цену хранения флагов в fact-таблице количественно. Пусть в fct_orders миллиард строк, и мы добавили четыре текстовых флага — в среднем по 10 байт каждый. Это 40 байт на строку, умноженные на миллиард, — около 40 ГБ дополнительного объёма, который читается при каждом полном скане fact-таблицы. Если же эти флаги вынесены в junk dimension, в fact-таблице остаётся один order_flags_key — целое число шириной в пару байт. Разница в объёме fact-таблицы — десятки гигабайт, а fact-таблицу сканируют постоянно. Узость fact-таблицы — это не эстетика, а прямая экономия I/O на самой большой таблице warehouse.
Есть и второй аргумент, помимо объёма. Текстовые флаги в fact-таблице повторяются: значение 'Курьер' физически хранится в каждой из сотен миллионов строк с курьерской доставкой. В junk dimension оно хранится ровно один раз — в той строке, что описывает соответствующую комбинацию. Junk dimension убирает повтор описательных значений; fact-таблица ссылается на описание, а не носит его копию в каждой строке.
Как устроена junk dimension
Junk dimension — это одна таблица, в которой каждый столбец соответствует одному флагу, а каждая строка — одной встречающейся комбинации значений всех флагов. У неё свой surrogate key, на который fact-таблица ссылается одним-единственным foreign key.
Сначала DDL:
CREATE TABLE dim_order_flags (
order_flags_key SMALLINT PRIMARY KEY, -- surrogate key
payment_status VARCHAR(12), -- 'Оплачен' | 'Не оплачен'
order_channel VARCHAR(12), -- 'Сайт' | 'Приложение' | 'Телефон'
delivery_type VARCHAR(12), -- 'Курьер' | 'Самовывоз' | 'Почта'
promo_applied VARCHAR(3) -- 'Да' | 'Нет'
);
Теперь содержимое. Таблица хранит наблюдаемые комбинации — декартово произведение всех значений (или его часть, если какие-то сочетания невозможны):
order_flags_key | payment_status | order_channel | delivery_type | promo_applied
-----------------+----------------+---------------+---------------+--------------
1 | Оплачен | Сайт | Курьер | Да
2 | Оплачен | Сайт | Курьер | Нет
3 | Оплачен | Сайт | Самовывоз | Нет
4 | Не оплачен | Телефон | Почта | Нет
5 | Оплачен | Приложение | Курьер | Да
... | ... | ... | ... | ...
Полное декартово произведение здесь: 2 * 3 * 3 * 2 = 36 комбинаций. Тридцать шесть строк — это весь объём таблицы. Fact-таблица на миллиард заказов ссылается на эти 36 строк одним столбцом order_flags_key.
Загрузка fact-таблицы с junk dimension
При загрузке заказа надо найти строку junk dimension, соответствующую его комбинации флагов, и взять её ключ. Если такой комбинации ещё нет — добавить.
-- Шаг 1: добавить недостающие комбинации (если появилась новая)
INSERT INTO dim_order_flags
(order_flags_key, payment_status, order_channel, delivery_type, promo_applied)
SELECT
(SELECT COALESCE(MAX(order_flags_key), 0) FROM dim_order_flags)
+ ROW_NUMBER() OVER (ORDER BY s.payment_status),
s.payment_status, s.order_channel, s.delivery_type, s.promo_applied
FROM (
SELECT DISTINCT payment_status, order_channel, delivery_type, promo_applied
FROM staging_orders
) s
LEFT JOIN dim_order_flags d
ON d.payment_status = s.payment_status
AND d.order_channel = s.order_channel
AND d.delivery_type = s.delivery_type
AND d.promo_applied = s.promo_applied
WHERE d.order_flags_key IS NULL;
-- Шаг 2: загрузить fct_orders, подставив order_flags_key через JOIN
INSERT INTO fct_orders (order_key, customer_key, date_key, order_flags_key, order_amount)
SELECT s.order_key, s.customer_key, s.date_key, d.order_flags_key, s.order_amount
FROM staging_orders s
JOIN dim_order_flags d
ON d.payment_status = s.payment_status
AND d.order_channel = s.order_channel
AND d.delivery_type = s.delivery_type
AND d.promo_applied = s.promo_applied;
Шаг 1 находит комбинации флагов, которых ещё нет в dimension (LEFT JOIN ... WHERE ключ IS NULL), и добавляет их. Шаг 2 присоединяет junk dimension по всем четырём флагам и кладёт в fact-таблицу только её ключ. После загрузки fact-таблица не содержит ни одного текстового флага — только узкий order_flags_key.
Ключевой приём здесь — LEFT JOIN ... WHERE d.order_flags_key IS NULL. Это стандартный способ найти «то, чего ещё нет». LEFT JOIN сохраняет все строки из левого набора (различные комбинации флагов в staging) и подставляет NULL в столбцы правой таблицы там, где совпадения не нашлось. Условие IS NULL оставляет ровно те комбинации, для которых строки в dim_order_flags пока не существует. Их и нужно вставить. Этот паттерн — «найти отсутствующие через LEFT JOIN и фильтр IS NULL» — пригодится вам при загрузке почти любой dimension, не только junk.
Шаг 2 заслуживает отдельного внимания. JOIN идёт по всем четырём флагам сразу — это обязательно. Junk dimension уникальна по комбинации, и чтобы найти ровно одну нужную строку, надо сопоставить все составляющие комбинации. Если присоединять по части флагов, JOIN вернёт несколько строк junk dimension, и fact-таблица получит неоднозначный ключ — а то и размножится. Полное совпадение по всем флагам гарантирует, что каждой строке staging соответствует ровно одна строка junk dimension.
Junk dimension можно заполнить заранее полным декартовым произведением всех значений всех флагов. Тогда шаг 1 не нужен совсем — все комбинации уже есть. Это работает, когда число комбинаций невелико (десятки-сотни) и набор значений каждого флага известен. Если флагов много и часть комбинаций нереальна — заполняйте по факту, как в примере выше.
Когда junk dimension оправдана, а когда нет
Junk dimension — инструмент для атрибутов, у которых одновременно: низкая кардинальность (единицы-десятки значений), описательный характер (флаги, индикаторы, мелкие категории), отсутствие собственных атрибутов (у payment_status нет «своих» свойств, которые надо было бы хранить).
Где junk dimension НЕ подходит:
- Высокая кардинальность. Если объединить четыре флага по 2-3 значения с атрибутом на 1000 значений, декартово произведение взорвётся до десятков тысяч строк — это уже не junk dimension. Высококардинальный атрибут заслуживает своей dimension.
- У атрибута есть собственные свойства. Если у «канала заказа» появляются свои атрибуты (стоимость канала, ответственная команда), это полноценная
dim_channel, а не флаг в мусорной dimension. - Взрыв комбинаций. Десять флагов по три значения дают теоретически 3^10 ≈ 59 000 комбинаций. Если реально встречается лишь малая часть — junk dimension всё ещё годится (храним только встречающиеся строки). Если встречается большинство — пересмотрите дизайн.
Различие между «теоретическим» и «реальным» числом комбинаций здесь принципиально. Декартово произведение даёт верхнюю границу — все мыслимые сочетания флагов. Но в реальных данных многие сочетания не встречаются никогда: например, комбинация «не оплачен + самовывоз + промокод применён» может быть бизнес-невозможной. Поэтому на практике junk dimension часто заполняют не полным произведением, а по факту — добавляя комбинацию лишь тогда, когда она впервые встретилась в данных (как в шаге 1 загрузки выше). Тогда таблица содержит ровно столько строк, сколько реальных сочетаний есть, и остаётся компактной даже при формально большом декартовом произведении.
| Признак атрибута | Junk dimension | Собственная dimension |
|---|---|---|
| Кардинальность 2-10 | [x] | |
| Кардинальность сотни-тысячи | [x] | |
| Нет собственных атрибутов | [x] | |
| Есть собственные атрибуты | [x] | |
| Описательный флаг/индикатор | [x] |
Полезно понимать и обратную сторону решения. Junk dimension не обязана быть одна на fact-таблицу. Если флагов много и они логически распадаются на несвязанные группы — скажем, отдельно «обстоятельства оплаты» и отдельно «обстоятельства доставки», — иногда заводят две junk dimensions вместо одной огромной. Это компромисс: две junk dimensions дают два FK вместо одного, зато каждая остаётся компактной и осмысленной, а декартово произведение не перемножает несвязанные между собой группы флагов. Решение «одна большая или несколько junk dimensions» принимают по числу комбинаций и по тому, насколько группы флагов независимы друг от друга.
Слово “junk” (мусор) в названии вводит в заблуждение. Это не «свалка для плохих данных» — это аккуратный инженерный приём. Сами флаги полезны для анализа: по junk dimension отлично делается срез «выручка по каналам заказа и типам доставки». «Мусорным» называют не данные, а тот факт, что эти атрибуты не тянули на отдельные dimensions и их собрали вместе.
Стоит увидеть, как junk dimension связана с другими приёмами этого модуля. Junk dimension — это, по сути, та же идея, что и conformed dimension и role-playing dimension: бороться за то, чтобы fact-таблица оставалась узкой и опрятной. Conformed dimension убирает дублирование справочников между fact-таблицами; role-playing убирает дублирование dimension при нескольких ролях; junk dimension убирает россыпь мелких FK и текстовых столбцов внутри fact-таблицы. Все три служат одной цели — fact-таблица должна нести foreign keys и measures, и ничего лишнего, а описательная нагрузка должна жить в dimensions.
И ещё одно практическое наблюдение. Junk dimension особенно уместна в transaction fact-таблицах — тех, где строка соответствует одному событию (заказ, продажа, обращение). У событий обычно есть набор «обстоятельств» — флагов, описывающих, как именно произошло событие. Эти обстоятельства редко имеют собственную структуру и редко высококардинальны — идеальные кандидаты в junk dimension. Когда вы проектируете transaction fact-таблицу и видите, что к ней «прилипает» горстка флагов, первая мысль должна быть: собрать их в junk dimension, а не размазывать по fact-таблице.
Попробуй сам
Возьмите fact-таблицу обращений в поддержку fct_support_tickets. У обращения есть мелкие признаки: приоритет (низкий/средний/высокий), решено ли с первого контакта (да/нет), канал обращения (чат/почта/телефон), был ли эскалирован (да/нет).
- Посчитайте кардинальность каждого признака и общее число комбинаций (декартово произведение).
- Напишите
CREATE TABLE dim_ticket_flagsс surrogate key и четырьмя столбцами. - Прикиньте: сколько строк будет в junk dimension и сколько foreign keys эта dimension убирает из
fct_support_tickets. - Добавьте к набору признак «оператор» (50 разных операторов). Должен ли он войти в junk dimension? Обоснуйте ответ через кардинальность.
Проверьте себя: после введения junk dimension сколько описательных текстовых столбцов осталось в самой fact-таблице? Должно быть ноль.