Learning Platform
Урок 12.04 · 17 мин
Средний
DenormalizationTrade-offsRead-heavyWrite-heavyMaterialized viewCaching

После 3NF — обратно?

Прошлый урок убедил нас, что нормализованная схема правильная: один источник истины, нет аномалий, всё консистентно. Звучит идеально — пока не приходит первый медленный запрос на дашборде.

SELECT c.full_name, c.country, COUNT(*) AS orders_total, SUM(p.amount_cents) AS revenue
FROM customers c
JOIN orders o    ON o.customer_id = c.id
JOIN payments p  ON p.order_id    = o.id
WHERE o.placed_at >= '2025-01-01'
GROUP BY c.id, c.full_name, c.country
ORDER BY revenue DESC;

Три JOIN, два сканирования, sort, агрегация — на миллионе заказов это становится секундами. И продакт-менеджер задумчиво спрашивает: «а можно ли сделать страницу клиента, где сразу видно общее число заказов и сумму трат?»

Самый честный ответ — не всегда. Иногда правильнее сознательно денормализовать: добавить дублирующие данные в схему, чтобы запросы стали быстрее. Этот урок — про то, когда такой ход оправдан.

Read-heavy vs write-heavy — главная развилка

Любая нагрузка на БД делится по соотношению чтений и записей.

Read-heavy vs write-heavy

Денормализация почти всегда выигрышна на read-heavy и почти всегда вредна на write-heavy. Понимание профиля нагрузки — первый шаг к решению.

Read-heavyдашборды, отчёты, поиск
чтений в 100-1000× больше записей
денормализацияускоряет 100 чтений за счёт +1 записьПлатим 1 копейкой на UPDATE, экономим 100 копеек на SELECT — нетто-выигрыш
Write-heavyevent ingestion, IoT, биллинг
миллионы INSERT/UPDATE в день
денормализациякаждая запись становится дорожеОдин UPDATE превращается в 5 UPDATE-ов — пишем в 5 раз медленнее

Эмпирическое правило: денормализация — это перенос работы со стороны чтения на сторону записи. Если чтений много, а записей мало — выигрываем. Если наоборот — проигрываем.

Три формы денормализации

1) Дублирование колонок

Самый частый случай. Кладём «избыточную» колонку в одну таблицу, чтобы не делать JOIN.

Пример: в orders нам часто нужен customer_country для фильтрации и группировки.

-- Нормализованная схема
SELECT country, COUNT(*) FROM orders o
JOIN customers c ON c.id = o.customer_id
GROUP BY country;

-- Денормализованная: добавляем country прямо в orders
ALTER TABLE orders ADD COLUMN customer_country TEXT;
UPDATE orders o SET customer_country = c.country
  FROM customers c WHERE c.id = o.customer_id;
-- + триггер или ON UPDATE логика, чтобы поддерживать актуальность

SELECT customer_country, COUNT(*) FROM orders GROUP BY customer_country;

Цена: каждый UPDATE customers SET country = ... теперь должен синхронизироваться с заказами этого клиента. Если этого не сделать — данные «расходятся», и нормализованная и денормализованная версии запроса начинают давать разные ответы.

2) Предрасчитанные агрегаты

Если на дашборде каждый раз считается SUM(amount) по миллиону строк — может быть дешевле хранить эту сумму отдельно и обновлять при каждой транзакции.

ALTER TABLE customers ADD COLUMN total_spent_cents BIGINT NOT NULL DEFAULT 0;
ALTER TABLE customers ADD COLUMN orders_count INT NOT NULL DEFAULT 0;

-- При оформлении заказа:
UPDATE customers
SET total_spent_cents = total_spent_cents + 12830000,
    orders_count = orders_count + 1
WHERE id = 1;

Запрос «топ-10 клиентов по тратам» теперь читает 12 строк вместо JOIN customers + orders + payments + GROUP BY. Цена — каждый заказ становится двумя записями вместо одной, и нужно тщательно обрабатывать возвраты (refund должен уменьшить счётчик).

3) Materialized views — компромиссный режим

Materialized view
— это «обычный VIEW, чьи результаты сохранены на диск». Postgres поддерживает их из коробки. Это золотая середина между «всегда живой JOIN» и «руками поддерживать дублирующие колонки»:

CREATE MATERIALIZED VIEW customer_stats AS
SELECT
  c.id, c.full_name, c.country,
  COUNT(o.id)            AS orders_count,
  COALESCE(SUM(p.amount_cents), 0) AS total_spent_cents
FROM customers c
LEFT JOIN orders   o ON o.customer_id = c.id
LEFT JOIN payments p ON p.order_id    = o.id
GROUP BY c.id, c.full_name, c.country;

-- Раз в час или раз в день:
REFRESH MATERIALIZED VIEW customer_stats;

SELECT * FROM customer_stats WHERE country = 'RU' ORDER BY total_spent_cents DESC LIMIT 10;

Запрос работает по «снимку» данных. Свежесть данных зависит от частоты REFRESH. Если бизнесу хватает «вчерашних» цифр — это идеальный инструмент: не трогаем нормализованную схему, не пишем триггеров, чтение быстрое.

Подробно про materialized views — следующий урок.

Когда денормализация оправдана: чек-лист принятия решения

Прежде чем добавить дублирующую колонку, ответьте на вопросы:

  1. Нагрузка действительно read-heavy? Если запись доминирует — нет.
  2. Запрос реально медленный? Не «теоретически медленный» — а на ваших данных, ваших индексах, с EXPLAIN ANALYZE. Часто оказывается, что JOIN на проиндексированных колонках занимает миллисекунды.
  3. Допустимо ли отставание? Если можно жить с данными «вчерашней давности» — берите materialized view, не трогайте основную схему.
  4. Знаете ли вы, как поддерживать консистентность? Без чёткого ответа на «кто обновит дублирующую колонку при изменении источника» — ловите аномалии через месяц.
  5. Можно ли решить индексом или CTE? Часто «медленный JOIN» — это «забыл индекс на FK». Сначала проверьте.

