Денормализация: когда и зачем нарушать нормальные формы
Весь модуль мы боролись с избыточностью: находили её, доказывали, что она порождает аномалии, и устраняли декомпозицией. Этот урок — про обратное движение. Денормализация — это сознательное добавление избыточности в уже нормализованную схему ради ускорения чтения.
Звучит как противоречие, но это не так. Денормализация — не отмена нормализации и не «плохой дизайн». Это инженерный компромисс с открытыми глазами: вы знаете, какую цену платите, и принимаете её осознанно. Ключевые слова — «сознательно» и «после». Сначала нормализуйте, поймите модель, и только потом, при доказанной необходимости, точечно денормализуйте.
Почему нормализация имеет цену на чтении
У нормализации есть оборотная сторона. Раскладывая данные так, чтобы каждый факт хранился один раз, мы разносим связанные данные по разным таблицам. А значит, чтобы собрать их обратно для запроса, нужен JOIN.
Вспомним результат из урока про 3NF — пять таблиц:
CITIES, CUSTOMERS, ORDERS, PRODUCTS, ORDER_ITEMS
Простой бизнес-вопрос «покажи позиции заказа с названием товара, именем покупателя и его городом» требует соединить четыре таблицы:
SELECT o.order_id, p.product_name, c.customer_name, ct.city_name, oi.qty
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id
JOIN products p ON p.product_id = oi.product_id
JOIN customers c ON c.customer_id = o.customer_id
JOIN cities ct ON ct.city_code = c.city_code;
Каждый JOIN — это работа: сопоставление строк по ключу, обращения к индексам, промежуточные наборы строк в памяти. Для OLTP с точечными запросами это незаметно — соединить несколько строк по индексу мгновенно. Но есть сценарии, где цена JOIN становится ощутимой: аналитический запрос соединяет десятки таблиц на миллионах строк; высоконагруженный endpoint выполняет один и тот же многотабличный JOIN тысячи раз в секунду; нужны предвычисленные агрегаты, которые иначе пересчитываются каждый раз с нуля.
В этих случаях возникает соблазн: сохранить связанные данные рядом, чтобы убрать JOIN. Это и есть денормализация.
Паттерны денормализации
Денормализация — не «свалить всё в одну таблицу», а набор конкретных приёмов. Разберём основные.
1. Дублирование атрибута (предрассчитанный JOIN). Часто запрашиваемый атрибут из родительской таблицы копируют в дочернюю, чтобы не делать JOIN. Например, в ORDER_ITEMS добавляют product_name рядом с product_id:
ORDER_ITEMS(order_id, product_id, product_name, qty) -- product_name продублирован
Запрос «позиции заказа с названиями» теперь не соединяется с PRODUCTS. Цена — product_name снова дублируется в каждой строке заказа с этим товаром, и переименование товара требует обновления всех этих строк (тот самый update anomaly из начала модуля — но теперь принятый сознательно).
2. Предвычисленный агрегат. Результат агрегирующего запроса хранят как столбец, а не считают каждый раз. Например, в ORDERS добавляют total_amount — сумму по всем позициям заказа:
ORDERS(order_id, customer_id, total_amount) -- = SUM(qty * unit_price) по позициям
Чтение «сумма заказа» становится одним обращением к столбцу вместо агрегации по ORDER_ITEMS. Цена — при каждом изменении позиции заказа total_amount надо пересчитывать, иначе он рассинхронизируется с реальными позициями.
3. Счётчик / поддерживаемое значение. Частный случай агрегата — хранить число связанных строк: customers.orders_count вместо COUNT(*) по ORDERS. Та же логика: быстрое чтение, цена — синхронное обновление при каждом новом заказе.
4. Объединение таблиц «1:1». Две таблицы, связанные один-к-одному, сливают в одну, убирая JOIN между ними. Это самый «безобидный» приём — при строгом 1:1 избыточность не возникает, исчезает лишь сам JOIN.
| Паттерн | Что ускоряет | Чем платим |
|---|---|---|
| Дублирование атрибута | Убирает JOIN с родительской таблицей | Update anomaly по дублю |
| Предвычисленный агрегат | Убирает агрегацию на чтении | Пересчёт при каждом изменении источника |
| Счётчик связанных строк | Убирает COUNT | Синхронное обновление счётчика |
| Слияние 1:1 таблиц | Убирает JOIN между ними | Почти ничего (при строгом 1:1) |
Цена денормализации
Денормализация платит ровно той монетой, против которой боролся весь модуль. Цена складывается из нескольких частей.
Возврат аномалий. Каждый продублированный атрибут или агрегат — это факт, хранящийся более одного раза, то есть потенциальный update/insert/delete anomaly. Денормализованная схема снова допускает рассинхрон.
Стоимость поддержания согласованности перекладывается на запись. В нормализованной схеме согласованность бесплатна — её обеспечивает структура. В денормализованной за неё надо платить на каждой записи: при изменении товара — обновить все его копии; при изменении позиции — пересчитать total_amount. Запись становится медленнее и сложнее. Денормализация — это сделка «быстрее чтение в обмен на медленнее и сложнее запись».
Риск тихого расхождения. Самое опасное. Если хоть один путь изменения данных забудет обновить дубль или агрегат, данные молча разойдутся. total_amount будет показывать одно, сумма позиций — другое, и СУБД об этом не предупредит: для неё это просто разные столбцы. Чем больше точек записи, тем выше риск.
Кто отвечает за согласованность. Раз структура больше не гарантирует согласованность, её должен обеспечивать контролируемый механизм: триггеры БД, транзакции приложения, которые обновляют дубль и источник атомарно, или периодический пересчёт. Денормализация без такого механизма — это просто баг.
Механизмы синхронизации: чем платить за денормализацию
Раз денормализация перекладывает согласованность на запись, у дублей и агрегатов должен быть механизм, который их обновляет. Денормализация без такого механизма — это не оптимизация, а просто баг, который проявится при первом расхождении данных. Разберём основные варианты.
1. Транзакция приложения. Код, меняющий источник, в той же транзакции обновляет и дубль. Добавили позицию заказа — этой же транзакцией пересчитали orders.total_amount. Плюс: обновление атомарно — либо оба изменения, либо ни одного, рассинхрон внутри одной транзакции невозможен. Минус: дисциплина — каждый путь записи в источник обязан помнить про дубль; забыл один путь — расхождение.
2. Триггер базы данных. СУБД сама при изменении источника запускает код, обновляющий дубль. Триггер на INSERT/UPDATE/DELETE в order_items пересчитывает total_amount. Плюс: работает на всех путях записи без исключения — логика в самой базе, обойти нельзя. Минус: логика «прячется» в БД, её сложнее увидеть и отлаживать, и триггер добавляет работу на каждую запись.
3. Регламентный пересчёт. Дубль или агрегат периодически пересчитывается с нуля (по расписанию, фоновой задачей). Плюс: просто, и пересчёт «лечит» любой накопленный рассинхрон. Минус: между пересчётами данные временно расходятся — подходит только там, где такое отставание терпимо.
| Механизм | Гарантия согласованности | Цена |
|---|---|---|
| Транзакция приложения | Точная, но только на учтённых путях записи | Дисциплина: каждый путь обязан обновлять дубль |
| Триггер БД | Точная, на всех путях записи | Скрытая логика, нагрузка на каждую запись |
| Регламентный пересчёт | Приблизительная, с отставанием | Временный рассинхрон между прогонами |
Выбор механизма — часть проектирования денормализации. Нужна строгая согласованность здесь и сейчас (баланс счёта) — транзакция или триггер. Допустимо небольшое отставание (аналитический счётчик на дашборде) — подойдёт регламентный пересчёт, он проще. Но какой-то механизм обязателен всегда: денормализованный дубль без сопровождающего его процесса синхронизации гарантированно разойдётся с источником.
Решая денормализовать, сразу проектируйте механизм синхронизации — это не отдельная задача «на потом», а неотъемлемая часть денормализации. Дубль, у которого нет ясного ответа на вопрос «кто и когда его обновляет», — это запланированный рассинхрон.
Когда денормализация оправдана
Денормализация — инструмент, а не стиль. Применять её стоит при совпадении нескольких условий:
- Доказанная проблема производительности. Не «кажется, что JOIN медленный», а измеренный, попавший в профиль запрос, который не ускоряется индексами и переписыванием. Денормализация — не первое средство, а одно из последних.
- Перекос чтения над записью. Данные читаются на порядки чаще, чем меняются. Тогда «дорогая запись ради дешёвого чтения» — выгодный размен. Если данные меняются часто, денормализация может всё замедлить.
- Есть надёжный механизм синхронизации. Триггеры, транзакционное обновление или регламентный пересчёт — на месте и протестированы.
- Цена рассинхрона приемлема. Для аналитического счётчика, который раз в сутки пересчитывается, небольшое временное расхождение терпимо. Для финансового баланса — нет; там денормализация требует особой строгости.
И главный архитектурный принцип:
Нормализуйте сначала, денормализуйте потом — и только точечно. Сперва спроектируйте корректную нормализованную схему (3NF): вы поймёте структуру данных и все зависимости. Денормализуйте после, по результатам измерений, на конкретных горячих местах. Денормализация поверх непонятой модели — это не оптимизация, а узаконенный беспорядок.
Денормализация в OLTP и в аналитике
Важно различать два контекста — об одном из них пойдёт речь дальше в курсе.
В OLTP (системы, где пользователи правят данные в реальном времени) денормализация — это всегда исключение, точечная мера под доказанную проблему. Базовая схема OLTP — нормализованная (3NF): целостность здесь критична, а данные постоянно меняются.
В аналитических хранилищах (OLAP, data warehouse) ситуация переворачивается. Там денормализация — не исключение, а норма проектирования. Размерные модели (star schema), широкие таблицы (One Big Table) намеренно денормализованы: данные грузятся контролируемым процессом, почти не меняются после загрузки, а читаются тяжёлыми аналитическими запросами, для которых десятки JOIN — это смерть. В этом контексте за согласованность отвечает процесс загрузки (ETL/ELT), а не структура. Этой теме — почему аналитике нужна другая модель и как её строят — посвящены следующие модули курса.
Денормализация в PostgreSQL: tradeoffs и паттерны Денормализация в ClickHouse: широкие таблицы как норма«Денормализация = плохой дизайн» — распространённое заблуждение. В OLTP неконтролируемая избыточность действительно дефект. Но в аналитическом слое осознанная денормализация — правильное, профессиональное решение. Разница не в самой избыточности, а в том, осознана ли она, есть ли механизм синхронизации, и подходит ли она под паттерн нагрузки. Денормализация — это про контекст.
Попробуй сам
Дана нормализованная схема интернет-магазина: CUSTOMERS, ORDERS, ORDER_ITEMS, PRODUCTS. Известно: страница «история заказов покупателя» открывается очень часто и каждый раз показывает по каждому заказу его итоговую сумму и число позиций; заказы после оформления почти никогда не меняются.
Выполните на бумаге:
- Предложите две денормализации, которые ускорят страницу «история заказов». Для каждой укажите, какой именно JOIN или какую агрегацию она убирает.
- Для каждой денормализации опишите цену: какой факт станет избыточным и какой anomaly при этом возвращается.
- Опишите механизм синхронизации для каждой денормализации: когда и как обновлять предрассчитанное значение, чтобы оно не разошлось с источником.
- Объясните, почему условие «заказы после оформления почти не меняются» делает эту денормализацию оправданной, и что изменилось бы, если бы позиции заказа правились постоянно.