Введение
Статья 402 завершилась явной оговоркой о практических пределах нормализации — в этой статье разберём денормализацию подробнее: осознанное, целенаправленное внесение избыточности в схему БД ради производительности или практического удобства, и как отличить оправданную денормализацию от небрежного, неструктурированного проектирования.
Концепция
Денормализация — намеренное дублирование данных или их предварительное агрегирование, устраняющее необходимость в дорогостоящих JOIN или вычислениях в самых частых, критичных по производительности запросах, за счёт усложнения логики поддержания согласованности этих дублированных данных при изменениях. Типичные формы: дублирование часто используемого значения из связанной таблицы напрямую в текущей (избегая JOIN), хранение предварительно вычисленного агрегата (например, общей суммы заказов клиента, обновляемой триггером, статья 410, вместо вычисления через SUM при каждом запросе), и материализованные представления (статья 407) как более структурированная, управляемая СУБД форма денормализации.
Пример кода: Денормализация
-- Нормализованная версия — сумма заказа клиента вычисляется каждый раз через агрегацию
SELECT c.name, SUM(o.total_amount) AS total_spent
FROM customers c
JOIN orders o ON c.id = o.customer_id
GROUP BY c.id, c.name;
-- Денормализованная версия — предварительно вычисленное значение хранится прямо в таблице клиентов
ALTER TABLE customers ADD COLUMN total_spent DECIMAL(12, 2) DEFAULT 0;
-- Поддержание согласованности через триггер (статья 410) при каждом новом заказе
CREATE OR REPLACE FUNCTION update_customer_total_spent()
RETURNS TRIGGER AS $$
BEGIN
UPDATE customers
SET total_spent = total_spent + NEW.total_amount
WHERE id = NEW.customer_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_update_total_spent
AFTER INSERT ON orders
FOR EACH ROW EXECUTE FUNCTION update_customer_total_spent();
-- Дублирование часто используемого значения из связанной таблицы — избегаем JOIN
-- для самого частого запроса (отображение списка заказов с именем клиента)
ALTER TABLE orders ADD COLUMN customer_name_snapshot VARCHAR(100);
-- заполняется при создании заказа, НЕ обновляется автоматически при изменении имени клиента —
-- что часто желаемое поведение (историческая "фотография" имени на момент заказа, статья 402)
Пояснения к коду
Денормализованный столбец total_spent в таблице customers избавляет от необходимости выполнять JOIN и агрегацию для частого, вероятно высоконагруженного запроса «показать клиента с его суммарными тратами» (например, для главной панели администратора, открываемой многократно в течение рабочего дня), но требует явного механизма поддержания согласованности — в примере это триггер, автоматически обновляющий денормализованное значение при каждой вставке нового заказа, что переносит сложность с этапа чтения (быстрее) на этап записи (немного медленнее и сложнее, поскольку триггер должен надёжно срабатывать при каждом релевантном изменении). customer_name_snapshot показывает другой, качественно отличный случай денормализации — не дублирование ради производительности текущего значения, а намеренное сохранение исторического «снимка» значения на конкретный момент времени, который не должен синхронизироваться с последующими изменениями оригинала.
Подводные камни
- Денормализация без полного понимания всех мест в коде, где денормализованное значение должно обновляться — в отличие от нормализованной схемы, где единственное место хранения значения автоматически гарантирует согласованность, денормализованная схема требует, чтобы разработчик не забыл обновить дублированное значение при КАЖДОЙ операции, способной на него повлиять; забытое обновление в одном из таких мест приводит к постепенному, незаметному рассогласованию денормализованных данных с реальным, актуальным состоянием.
- Применение денормализации «профилактически», без реального измерения и подтверждения проблемы производительности, которую она должна решить — денормализация всегда увеличивает сложность поддержания согласованности схемы, и эта стоимость должна быть оправдана реально измеренной, а не предполагаемой проблемой производительности; для запросов, выполняющихся достаточно редко или над достаточно небольшим объёмом данных, нормализованная версия с обычным JOIN часто остаётся вполне приемлемой по скорости, не требуя такой жертвы в простоте поддержания.
- Путаница между денормализацией ради производительности и намеренным сохранением исторического снимка данных (как в примере с
customer_name_snapshot) — это качественно разные случаи: первый требует активного поддержания синхронизации (и является источником потенциального рассогласования при ошибке в этом поддержании), а второй намеренно НЕ синхронизируется с изменениями оригинала, поскольку именно это и есть желаемое, корректное поведение для исторической записи; смешение этих двух концепций в одном проекте без явного разделения может привести к путанице, действительно ли конкретное дублированное значение должно обновляться при изменении оригинала. - Реализация поддержания согласованности денормализованных данных в коде приложения вместо триггеров на уровне СУБД, что создаёт риск рассогласования при любом изменении данных, выполненном не через тот конкретный путь кода приложения, который содержит логику обновления (прямые изменения через консоль СУБД, миграции, другие приложения, обращающиеся к той же БД) — триггеры на уровне самой СУБД (как в примере) гарантируют срабатывание независимо от конкретного клиента/пути, через который было выполнено изменение, что значительно надёжнее логики, разнесённой по коду приложения.