Learning Platform
Глоссарий Troubleshooting
Урок 11.02 · 18 мин
Начальный
normalizationjoinsolapdenormalization

Почему нормализованная схема плоха для аналитики

Прошлые модули убедительно доказывали: нормализация — это хорошо. Третья нормальная форма убирает аномалии вставки, обновления и удаления, каждый факт хранится ровно один раз, целостность поддерживается foreign keys. Всё верно — для OLTP. Этот урок показывает обратную сторону медали: та же самая нормализованная схема делает аналитические запросы медленными, громоздкими и хрупкими. Не из-за ошибки проектирования, а потому что нормализация решает задачу записи, а аналитика — это задача чтения.

Понять этот конфликт — значит понять, зачем вообще существует размерное моделирование. Star schema из следующего модуля — это не «другой стиль», это прямой ответ на проблемы, которые мы сейчас разберём.

Что делает нормализация с данными

Нормализация дробит данные на много узких таблиц, чтобы исключить избыточность. Каждая сущность — в своей таблице, связи — через foreign keys. Возьмём типичную нормализованную OLTP-схему интернет-магазина.

-- Нормализованная схема: каждый факт хранится один раз
customers      (customer_id PK, name, email, segment_id FK, city_id FK)
customer_segments (segment_id PK, segment_name)
cities         (city_id PK, city_name, region_id FK)
regions        (region_id PK, region_name, country_id FK)
countries      (country_id PK, country_name)
orders         (order_id PK, customer_id FK, order_date, channel_id FK)
sales_channels (channel_id PK, channel_name)
order_items    (order_item_id PK, order_id FK, product_id FK, qty, unit_price)
products       (product_id PK, product_name, category_id FK, brand_id FK)
categories     (category_id PK, category_name, dept_id FK)
departments    (dept_id PK, dept_name)
brands         (brand_id PK, brand_name)

Двенадцать таблиц. Для OLTP это образцово: название категории живёт в одной строке categories, при ребрендинге меняется в одном месте, никакой рассинхронизации. Имя клиента — одна строка в customers. Никакого дублирования.

Нормализованная схема: цепочки справочников
order_itemsСтроки заказов: количество и цена. Центр транзакционных данных
FK
productsТовары. Не содержит названия категории — только category_id
FK
categoriesКатегории. Не содержит названия департамента — только dept_id
FK
departmentsДепартаменты. Конец цепочки справочников по товарам
ordersЗаголовки заказов: дата и клиент
FK
customersКлиенты. Сегмент и город — снова через FK
FK
citiesГорода. Регион — через ещё один FK
FK
regionsРегионы, далее countries. Цепочка географии

Заметьте структуру: чтобы добраться от строки заказа до названия департамента, нужно пройти order_items -> products -> categories -> departments. Чтобы от заказа до страны — orders -> customers -> cities -> regions -> countries. Данные разложены по цепочкам справочников. Для записи это удобно. Для чтения — это путь, который придётся каждый раз проходить заново.

Аналитический запрос: каскад JOIN

Теперь обычный аналитический вопрос: «Выручка по департаментам и странам за 2025 год». Человеческими словами — одно предложение. На нормализованной схеме — вот это.

SELECT
    d.dept_name,
    co.country_name,
    SUM(oi.qty * oi.unit_price) AS revenue
FROM order_items   oi
JOIN orders        o   ON oi.order_id    = o.order_id
JOIN customers     c   ON o.customer_id  = c.customer_id
JOIN cities        ci  ON c.city_id      = ci.city_id
JOIN regions       r   ON ci.region_id   = r.region_id
JOIN countries     co  ON r.country_id   = co.country_id
JOIN products      p   ON oi.product_id  = p.product_id
JOIN categories    cat ON p.category_id  = cat.category_id
JOIN departments   d   ON cat.dept_id    = d.dept_id
WHERE o.order_date >= DATE '2025-01-01'
GROUP BY d.dept_name, co.country_name
ORDER BY revenue DESC;

-- dept_name    | country_name | revenue
-- Electronics  | Germany      | 4920140.00
-- Apparel      | France       | 3110870.50
-- ... 
-- Время: 14.2 s

