1. Одна таблица: соблазн и цена
Идея одной большой таблицы обычно приходит очень честно и по‑человечески: «Зачем мне пять таблиц, если можно сделать одну? Я же не DBA, я программист». В этот момент внутри каждого начинающего разработчика просыпается маленький Excel‑энтузиаст: хочется один лист, тысячу колонок и ощущение контроля над вселенной.
В домене mini‑shop эта «универсальная таблица» выглядит примерно так: и заказ, и покупатель, и товары, и категории — всё в одном месте. На старте даже кажется, что так проще читать: открываешь таблицу и «видишь всё». Но как только в одном заказе появляется второй товар, таблица начинает вести себя как чемодан без ручки: нести неудобно, бросить жалко.
Вот упрощённый пример «плоской» таблицы заказа — плохой, но очень соблазнительный дизайн:
-- Плохой пример: в одной таблице смешаны заказ, покупатель, товар и категория
-- Это почти гарантированно приведёт к дублям и аномалиям при изменениях
CREATE TABLE order_flat (
order_number VARCHAR(30),
customer_email VARCHAR(255),
product_sku VARCHAR(50),
product_name VARCHAR(200),
category_code VARCHAR(50),
category_name VARCHAR(100),
quantity INT
);
А теперь представьте один заказ с двумя позициями. В реляционной модели это не одна строка, а как минимум две, потому что у нас повторяется набор «товар + количество». Таблица начинает дублировать «шапку заказа» в каждой строке.
Пример данных — условный, но жизненный:
| order_number | customer_email | product_sku | product_name | category_code | category_name | quantity |
|---|---|---|---|---|---|---|
| ORD-1001 | alice@example.com | BOOK-001 | Clean Code | BOOKS | Books | 1 |
| ORD-1001 | alice@example.com | GAME-777 | Chess Set | GAMES | Games | 1 |
Пока всего две строки — вроде терпимо. Но в реальном магазине заказов много, позиций в заказе много, товар встречается в разных заказах, категория встречается почти у каждого товара, и внезапно «одна табличка» становится фабрикой повторов.
2. Избыточность и противоречия
Нормализация начинается не с терминов, а с запаха. У данных есть «запах избыточности»: вы видите одно и то же значение, которое копируется в десятках строк, и понимаете, что однажды оно обязательно начнёт жить в разных вариантах. И вот тогда системой уже управляете не вы — она начинает управлять вами (а заодно и вашими дедлайнами).
В order_flat повторяется всё, что относится не к позиции, а к чему-то более «верхнему» или более «справочному»: email покупателя повторяется в каждой позиции заказа, название товара повторяется в каждом заказе с этим товаром, название категории повторяется у каждого товара, который к ней относится. В итоге одно логическое значение существует во многих физических местах.
Проблема не только в том, что это «занимает место». Память сейчас дешёвая, а вот ошибки и противоречия — дорогие. Представьте, что вы решили переименовать категорию BOOKS из Books в Books and Media. В плоской таблице это превращается в массовую правку множества строк:
-- Риск: правка размазана по множеству строк, можно обновить не всё и получить противоречия
UPDATE order_flat
SET category_name = 'Books and Media'
WHERE category_code = 'BOOKS';
Казалось бы, команда одна — и всё хорошо. Но реальная жизнь любит усложнять: иногда такие обновления делают не одним SQL‑скриптом, а через приложение, частями, порциями, руками «быстренько поправили в админке». И вот у вас уже появляются строки, где category_code = BOOKS, но category_name где-то осталось старым. В итоге база хранит взаимоисключающие версии реальности, а вы потом пишете баг‑репорт уровня «почему у нас в отчёте две категории BOOKS?».
Плоская таблица подталкивает к тому, что «правда» размазана. Нормализация, наоборот, пытается сделать так, чтобы у каждого факта было одно место хранения: одна категория — одна строка в таблице категорий, один товар — одна строка в таблице товаров, один заказ — одна строка в таблице заказов.
3. Аномалии данных в широкой таблице
Когда разработчики слышат слово «аномалия», кажется, что сейчас начнётся что-то из учебника с формулами и грустью. Но в практическом смысле аномалии — это просто ситуации, где схема заставляет делать странные, неестественные действия или порождает потери данных. Это не «академия», а обычная разработка, которая внезапно стала неприятной.
Аномалия обновления в плоской таблице — это когда одно логическое изменение приходится размазывать по множеству строк. Например, у покупателя изменился email (да, бывает: люди меняют домен компании или просто устают от старого адреса). Если email хранится в каждой строке заказа, то обновлять нужно не «заказ», а «все строки заказа». И чем больше строк, тем выше шанс ошибиться, обновить не всё или обновить лишнее.
Аномалия удаления — ещё более коварная. Представьте, что вы храните заказ только в order_flat. У заказа есть две строки — две позиции. Вы удаляете одну позицию — всё ещё остаётся другая строка, и заказ «жив». Но если вы удаляете последнюю позицию, то вместе с ней исчезает и вся «шапка заказа», потому что шапка у вас нигде отдельно не хранится. Это почти философская проблема: «Если в лесу упало дерево, но никто не слышал — упало ли оно?» В плоской таблице это превращается в вопрос: «Если удалили последнюю позицию — существовал ли вообще заказ?»
Выглядит это примерно так:
-- Осторожно: если это последняя позиция заказа, вместе с ней исчезает и сам "заказ" как факт
DELETE FROM order_flat
WHERE order_number = 'ORD-1001' AND product_sku = 'GAME-777';
Если это была последняя строка заказа, то после удаления у вас в БД не останется никакого следа, что заказ вообще когда-то был. Иногда это допустимо (если бизнес так решил), но чаще это неожиданная потеря данных.
Аномалия вставки — когда вам нужно добавить «справочную» сущность, но схема заставляет притворяться, что уже есть заказ. Например, вы хотите добавить новую категорию SPORTS. В нормальном мире вы бы просто вставили категорию в справочник. В плоской таблице вы не можете «просто добавить категорию» — у вас нет места, где категория живёт сама по себе. Вам приходится либо ждать первого заказа с этой категорией, либо вставлять строку с кучей NULL или «пустышек». А это уже пахнет тем, что таблица хранит несколько разных сущностей одновременно.
Есть ещё один практический сигнал проблемы: появляются лишние NULL-колонки. Когда вы пытаетесь впихнуть в одну таблицу разные типы данных (справочники, заказы, остатки), часть колонок будет регулярно пустовать. Обычно это означает не «у нас гибкая модель», а «у нас в одной таблице живут разные сущности, которым не место вместе».
4. Нормализация: один факт — одно место
Нормализация часто воспринимается как набор правил вроде «третья нормальная форма» и «функциональные зависимости». Это всё существует, но на junior‑уровне полезнее другая ментальная модель: нормализация — это здравый смысл, который уменьшает повторы и противоречия. Если вы чувствуете, что значения копируются, а любое изменение превращается в массовую правку, значит, данные просятся в отдельную таблицу.
Самый простой принцип, который реально работает: каждая таблица должна описывать один тип «вещи» или один тип «факта». Категория — это отдельная вещь, товар — отдельная вещь, заказ — отдельная вещь, позиция заказа — отдельный факт внутри заказа, остатки — отдельный факт про товар. Если держать всё это в одной таблице, вы заставляете одну структуру данных выполнять работу нескольких.
Второй принцип: повторяющийся набор данных почти всегда означает отдельную таблицу. У заказа есть позиции, и позиций много — значит, «позиция заказа» — это отдельная сущность или отдельная таблица. Это не «сложность ради сложности», а честное признание: в одной строке нельзя естественно хранить много однотипных элементов.
Третий принцип, который очень помогает в проектировании: разный «жизненный цикл» — повод разделять. Категория создаётся редко и меняется редко. Товар меняется иногда (цена, статус, название). Заказ создаётся часто и после создания обычно не должен переписываться как попало. Остатки меняются постоянно. Когда вы смешиваете данные с разным ритмом жизни, вы либо обновляете слишком много строк, либо случайно трогаете то, что трогать не должны.
Чтобы видеть это как картинку, удобно держать в голове такую схему домена mini‑shop — не как JPA‑модель, а как SQL‑карту смыслов:
erDiagram
CATEGORY ||--o{ PRODUCT : contains
PRODUCT ||--|| STOCK_ITEM : has
CUSTOMER_ORDER ||--o{ ORDER_ITEM : includes
ORDER_ITEM }o--|| PRODUCT : refers
Здесь нет ничего «магического»: это просто честное разделение «что есть что» и как одно относится к другому. Дальше ключи и ограничения из прошлой лекции становятся тем самым клеем, который держит эти таблицы в одном мире данных.
5. Mini-shop: схема вместо плоского заказа
Теперь применим нормализацию к нашему домену так, чтобы это выглядело не как теория, а как инженерная работа. Начнём с самого болезненного места плоской модели — заказа с несколькими товарами. Потом отделим справочные сущности (категории и товары) и отдельно вынесем остатки. Возьмём укрупнённый фрагмент схемы: только те таблицы и связи, на которых видно, как заказ, позиция, товар, категория и остатки расходятся по разным местам хранения. Наша цель сегодня не «идеальная схема на века», а понятная и логичная модель без гигантской таблицы.
Заказ и позиции заказа: две таблицы
Заказ (customer_order) — это «шапка»: номер, email, статус, сумма и т.д. Позиция (order_item) — это «строка состава заказа»: какой товар, сколько штук, по какой цене. Позиции повторяются, а шапка должна быть одна. Поэтому нормальная схема почти неизбежно выглядит так:
-- "Шапка" заказа: одна строка на один заказ
CREATE TABLE customer_order (
id BIGINT PRIMARY KEY,
-- Бизнес-идентификатор заказа (то, что видит пользователь/интеграции)
order_number VARCHAR(30) NOT NULL UNIQUE,
-- В упрощённом примере покупателя идентифицируем email'ом
customer_email VARCHAR(255) NOT NULL
);
-- Позиция заказа: много строк на один заказ
CREATE TABLE order_item (
id BIGINT PRIMARY KEY,
-- FK на "шапку": позиция не должна существовать без заказа
order_id BIGINT NOT NULL,
-- Ссылка на товар в каталоге
product_id BIGINT NOT NULL,
-- Количество товара в конкретной позиции
quantity INT NOT NULL,
FOREIGN KEY (order_id) REFERENCES customer_order(id)
);
Я специально держу пример коротким: без статусов, без денег и без адресов. Смысл сейчас в разделении «один заказ» и «много строк состава». Ключ order_id — это тот самый FK, который не даст позиции «висеть в воздухе» без заказа.
Категория и товар: справочники отдельно
Категория — это справочник. У неё есть код и название, и она должна существовать сама по себе. Товар — это сущность каталога, он принадлежит категории. Всё это нормально описывается отдельными таблицами:
-- Справочник категорий: живёт независимо от заказов
CREATE TABLE category (
id BIGINT PRIMARY KEY,
-- Короткий стабильный код (удобен для интеграций/читаемости)
code VARCHAR(50) NOT NULL UNIQUE,
-- Человекочитаемое имя категории
name VARCHAR(100) NOT NULL
);
-- Каталог товаров: каждая строка — один товар
CREATE TABLE product (
id BIGINT PRIMARY KEY,
-- Бизнес-идентификатор товара (SKU)
sku VARCHAR(50) NOT NULL UNIQUE,
-- Название товара для витрины/отчётов
name VARCHAR(200) NOT NULL,
-- FK на категорию: товар обязан принадлежать категории
category_id BIGINT NOT NULL,
FOREIGN KEY (category_id) REFERENCES category(id)
);
И в этот момент закрывается вторая ссылка из позиции заказа. order_item.product_id — это не просто число «про какой-то товар», а такая же обязательная ссылка на product.id, как order_id на сам заказ. Если показать это на языке SQL, связь добавляется так:
ALTER TABLE order_item
ADD FOREIGN KEY (product_id) REFERENCES product(id);
Теперь позиция заказа не может указывать на несуществующий товар — иначе нормализация быстро превратится в набор красивых, но слабо связанных таблиц.
И вот здесь становится видно главное преимущество: если вы переименовываете категорию, вы делаете это в одном месте. Без массового обновления всех заказов и всех товаров, где она когда-то фигурировала текстом.
UPDATE category
SET name = 'Books and Media'
WHERE code = 'BOOKS';
И всё: одна строка. Одна правда.
Остатки (stock_item): отдельно от товара
Остатки — это не «часть товара», а отдельный кусок данных с другим жизненным циклом. Название товара меняется редко, остатки — постоянно. Если вы храните их в одной таблице product, то превращаете каталог в таблицу, которая обновляется каждую минуту. В учебном проекте это может показаться мелочью, но как архитектурная привычка — вредно.
Поэтому обычно остатки выносят отдельно:
-- Остатки на складе: отдельная таблица с отдельным жизненным циклом
CREATE TABLE stock_item (
id BIGINT PRIMARY KEY,
-- "Один товар — одна запись остатков" (в рамках упрощения: один склад)
product_id BIGINT NOT NULL UNIQUE,
-- Доступно к продаже прямо сейчас
available_quantity INT NOT NULL,
-- Зарезервировано под заказы (ещё не списано)
reserved_quantity INT NOT NULL,
FOREIGN KEY (product_id) REFERENCES product(id)
);
Обратите внимание на UNIQUE на product_id: этим мы фиксируем правило «одна запись остатков на один товар» (в нашем упрощённом домене один склад). Это хороший пример того, как ограничения из прошлой лекции поддерживают смысл модели.
Важная мысль: иногда “дублировать” — это нормально
Нормализация не означает «никогда не повторяй данные». В заказах очень часто есть поле unit_price в order_item, хотя цена есть и в product. Новичку это выглядит как «ошибка нормализации», но на самом деле это разные факты. Цена товара в каталоге — это «текущая цена сейчас», а unit_price в позиции заказа — это «цена в момент покупки». Если завтра цена в каталоге изменится, ваш заказ не должен переписать историю.
Вот это и есть пример здравого смысла: мы разделяем данные по смыслу и по времени жизни. Нормализация помогает увидеть, где повтор — это действительно повтор, а где — новая самостоятельная «истина» с другим временем и контекстом.
6. Достаточная нормализация без религии
После первых успехов с нормализацией иногда появляется другая крайность: хочется разделить всё на максимум таблиц, потому что «так правильно». В этот момент база данных начинает напоминать шкаф, где носки лежат отдельно от левого носка, а левый носок — отдельно от его настроения. Формально, наверное, можно. Практически — вы сами себе усложняете жизнь.
В бэкенд‑проектах уровня junior/junior+ обычно достаточно дойти до состояния, где основные аномалии сняты: справочники живут отдельно, повторяющиеся наборы вынесены, связи выражены FK, уникальность бизнес‑полей зафиксирована, обязательные поля — NOT NULL. Это уже делает модель устойчивой: её можно менять, можно проверять, можно читать и не бояться, что данные противоречат друг другу.
Полезно держать в голове небольшое сравнение — не как догму, а как «здравый смысл»:
| Ситуация | Плоская таблица | Нормальная схема |
|---|---|---|
| Переименовать категорию | Нужно обновить множество строк | Обновляется одна строка в category |
| Удалить позицию заказа | Риск потерять “шапку заказа” | Удаляется строка из order_item, заказ остаётся |
| Добавить категорию заранее | Непонятно куда вставлять, появляются NULL | Просто вставка в category |
| Хранить цену на момент покупки | Трудно объяснить, где “правда” | product.price — текущая, order_item.unit_price — историческая |
Заметьте: мы всё ещё говорим о логике данных, а не о «крутой оптимизации». Нормализация здесь не про скорость, а про то, чтобы данные были непротиворечивыми и управляемыми.
7. Типичные ошибки при нормализации
Ошибка №1: делать «универсальную таблицу», чтобы избежать связей.
Кажется, что одна таблица проще: не нужно писать JOIN, не нужны FK, всё в одном месте. Но это работает только на старте. При первом же изменении структуры данные начинают конфликтовать, появляются дубли и несогласованность. Связи — это не усложнение, а способ зафиксировать смысл в схеме.
Ошибка №2: создавать «связи по договорённости» без FOREIGN KEY.
Когда вместо category_id используется строка category_code, база теряет контроль над целостностью. Любая опечатка превращает данные в несвязанные куски. Если связь важна, она должна быть выражена через FK, а не держаться на договорённости между разработчиками.
Ошибка №3: нормализовать «до атомов» без учёта чтения.
Можно разложить всё по таблицам так, что простая выборка превращается в сложную цепочку JOIN. Формально схема «правильная», но практически неудобная. Нормализация должна упрощать модель и работу с ней, а не превращать чтение в инженерный квест.
Ошибка №4: хранить списки значений в одной колонке.
sku_list = 'BOOK-001,GAME-777' выглядит как быстрое решение, но ломает всю идею реляционной модели. Такие данные нельзя нормально фильтровать, валидировать и связывать через FK. Если есть повторяющиеся элементы, им нужна отдельная таблица, а не строка с разделителями.
Ошибка №5: считать любое дублирование ошибкой.
Не всё повторение — зло. Поля вроде unit_price в order_item фиксируют состояние на момент события, а не дублируют «текущую цену». То же касается агрегатов вроде total_amount. Важно понимать смысл данных: иногда дублирование — это не нарушение нормализации, а осознанное решение.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