После 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 и почти всегда вредна на write-heavy. Понимание профиля нагрузки — первый шаг к решению.
Эмпирическое правило: денормализация — это перенос работы со стороны чтения на сторону записи. Если чтений много, а записей мало — выигрываем. Если наоборот — проигрываем.
Три формы денормализации
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 — компромиссный режим
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 — следующий урок.
Когда денормализация оправдана: чек-лист принятия решения
Прежде чем добавить дублирующую колонку, ответьте на вопросы:
- Нагрузка действительно read-heavy? Если запись доминирует — нет.
- Запрос реально медленный? Не «теоретически медленный» — а на ваших данных, ваших индексах, с
EXPLAIN ANALYZE. Часто оказывается, чтоJOINна проиндексированных колонках занимает миллисекунды. - Допустимо ли отставание? Если можно жить с данными «вчерашней давности» — берите materialized view, не трогайте основную схему.
- Знаете ли вы, как поддерживать консистентность? Без чёткого ответа на «кто обновит дублирующую колонку при изменении источника» — ловите аномалии через месяц.
- Можно ли решить индексом или CTE? Часто «медленный JOIN» — это «забыл индекс на FK». Сначала проверьте.
Если на все пять «да» — денормализуйте сознательно.
Практика: посмотрим на профиль нагрузки
В нашей вселенной у нас есть customers, orders и payments. Запрос «сколько потратил каждый клиент» — типичный кандидат на денормализацию.
Нормализованный запрос: JOIN customers + orders + payments
На наших 12 клиентах это работает мгновенно. На миллионе клиентов с десятками миллионов заказов — уже секунды. Решение через materialized view:
Materialized view: считаем один раз, читаем многократно
Свежесть данных = момент создания view (или последнего REFRESH). На дашборде «топ клиентов за вчера» это норма. На «текущий баланс клиента» — нет.
Денормализация и истории цен — частая ловушка
Один из самых классических случаев правильной денормализации — это «фиксация значения на момент события». Когда мы кладём в order_items колонку unit_price_cents, это денормализация: цена товара уже есть в products.price_cents. Зачем дублировать?
Затем, что цена товара в каталоге меняется во времени, а цена в чеке — нет. Если бы мы каждый раз делали JOIN products и брали текущую цену, отчёт «выручка за 2023 год» считал бы её по сегодняшним ценникам — финансовый кошмар. Правильное хранение — снимок на момент покупки.
То же правило работает для адресов доставки, ставок налога, имени клиента в чеке. Денормализация здесь — это не «оптимизация», а семантически правильный способ моделировать time-sensitive данные. Не путайте с «дублированием ради скорости».
unit_price_cents в order_items: исторический снимок цены
В нашей вселенной снапшоты и текущая цена совпадают — но в production они расходятся постоянно.
Анти-паттерны денормализации
Что чаще всего делают неправильно:
- Денормализуют «на всякий случай», без замеров. Получают сложную схему, которую теперь нужно поддерживать, и не получают выигрыша.
- Забывают про refund/cancel. Счётчик
orders_countидёт только вверх, в реальности заказы отменяются. Нужны компенсационные UPDATE. - Полагаются на консистентность приложения, а потом в БД заходит миграция или ETL, которая правит данные мимо. Триггер на уровне БД спасает, но тогда добавляется ещё одна точка отказа.
- Денормализуют горячие колонки.
countryредко меняется — нормально. Цена товара — меняется часто и историю надо хранить отдельно, иначе сломается «сколько мы выручили в прошлом квартале». - Refresh раз в сутки на 24/7-системе. Materialized view, который читают глобально, должен либо refresh-иться часто и
CONCURRENTLY, либо предупреждать о времени снапшота.
Когда лучше остаться нормализованным
Не каждый «медленный запрос» требует денормализации. Чаще проблема в:
- Отсутствующих индексах на FK-колонках. Помним из урока про constraints: Postgres не создаёт индекс на стороне ссылающейся таблицы автоматически.
CREATE INDEX ON orders (customer_id)часто превращает «5 секунд» в «5 миллисекунд». - Неправильном плане запроса.
EXPLAIN ANALYZEпоказывает, что планировщик выбрал, и часто проблема в устаревшей статистике (ANALYZE table) или в кривомWHERE. - Большой выборке, которую можно сузить. Дашборд читает миллион строк, чтобы показать топ-10? Уберите
LIMIT 10в нужное место и сократите данные ещё на стороне БД.
Денормализация — последний шаг, когда нормализованная схема плюс правильные индексы плюс CTE не справляются.
Денормализация: когда и зачем нарушать нормальные формы Почему нормализованная схема плоха для аналитики Денормализация и широкие таблицы в ClickHouseЧек-лист
- Денормализация = намеренное добавление дублирующих данных, чтобы ускорить чтение за счёт усложнения записи.
- Решение «нормализовать или денормализовать» зависит от профиля нагрузки: read-heavy → денормализация выигрышна, write-heavy → вредна.
- Три формы: дублирование колонок, предрасчитанные агрегаты, materialized views.
- Materialized views — самый безопасный путь: не трогаем основную схему, тратим только
REFRESH. Данные при этом «свежи на момент последнего refresh». - Перед денормализацией проверьте: реально ли запрос медленный (
EXPLAIN ANALYZE), есть ли индекс на FK, можно ли уложиться в допустимое отставание данных. - Главный анти-паттерн — денормализация без замеров и без плана поддержки консистентности.