Восемь JOIN ради одного бизнес-вопроса. И это ещё скромный пример. В реальной нормализованной схеме крупной компании такой запрос легко доходит до 15-25 JOIN. Разберём, почему это плохо не только на вид.

Это дорого исполнять. Каждый JOIN — отдельная операция: построить хеш-таблицу, прогнать через неё строки, либо отсортировать оба входа и слить. Восемь JOIN — восемь таких шагов. Оптимизатор должен ещё и выбрать порядок их выполнения; для большого числа таблиц пространство планов огромно, и оптимизатор иногда выбирает неудачный план. Один промах в порядке JOIN — и запрос замедляется в разы.

Это легко написать неправильно. Восемь условий ON. Забыли одно — получаете декартово произведение или потерю строк. Перепутали c.city_id и ci.city_id — тихо неверный результат без ошибки. Аналитик, который пишет это каждый день, регулярно ошибается, и ошибка не падает, а молча искажает цифры.

Это нельзя переиспользовать. Следующий вопрос — «выручка по брендам и сегментам клиентов» — требует другого набора восьми JOIN. Третий вопрос — снова другого. Каждый отчёт переоткрывает геометрию схемы заново. Знание «как соединить эти двенадцать таблиц правильно» живёт в головах, а не в структуре данных.

Это хрупко к изменениям. Команда приложения добавила между cities и regions новый уровень — metro_areas. Все аналитические запросы, проходившие через географию, теперь либо сломаны, либо незаметно неверны.

WARNING

Самая опасная проблема каскада JOIN — не скорость, а тихая некорректность. Медленный запрос виден сразу. Запрос с одним пропущенным или неверным условием JOIN выполняется успешно и возвращает правдоподобное, но неправильное число. На таких числах принимают бизнес-решения.

Есть и пятая проблема, менее очевидная, но дорогая в долгую: каскад JOIN отрезает аналитику от данных. Чтобы написать правильный восьмиходовый запрос, нужно держать в голове всю топологию из двенадцати таблиц — какая на какую ссылается, через какой столбец, в каком направлении. Это знание есть у инженера, спроектировавшего схему, и почти не передаётся. Аналитик-новичок не может просто сесть и спросить данные — он сначала неделями осваивает карту схемы. Бизнес-пользователь не может и подавно. Нормализованная схема технически открыта для запросов, но практически закрыта для всех, кроме тех, кто знает её наизусть. Размерная модель, к которой ведёт курс, как раз снимает этот барьер: её форму видно сразу, и запрос к ней пишется почти на естественном языке.

Почему именно JOIN — узкое место

Стоит на минуту спуститься «до железа». Почему JOIN дорог?

Самый частый алгоритм для аналитики — hash join. Чтобы соединить две таблицы, движок берёт меньшую, строит из неё хеш-таблицу в памяти по ключу соединения, затем прогоняет через эту хеш-таблицу строки большей таблицы, для каждой ища совпадение. Если меньшая таблица не влезает в память — хеш-таблица выплёскивается на диск (spill), и операция кратно замедляется.

Теперь представьте каскад. Результат первого JOIN становится входом для второго, результат второго — для третьего. Промежуточные результаты материализуются, занимают память. Восемь JOIN — это восемь хеш-таблиц и до восьми промежуточных наборов строк, текущих по конвейеру. Каждый — расход CPU на хеширование и памяти на хранение.

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

Каскад JOIN против плоского чтения
НормализованнаяДанные размазаны по 12 таблицам, описания спрятаны в хвостах цепочек справочников
8+ JOIN на запрос
Дорого и хрупкоКаждый JOIN строит хеш-таблицу; легко ошибиться в условии; запрос медленный
РазмернаяОписательные атрибуты денормализованы в широкие dimension-таблицы рядом с fact
1 JOIN на dimension
Быстро и простоFact соединяется с каждой dimension одним JOIN; запрос предсказуем по структуре

Денормализация как осознанное решение

Здесь важно снять частое заблуждение джуниоров: «денормализация — признак плохого дизайна». В OLTP — да, неконтролируемое дублирование данных порождает аномалии. Но в аналитике денормализация — это осознанный, правильный инженерный выбор, а не неряшливость.

