Почему нормализованная схема плоха для аналитики
Прошлые модули убедительно доказывали: нормализация — это хорошо. Третья нормальная форма убирает аномалии вставки, обновления и удаления, каждый факт хранится ровно один раз, целостность поддерживается 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 -> 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. Все аналитические запросы, проходившие через географию, теперь либо сломаны, либо незаметно неверны.
Самая опасная проблема каскада JOIN — не скорость, а тихая некорректность. Медленный запрос виден сразу. Запрос с одним пропущенным или неверным условием JOIN выполняется успешно и возвращает правдоподобное, но неправильное число. На таких числах принимают бизнес-решения.
Есть и пятая проблема, менее очевидная, но дорогая в долгую: каскад JOIN отрезает аналитику от данных. Чтобы написать правильный восьмиходовый запрос, нужно держать в голове всю топологию из двенадцати таблиц — какая на какую ссылается, через какой столбец, в каком направлении. Это знание есть у инженера, спроектировавшего схему, и почти не передаётся. Аналитик-новичок не может просто сесть и спросить данные — он сначала неделями осваивает карту схемы. Бизнес-пользователь не может и подавно. Нормализованная схема технически открыта для запросов, но практически закрыта для всех, кроме тех, кто знает её наизусть. Размерная модель, к которой ведёт курс, как раз снимает этот барьер: её форму видно сразу, и запрос к ней пишется почти на естественном языке.
Почему именно JOIN — узкое место
Стоит на минуту спуститься «до железа». Почему JOIN дорог?
Самый частый алгоритм для аналитики — hash join. Чтобы соединить две таблицы, движок берёт меньшую, строит из неё хеш-таблицу в памяти по ключу соединения, затем прогоняет через эту хеш-таблицу строки большей таблицы, для каждой ища совпадение. Если меньшая таблица не влезает в память — хеш-таблица выплёскивается на диск (spill), и операция кратно замедляется.
Теперь представьте каскад. Результат первого JOIN становится входом для второго, результат второго — для третьего. Промежуточные результаты материализуются, занимают память. Восемь JOIN — это восемь хеш-таблиц и до восьми промежуточных наборов строк, текущих по конвейеру. Каждый — расход CPU на хеширование и памяти на хранение.
Сравните с тем, что было бы, если бы названия департамента и страны лежали прямо в строке данных о продаже. Тогда запрос «выручка по департаментам и странам» — это один проход по одной таблице с группировкой. Ноль JOIN. Ноль хеш-таблиц. Именно эту идею реализует размерное моделирование: вынести часто запрашиваемые описательные атрибуты из глубоких цепочек справочников поближе — в широкие dimension-таблицы, к которым fact-таблица подключается одним JOIN.
Денормализация как осознанное решение
Здесь важно снять частое заблуждение джуниоров: «денормализация — признак плохого дизайна». В OLTP — да, неконтролируемое дублирование данных порождает аномалии. Но в аналитике денормализация — это осознанный, правильный инженерный выбор, а не неряшливость.
Почему дублирование, опасное в OLTP, безопасно в OLAP? Потому что аномалии — это аномалии записи. Update anomaly возникает, когда вы меняете дублированное значение и рискуете обновить не все копии. Но аналитический warehouse почти не обновляется построчно — он наполняется пакетной перезагрузкой из источника по расписанию. Нет точечных обновлений вразнобой — нет update anomaly. Цена нормализации (защита от аномалий записи) в аналитике почти не нужна, а её минус (каскад JOIN на каждый запрос) бьёт по главному — по скорости чтения.
| Аспект | Нормализованная (OLTP) | Денормализованная (OLAP) |
|---|---|---|
| Хранение факта | один раз | дублируется в широких таблицах |
| Аномалии записи | исключены | не страшны: записи нет, есть перезагрузка |
| Запрос аналитика | каскад из 8-25 JOIN | один JOIN на dimension |
| Скорость чтения | низкая | высокая |
| Расход места на диске | минимальный | больше (но диск дёшев) |
| Кто платит цену | аналитик на каждом запросе | пайплайн загрузки один раз |
Последняя строка — ключевая. Размерное моделирование не отменяет работу по соединению таблиц — оно переносит её во времени. Вместо того чтобы аналитик соединял двенадцать таблиц при каждом запросе, пайплайн загрузки делает это один раз, заранее, и складывает результат в широкие денормализованные таблицы. Дорогую работу делают однократно при загрузке, а не многократно при чтении.
Формулировка для собеседования: «Нормализация оптимизирует запись и целостность ценой скорости чтения. Аналитика — это нагрузка на чтение, где записи почти нет. Поэтому в warehouse денормализуют осознанно: дублирование данных безопасно, потому что нет точечных обновлений, способных породить аномалию».
Попробуй сам
Возьмите нормализованную схему из этого урока (двенадцать таблиц). Напишите на бумаге SQL ещё для двух аналитических вопросов:
- «Средний чек по сегментам клиентов и каналам продаж за последний квартал».
- «Количество проданных единиц по брендам и регионам».
Для каждого запроса посчитайте, сколько JOIN нужно и через какие таблицы проходит цепочка. Затем нарисуйте «целевую» широкую таблицу: одна строка — одна проданная позиция, и в этой же строке сразу лежат dept_name, country_name, segment_name, channel_name, brand_name, region_name, дата и сумма. Перепишите оба запроса для такой таблицы и сравните: сколько JOIN теперь, насколько короче SQL, где меньше шансов ошибиться. Этот мысленный переход от цепочек справочников к широкой таблице — суть всего, что вы будете изучать в размерном моделировании.