JavaRush /Курсы /Spring Data JPA /Нормализация без академической боли

Нормализация без академической боли

Spring Data JPA
2 уровень , 2 лекция
Открыта

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. Важно понимать смысл данных: иногда дублирование — это не нарушение нормализации, а осознанное решение.

1
Задача
Spring Data JPA, 2 уровень, 2 лекция
Недоступна
Разделите плоский заказ на шапку и позиции
Разделите плоский заказ на шапку и позиции
1
Задача
Spring Data JPA, 2 уровень, 2 лекция
Недоступна
Вынесите категорию из таблицы товара
Вынесите категорию из таблицы товара
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