Что это
- 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, денормализуйте точечно по профилю нагрузки.