MidТеория4 min

Normalization vs Denormalization

3NF правила vs денормализация для чтения, материализованные views, document model, OLAP star schema

Что это

  • Normalization (нормализация) -- правила организации данных, минимизирующие дублирование. Каждая сущность -- в своей таблице; связи -- через ключи.
  • Denormalization (денормализация) -- намеренное дублирование данных ради скорости чтения или простоты запросов.
NORMALIZED (3NF)               DENORMALIZED
users(id, email)               orders(id, user_email, ...)
orders(id, user_id)            -- user_email хранится в заказе

Чтобы получить письмо юзера заказа:
JOIN users ON orders.user_id    просто SELECT user_email FROM orders
= 2 таблицы, индексы             = одна таблица, одно обращение

Это спектр, а не бинарный выбор. OLTP тяготеет к нормализации, OLAP и document-store -- к денормализации, реальные системы -- где-то посередине.

Нормальные формы (кратко)

Форма Правило Что нарушает
1NF Атомарные значения, нет повторяющихся групп phones как CSV-строка
2NF Нет частичных зависимостей от составного PK Поле зависит от части PK
3NF Нет транзитивных зависимостей (non-key → non-key) city зависит от zip, а не от user_id
BCNF Усиленная 3NF Редко нарушается

3NF -- практический дефолт для OLTP. Более высокие формы редко стоят усилий.

Когда Normalized лучше

  • Write-heavy система. Обновление email -- в одном месте, не в 1000 заказах.
  • Целостность через FK. БД гарантирует, что нельзя создать заказ для несуществующего пользователя.
  • Компактное хранение. Меньше дублирования = меньше диск и RAM для hot data.
  • Простота инвариантов. "Email пользователя -- единственная истина" -- в одной таблице.
  • Частые схема-изменения. Изменить структуру адреса -- в одной таблице.
  • Строгий аудит. Источник правды один, не надо сверять копии.

Когда нормализация "чище"

  • OLTP-системы (заказы, платежи, учёт).
  • Системы записей (CRM, ERP, HR).
  • Где 10+ сущностей связаны.

Когда Denormalized лучше

  • Read-heavy. Читаем в 100-1000 раз чаще, чем пишем -- экономим JOIN.
  • Одностраничные отчёты. Денормализованная витрина -- один запрос без JOIN.
  • Legitimate доменный snapshot. Заказ -- "запись в тот момент"; email клиента на момент заказа должен оставаться тем, каким он был.
  • Document-oriented БД. Mongo-заказ с вложенными позициями и снимком пользователя -- естественно.
  • OLAP. Star/snowflake schema с fact-таблицами и dimension-таблицами.
  • Ограничения движка. Cassandra, DynamoDB не умеют JOIN; схема диктуется доступом.

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

Что Пример
Snapshot полей order.customer_email копируется в момент заказа
Предвычисленные агрегаты user.posts_count вместо SELECT COUNT(*)
Material view Материализованный список "топ товаров"
Full copy Search index: дублирует документы из primary
Flat history events(user_id, email_at_time, ...) для аудита

Почему "snapshot" обычно правильный

Типичная ошибка: ссылка на mutable entity.

-- Неправильно: если пользователь сменит адрес, исторический заказ перепишется.
orders(id, user_id) JOIN users(id, address) -- address сейчас, а не на момент заказа

-- Правильно: snapshot на момент заказа.
orders(id, user_id, shipping_address_snapshot)

Финансовые / логистические / юридические системы почти всегда хранят snapshot -- это не денормализация "ради скорости", это корректность домена.

Таблица: normalized vs denormalized

Ось Normalized Denormalized
Дублирование Минимум Сознательное
Read (сложный) JOIN'ы Одно обращение
Write Единая точка Fan-out update
Storage Компактно Избыточно
Schema evolution Проще Сложнее (обновлять везде)
FK / constraint Естественно Своими силами
Типичная БД SQL OLTP Document, wide-column, search, OLAP
Типичный кейс Транзакции, записи Feeds, каталоги, аналитика

Гибрид 1: материализованные views

Нормализованный источник + денормализованная копия, обновляемая автоматически.

Способы обновления

Способ Консистентность Complexity
PostgreSQL MATERIALIZED VIEW + REFRESH Периодически Низкая
CONCURRENT REFRESH Без блокировки чтений Низкая
Trigger-based Near realtime Средняя
CDC (Debezium) → отдельная БД Eventual, near realtime Высокая
Приложением в transaction (outbox) Sync / async Средняя

Когда идеален паттерн

  • Дашборды (обновление раз в 5 минут достаточно).
  • Специфичные выборки с тяжёлыми JOIN.
  • Поисковый индекс (Elasticsearch) как материализованная view primary БД.

