Нормализация и денормализация в PostgreSQL: где граница
Дублировать данные в PostgreSQL ради скорости кажется простым решением, пока одно изменение не оставляет разные версии факта в разных строках. Разбираем, когда достаточно нормальной схемы и индексов, а когда копия данных оправдана.

В интернет-магазине имя покупателя может храниться в таблице заказов, чтобы быстрее собирать историю покупок. Но если человек сменит имя, старые заказы сохранят прежнее значение — и системе придётся объяснять, какое из них верное. В PostgreSQL граница между нормализацией и денормализацией проходит не по числу таблиц, а по цене чтения и цене поддержания согласованности.
Сначала найдите узкое место, а не таблицу
Дальше речь о PostgreSQL 16. Базовые принципы применимы и к другим версиям, но настройки и поведение конкретной версии сверяйте с её документацией.
Нормализованная схема хранит факт в одном месте. Например, имя покупателя находится в таблице покупателей, а заказ содержит ссылку на покупателя. Чтобы показать имя в истории заказов, запрос соединяет таблицы. Денормализация сохраняет копию имени непосредственно в заказе: чтение становится проще, но при изменении имени нужно решить, обновлять ли старые заказы.
Само наличие JOIN не доказывает, что схема медленная. PostgreSQL может эффективно соединять таблицы, если запрос, объём данных, статистика и индексы подходят друг другу. Ускорение за счёт копирования данных имеет смысл проверять на конкретном запросе и реальной нагрузке, а не предполагать заранее.
Дублирование оправдано, когда вы точно знаете, какое чтение оно ускоряет, и можете назвать источник истины для каждого скопированного значения.
Три варианта для одной задачи
Представим страницу заказа: ей нужны дата покупки, имя покупателя и сумма. Есть три рабочих подхода. Они решают разные проблемы, и каждый добавляет свою цену.
| Подход | Что хранится | Плюс | Цена и риск |
|---|---|---|---|
| Нормализованные таблицы | Заказ ссылается на покупателя; имя хранится у покупателя | Один источник истины, изменения не нужно размножать | Чтение требует соединения таблиц; нужно измерить его стоимость |
| Снимок данных в заказе | В заказе дополнительно сохраняется имя на момент покупки | Историю можно показать без текущих данных профиля | Нужно определить, что это именно снимок, а не актуальное имя |
| Отдельная витрина или материализованное представление | Подготовленный результат для чтения | Тяжёлую агрегацию можно не выполнять при каждом запросе | Данные могут отставать; нужно организовать обновление и контролировать задержку |
Эти варианты не обязательно взаимоисключающие. В заказе разумно хранить адрес доставки как снимок: покупатель мог переехать, а заказ должен показывать адрес, использованный при оформлении. Имя в профиле и имя в историческом документе тоже могут иметь разные смыслы. Важно назвать эти смыслы в модели, а не просто добавить похожие столбцы.
Нормализуйте изменяемые факты
Если значение описывает текущую сущность и должно меняться в одном месте, обычно храните его там. Например, текущий статус товара не стоит копировать в каждую строку корзины без чёткого требования к снимку. Иначе изменение товара потребует синхронно обновить копии, а сбой одного из путей записи оставит противоречивые значения.
Связи защищайте ограничениями целостности, когда это соответствует правилам предметной области. В PostgreSQL внешний ключ проверяет, что ссылка указывает на существующую строку. Но индекс на столбцах ссылающейся таблицы PostgreSQL автоматически не создаёт: если такие столбцы участвуют в частых соединениях или удалениях родительских строк, проверьте необходимость индекса отдельно.
Денормализуйте снимки, а не случайные копии
Снимок полезен, когда значение должно сохранять прошлое состояние: цена и название товара в оформленном заказе, адрес на момент доставки, применённые условия скидки. Это не «лишняя копия» текущих данных, а часть истории операции. Для такого поля заранее решите, кто его записывает и какие изменения после создания заказа его не затрагивают.
Суммарные данные — другой случай. Если список заказов постоянно считает итог по множеству строк, можно рассмотреть предварительный расчёт или отдельную витрину чтения. Но у такого решения появляется вопрос свежести: допустима ли задержка в секунды, минуты или нельзя показывать устаревший результат вовсе? Материализованное представление в PostgreSQL хранит результат запроса, а не обновляет его автоматически при изменении исходных таблиц.
Как выбрать подход
- Зафиксируйте запрос. Запишите, что именно медленно: конкретная страница, отчёт или агрегат. Укажите объём данных и типичный параллелизм. Проверка: у команды есть воспроизводимый запрос или сценарий, а не только жалоба «база тормозит».
- Измерьте исходное состояние. Посмотрите план выполнения и фактическое время на данных, близких к рабочим. Проверьте, не тратится ли время на сортировку, чтение лишних строк или повторный расчёт агрегации. Проверка: вы знаете, какой участок запроса занимает основную долю времени.
- Сначала попробуйте улучшить нормализованный вариант. Проверьте условия соединения, фильтры, объём выдачи и индексы, которые подходят именно этим операциям. Проверка: повторный запуск того же сценария показывает, изменилось ли время и число обработанных строк.
- Если чтение всё ещё дорого, сформулируйте правило копирования. Назовите источник истины, момент создания снимка и допустимую свежесть. Для агрегата определите, как часто результат пересчитывается и что увидит читатель между обновлениями. Проверка: можно объяснить, как система восстановит правильное состояние после сбоя записи или обновления.
- Сравните оба варианта под нагрузкой. Смотрите не только среднее время ответа, но и распределение задержки, нагрузку на базу, объём записи и частоту конфликтующих обновлений. Проверка: выигрыш чтения не обернулся неприемлемой задержкой обновления или усложнением восстановления.
План выполнения1 показывает, как PostgreSQL собирается выполнить запрос; с фактическими данными он помогает проверить, где расходуются время и ресурсы.
Когда выбирать каждый вариант
- Оставляйте нормализованную модель, если данные часто меняются, важна единственная актуальная версия факта, а измерения не показывают неприемлемую цену JOIN.
- Храните снимок, если значение должно оставаться таким, каким оно было при событии. Уточните это в именовании и логике приложения, чтобы никто не принимал снимок за текущие данные.
- Готовьте витрину, если один и тот же дорогой расчёт выполняется часто, а правила допустимой задержки можно описать и проверить. Для материализованного представления отдельно планируйте обновление: оно не происходит само при изменении исходных таблиц.
Перед внедрением копии проверьте ещё один риск: несколько независимых частей приложения могут менять один и тот же факт. Если обновлять все копии нужно транзакционно, это должно быть явно обеспечено. Если допускается задержка, нужен механизм обновления и способ обнаружить отставание. «Потом синхронизируем» — не стратегия согласованности.
Где ломается денормализация
Чаще всего проблема не в лишнем столбце, а в неоговорённом значении этого столбца. Имя покупателя в заказе — текущее имя или имя на момент покупки? Итог заказа — пересчитываемая сумма или зафиксированный результат с учётом скидок? Пока на эти вопросы нет ответа, копия создаёт неоднозначность.
Второй риск — запись и обновление. Если один путь меняет исходную таблицу, но забывает о копии, результаты расходятся. Если обновлять копии отдельным фоновым процессом, появляется период, когда данные закономерно отличаются. Это может быть приемлемо для отчёта, но опасно для проверки оплаты или остатка товара.
Наконец, оптимизация может перестать быть выгодной при изменении профиля нагрузки. Копия ускоряет чтение, но увеличивает объём хранения и усложняет запись. Поэтому после запуска сравнивайте те же показатели, что измеряли до изменения, и отдельно проверяйте свежесть данных.
1 План выполнения — описание выбранного PostgreSQL способа обработать запрос: например, какие таблицы и индексы читать и в каком порядке соединять данные.
Что делать дальше
Выберите один дорогой запрос и зафиксируйте его план, время, объём чтения и частоту запуска на данных, похожих на рабочие. Сначала проверьте нормализованный вариант. Если он не укладывается в требования, заведите для предлагаемой копии правило источника истины, обновления и допустимой задержки — и только потом сравнивайте варианты в тесте. Инвалидация прикладного кеша — отдельная тема; здесь речь о данных и запросах PostgreSQL.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: PostgreSQL 16: ограничения целостности данных, PostgreSQL 16: материализованные представления
Частые вопросы
JOIN в PostgreSQL всегда медленнее, чем чтение одной таблицы?
Нужно ли копировать имя и цену товара в заказ?
Что выбрать для отчёта, который считает итоги по большим таблицам?
Материализованное представление обновляется при изменении исходных таблиц?
Если в PostgreSQL другая версия, эти рекомендации подойдут?
Какие подводные камни ждать в проде после денормализации?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.