Если на все пять «да» — денормализуйте сознательно.

Практика: посмотрим на профиль нагрузки

В нашей вселенной у нас есть customers, orders и payments. Запрос «сколько потратил каждый клиент» — типичный кандидат на денормализацию.

Нормализованный запрос: JOIN customers + orders + payments

PostgreSQL

На наших 12 клиентах это работает мгновенно. На миллионе клиентов с десятками миллионов заказов — уже секунды. Решение через materialized view:

Materialized view: считаем один раз, читаем многократно

PostgreSQL

Свежесть данных = момент создания view (или последнего REFRESH). На дашборде «топ клиентов за вчера» это норма. На «текущий баланс клиента» — нет.

Денормализация и истории цен — частая ловушка

Один из самых классических случаев правильной денормализации — это «фиксация значения на момент события». Когда мы кладём в order_items колонку unit_price_cents, это денормализация: цена товара уже есть в products.price_cents. Зачем дублировать?

Затем, что цена товара в каталоге меняется во времени, а цена в чеке — нет. Если бы мы каждый раз делали JOIN products и брали текущую цену, отчёт «выручка за 2023 год» считал бы её по сегодняшним ценникам — финансовый кошмар. Правильное хранение — снимок на момент покупки.

То же правило работает для адресов доставки, ставок налога, имени клиента в чеке. Денормализация здесь — это не «оптимизация», а семантически правильный способ моделировать time-sensitive данные. Не путайте с «дублированием ради скорости».

unit_price_cents в order_items: исторический снимок цены

PostgreSQL

В нашей вселенной снапшоты и текущая цена совпадают — но в production они расходятся постоянно.

Анти-паттерны денормализации

Что чаще всего делают неправильно:

  • Денормализуют «на всякий случай», без замеров. Получают сложную схему, которую теперь нужно поддерживать, и не получают выигрыша.
  • Забывают про refund/cancel. Счётчик orders_count идёт только вверх, в реальности заказы отменяются. Нужны компенсационные UPDATE.
  • Полагаются на консистентность приложения, а потом в БД заходит миграция или ETL, которая правит данные мимо. Триггер на уровне БД спасает, но тогда добавляется ещё одна точка отказа.
  • Денормализуют горячие колонки. country редко меняется — нормально. Цена товара — меняется часто и историю надо хранить отдельно, иначе сломается «сколько мы выручили в прошлом квартале».
  • Refresh раз в сутки на 24/7-системе. Materialized view, который читают глобально, должен либо refresh-иться часто и CONCURRENTLY, либо предупреждать о времени снапшота.

Когда лучше остаться нормализованным

Не каждый «медленный запрос» требует денормализации. Чаще проблема в:

  1. Отсутствующих индексах на FK-колонках. Помним из урока про constraints: Postgres не создаёт индекс на стороне ссылающейся таблицы автоматически. CREATE INDEX ON orders (customer_id) часто превращает «5 секунд» в «5 миллисекунд».
  2. Неправильном плане запроса. EXPLAIN ANALYZE показывает, что планировщик выбрал, и часто проблема в устаревшей статистике (ANALYZE table) или в кривом WHERE.
  3. Большой выборке, которую можно сузить. Дашборд читает миллион строк, чтобы показать топ-10? Уберите LIMIT 10 в нужное место и сократите данные ещё на стороне БД.

Денормализация — последний шаг, когда нормализованная схема плюс правильные индексы плюс CTE не справляются.

Денормализация: когда и зачем нарушать нормальные формы Почему нормализованная схема плоха для аналитики Денормализация и широкие таблицы в ClickHouse

Чек-лист

  • Денормализация = намеренное добавление дублирующих данных, чтобы ускорить чтение за счёт усложнения записи.
  • Решение «нормализовать или денормализовать» зависит от профиля нагрузки: read-heavy → денормализация выигрышна, write-heavy → вредна.
  • Три формы: дублирование колонок, предрасчитанные агрегаты, materialized views.
  • Materialized views — самый безопасный путь: не трогаем основную схему, тратим только REFRESH. Данные при этом «свежи на момент последнего refresh».
  • Перед денормализацией проверьте: реально ли запрос медленный (EXPLAIN ANALYZE), есть ли индекс на FK, можно ли уложиться в допустимое отставание данных.
  • Главный анти-паттерн — денормализация без замеров и без плана поддержки консистентности.
Проверка знанийKnowledge check
У вас система оформления заказов: 50k заказов в день, дашборд «сколько потратил клиент» открывается 100 раз в час. Какой подход к показателю total_spent_cents выберете и почему?
ОтветAnswer
Открытий дашборда: 100 * 24 = 2400 в день. Заказов: 50 000 в день. Это write-heavy сценарий (записей в 20+ раз больше чтений), классическая денормализация через UPDATE customers SET total_spent_cents += ... на каждом заказе невыгодна. Materialized view с REFRESH раз в 5-15 минут — идеальный компромисс: считаем агрегаты по расписанию, чтения идут с готового снапшота, основная схема не трогается, и каждый INSERT в orders остаётся одной записью, а не двумя. Если бизнесу нужны real-time цифры — тогда честная денормализация с триггером или с обновлением в коде, но платить за это придётся на write-пути.

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

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. В каком сценарии денормализация скорее всего НЕ оправдана?

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

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

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

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