Гибрид 2: кеш горячих данных

Нормализованные таблицы + Redis с денормализованной "карточкой" объекта, готовой к отдаче.

user:42 = {"id":42,"email":"...","city":"...","posts_count":17}

Write: инвалидация/обновление кеша. Read: одно обращение в Redis.

Гибрид 3: документ с эмбеддингом + ссылки

В Mongo / Postgres JSONB можно хранить:

  • Ядро сущности нормализовано,
  • Редко меняющиеся дочерние -- эмбеддед (денорм),
  • Часто меняющиеся или шаренные -- ссылкой.
{
  "_id": "order-1",
  "user_id": "u-42",                        // ссылка, live lookup
  "user_snapshot": {"email": "...", "name": "..."}, // snapshot на момент
  "items": [                                // эмбеддед, только для этого заказа
    {"sku": "X", "qty": 2, "price": 9.99}
  ]
}

Star schema (OLAP)

Аналитические хранилища (ClickHouse, BigQuery, Redshift) почти всегда денормализованы в star schema.

             ┌────────────┐
             │ dim_users  │
             └─────┬──────┘
                   │
┌────────────┐    ▼    ┌──────────────┐
│ dim_time   │──▶ fact │ dim_products │
└────────────┘  orders └──────────────┘
                   ▲
             ┌─────┴──────┐
             │ dim_region │
             └────────────┘

Fact-таблица -- миллиарды строк с foreign keys на dimensions. Dimensions -- широкие, редко меняются. JOIN простой (star), запросы быстрые.

Ещё шире -- wide-table: вообще всё в одной денормализованной таблице с повторяющимися dimension-полями. Быстрее читается, но занимает больше.

Код: нормализованный и денормализованный вариант одной сущности

<?php

declare(strict_types=1);

/**
 * Normalized schema:
 *   users(id, email, name)
 *   products(id, sku, name, price)
 *   orders(id, user_id, created_at, status)
 *   order_items(order_id, product_id, qty, price_snapshot)
 *
 * Trade-offs:
 *   + Update email in one place.
 *   + FK-enforced integrity.
 *   - Reading full order = 3 JOINs.
 */
final class OrderViewQuery
{
    public function __construct(private readonly \PDO $db) {}

    public function fetch(int $orderId): array
    {
        $sql = <<<SQL
            SELECT
              o.id            AS order_id,
              o.status,
              o.created_at,
              u.email         AS user_email,
              u.name          AS user_name,
              i.qty,
              i.price_snapshot,
              p.sku,
              p.name          AS product_name
            FROM orders o
            JOIN users u          ON u.id = o.user_id
            JOIN order_items i    ON i.order_id = o.id
            JOIN products p       ON p.id = i.product_id
            WHERE o.id = :id
        SQL;

        $stmt = $this->db->prepare($sql);
        $stmt->execute(['id' => $orderId]);
        return $stmt->fetchAll(\PDO::FETCH_ASSOC);
    }
}
## Матрица выбора
Ситуация Подход
OLTP core (заказы, платежи) Normalized
Исторические снапшоты (биллинг, отгрузка) Denormalized snapshot
Read-heavy каталог Denormalized или materialized view
Feed / timeline Denormalized (fan-out on write)
Search (Elasticsearch) Денормализованная копия
Analytics dashboard Star schema (OLAP)
Ранняя стадия, доступ неизвестен Normalized, добавить views по мере необходимости
Cassandra / DynamoDB Денорм обязательна (нет JOIN)

Антипаттерны

Антипаттерн Почему плохо
Денормализация без необходимости Bug прокрался = распространился в копиях
Нормализация до 5NF Каждая выборка -- 10 JOIN, ничего не работает
"Хранить имя пользователя только в users" для инвойсов Смена имени -- изменила историю
Material view без стратегии обновления Копия постоянно устаревает
Дублирование без описания источника правды Никто не знает, какая копия верна

Правило: пишите в нормализованное; читайте из того, что быстрее. И явно обозначайте, где "source of truth", а где "production copy".

Выводы

  • Нормализация оптимальна для write-heavy, целостности, компактности.
  • Денормализация -- для read-heavy, аналитики, снапшотов, document/OLAP-моделей.
  • Snapshot полей в заказах/инвойсах -- не "optimization", а корректность.
  • Материализованные views и search-индексы -- гибриды: primary нормализован, read-path денормализован.
  • Document-ориентированные БД подталкивают к денормализации, wide-column её требуют.
  • OLAP всегда денормализован -- star / snowflake schema.
  • Практическое правило: начните с 3NF, денормализуйте точечно по профилю нагрузки.