Почему дублирование, опасное в OLTP, безопасно в OLAP? Потому что аномалии — это аномалии записи. Update anomaly возникает, когда вы меняете дублированное значение и рискуете обновить не все копии. Но аналитический warehouse почти не обновляется построчно — он наполняется пакетной перезагрузкой из источника по расписанию. Нет точечных обновлений вразнобой — нет update anomaly. Цена нормализации (защита от аномалий записи) в аналитике почти не нужна, а её минус (каскад JOIN на каждый запрос) бьёт по главному — по скорости чтения.

АспектНормализованная (OLTP)Денормализованная (OLAP)
Хранение фактаодин раздублируется в широких таблицах
Аномалии записиисключеныне страшны: записи нет, есть перезагрузка
Запрос аналитикакаскад из 8-25 JOINодин JOIN на dimension
Скорость чтениянизкаявысокая
Расход места на дискеминимальныйбольше (но диск дёшев)
Кто платит ценуаналитик на каждом запросепайплайн загрузки один раз

Последняя строка — ключевая. Размерное моделирование не отменяет работу по соединению таблиц — оно переносит её во времени. Вместо того чтобы аналитик соединял двенадцать таблиц при каждом запросе, пайплайн загрузки делает это один раз, заранее, и складывает результат в широкие денормализованные таблицы. Дорогую работу делают однократно при загрузке, а не многократно при чтении.

TIP

Формулировка для собеседования: «Нормализация оптимизирует запись и целостность ценой скорости чтения. Аналитика — это нагрузка на чтение, где записи почти нет. Поэтому в warehouse денормализуют осознанно: дублирование данных безопасно, потому что нет точечных обновлений, способных породить аномалию».

Почему колоночное хранилище решает задачу аналитики без каскада JOIN Как dbt переносит работу соединения «во время загрузки», а не запроса

Попробуй сам

Возьмите нормализованную схему из этого урока (двенадцать таблиц). Напишите на бумаге SQL ещё для двух аналитических вопросов:

  1. «Средний чек по сегментам клиентов и каналам продаж за последний квартал».
  2. «Количество проданных единиц по брендам и регионам».

Для каждого запроса посчитайте, сколько JOIN нужно и через какие таблицы проходит цепочка. Затем нарисуйте «целевую» широкую таблицу: одна строка — одна проданная позиция, и в этой же строке сразу лежат dept_name, country_name, segment_name, channel_name, brand_name, region_name, дата и сумма. Перепишите оба запроса для такой таблицы и сравните: сколько JOIN теперь, насколько короче SQL, где меньше шансов ошибиться. Этот мысленный переход от цепочек справочников к широкой таблице — суть всего, что вы будете изучать в размерном моделировании.


Проверка знанийKnowledge check
Почему денормализация, которая в OLTP считается плохим дизайном из-за аномалий, в аналитическом warehouse — правильное решение?
ОтветAnswer
Потому что аномалии вставки, обновления и удаления — это аномалии записи, а аналитический warehouse почти не обновляется построчно. Update anomaly возникает, когда дублированное значение меняют и рискуют обновить не все копии в разнобой. Но warehouse наполняется пакетной перезагрузкой из источника по расписанию, а не точечными транзакциями приложения. Нет хаотичных точечных обновлений — нет рассинхронизации копий — нет update anomaly. При этом главная цена нормализации в аналитике — это каскад из десятков JOIN на каждый запрос: данные размазаны по узким таблицам и справочным цепочкам, и простой бизнес-вопрос превращается в 8-25 соединений, которые дороги (каждый JOIN строит хеш-таблицу и промежуточный набор строк), легко пишутся с ошибкой (пропущенное условие ON даёт тихо неверный результат) и не переиспользуются. Денормализация переносит работу по соединению во времени: пайплайн загрузки делает её один раз, а аналитик читает готовые широкие таблицы одним JOIN на dimension.

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

Результат: 0 из 0
Концептуальный
Вопрос 1 из 4. Почему простой бизнес-вопрос «выручка по департаментам и странам» на нормализованной OLTP-схеме превращается в запрос с восемью JOIN?

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

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

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

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