9. Нормализация. Введение

Искусство разделять и властвовать

В предыдущих главах мы подробно изучали механику физического уровня: как байты укладываются в 8-килобайтные страницы, как работают индексы и что происходит внутри ядра СУБД при системных вызовах. Но сколь бы быстрым ни был ваш сервер и сколь бы производительными ни были NVMe-накопители, аппаратная мощь бессильна перед архитектурными ошибками. Небрежно спроектированная схема базы данных способна свести на нет любые усилия инженеров.

Нормализация — это математически выверенный процесс декомпозиции (разделения) отношений с целью полного устранения избыточности данных и предотвращения логических аномалий при их модификации.

Говоря простым инженерным языком: это строгий свод правил, предписывающий разбить одну гигантскую, рыхлую «плоскую» таблицу на компактные, логически независимые сущности, соединенные надежными связями через 6. Первичные и внешние ключи. Зачем это необходимо и какую цену мы платим за этот порядок?


Плоский мир и его проблемы (Аномалии данных)

Каждый разработчик в начале своего профессионального пути испытывает соблазн спроектировать единую плоскую таблицу. Это кажется естественным: ведь именно так данные отображаются в электронных таблицах Excel или возвращаются в JSON-ответах внешних API.

Представим наивную таблицу заказов интернет-магазина:

order_id user_email user_phone product_name category price
1 bob@go.dev 555-01 Gopher Plush Toys 20
2 bob@go.dev 555-01 Go Book Books 40
3 alice@go.dev 555-02 Gopher Plush Toys 20

На первый взгляд, такая структура выглядит привлекательно: один простой запрос SELECT * FROM orders WHERE user_email = 'bob@go.dev' моментально возвращает всю историю покупок Боба без единого JOIN-а. Однако в боевых условиях такая схема превращается в пороховую бочку. В реляционной теории это называется тремя классическими Аномалиями данных.

1. Аномалия обновления (Update Anomaly)

Пользователь Боб сменил номер телефона. Если Боб — постоянный клиент, у него в таблице накоплено 10 000 заказов за пять лет.
Чтобы актуализировать один номер телефона, СУБД обязана найти и перезаписать все 10 000 физических строк.

  • Катастрофическое последствие: Если посреди этой тяжелой операции произойдет аппаратный сбой, отвалится сетевое соединение или возникнет дедлок блокировок, часть строк обновится, а часть сохранит старый номер. База данных погрузится в неконсистентное состояние, а служба поддержки потеряет возможность связаться с клиентом.

2. Аномалия удаления (Deletion Anomaly)

Боб оформил лишь один пробный заказ (order_id = 1), но затем передумал и отменил его. Сервис выполняет операцию:
DELETE FROM orders WHERE order_id = 1.

  • Катастрофическое последствие: Вместе с удалением строки заказа мы безвозвратно стираем из базы данных учетную запись самого Боба — его email и телефон. Пользователь фактически перестает существовать в нашей системе лишь потому, что у него нет активных покупок.

3. Аномалия вставки (Insertion Anomaly)

Отдел маркетинга добавляет в товарный каталог новую позицию — фирменную кружку с логотипом Go, которую пока еще никто не успел купить.

  • Катастрофическое последствие: Мы физически не можем вставить запись о товаре в таблицу, так как атрибут order_id является обязательным идентификатором. Единственный костыль — вставлять NULL во все поля заказа и пользователя, разрушая логику целостности и превращая базу в склад мусора.

Mechanical Sympathy: Налог на избыточность

Помимо логических аномалий, плоские денормализованные структуры накладывают тяжелый «налог» на физическое оборудование сервера.

Нормализация устраняет этот налог с математической строгостью: мы изолируем user_email в независимую таблицу users, а в таблице заказов сохраняем лишь компактный 8-байтный идентификатор user_id (BIGINT).

flowchart TD
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    subgraph Плоская архитектура
        Flat["orders_flat<br>order_id, email, phone, product..."]:::process
    end

    subgraph Нормализованная архитектура
        Users["users<br>id PK, email, phone"]:::process
        Orders["orders<br>id PK, user_id FK, product_id FK"]:::process
        Products["products<br>id PK, name, category, price"]:::process

        Users -->|"1 ко многим"| Orders
        Products -->|"1 ко многим"| Orders
    end
  

Цена нормализации: Операция JOIN

В архитектуре высоконагруженных систем не существует бесплатных решений — инженерия всегда состоит из компромиссов. Нормализуя схему, мы побеждаем избыточность, ликвидируем аномалии, ускоряем операции вставки (INSERT) и модификации (UPDATE), а также экономим гигабайты памяти.

Однако платой за порядок становится штраф на чтение (Read Penalty).

Чтобы сформировать страницу заказа для пользователя, бэкенд больше не может прочитать один непрерывный кусок данных с диска. Движок базы данных обязан выполнить операцию JOIN — вычислительное объединение независимых множеств в оперативной памяти.


Архитектура в Go: Маппинг нормализованных данных

При разработке сервисов на Go мы редко транслируем нормализованные таблицы один к одному в доменные модели. На уровне транспортного слоя или сервиса бизнес-логики мы проектируем специализированные структуры — DTO (Data Transfer Object), которые аккумулируют результат JOIN-выборки за одно эффективное сетевое обращение к СУБД:

Go
package main

import (
    "context"
    "database/sql"
)

// OrderDetailsDTO - Data Transfer Object для ответа клиенту
// В Go мы собираем нормализованные данные из базы обратно в плоскую структуру
type OrderDetailsDTO struct {
    OrderID     int64
    UserEmail   string
    ProductName string
    Price       int
}

func getOrderDetails(ctx context.Context, db *sql.DB, orderID int64) (*OrderDetailsDTO, error) {
    // База данных делает тяжелую работу по объединению нормализованных таблиц
    query := `
        SELECT o.id, u.email, p.name, p.price
        FROM orders o
        JOIN users u ON o.user_id = u.id
        JOIN products p ON o.product_id = p.id
        WHERE o.id = $1
    `

    var dto OrderDetailsDTO
    err := db.QueryRowContext(ctx, query, orderID).Scan(
        &dto.OrderID, 
        &dto.UserEmail, 
        &dto.ProductName, 
        &dto.Price,
    )

    if err != nil {
        return nil, err
    }
    return &dto, nil
}

Итог

  1. Нормализация — это математическая дисциплина декомпозиции отношений, устраняющая аномалии обновления, удаления и вставки.
  2. На уровне физического «железа» нормализация снижает нагрузку на Buffer Pool, уменьшает объем генерируемого журнала предзаписи (WAL) и защищает оперативную память от замусоривания дубликатами.
  3. Главный инженерный компромисс нормализации: ускорение операций записи ценой усложнения чтения через операции JOIN (Nested Loop, Hash Join, Merge Join).
  4. Золотое правило индустрии: проектируйте схему нормализованной по умолчанию (3NF), а к денормализации и кэшированию прибегайте точечно на основе профилирования узких мест.

Теория реляционных баз данных выделяет шесть классических нормальных форм, каждая из которых последовательно устраняет определенные классы зависимостей. Наше погружение в алгоритмы нормализации мы начнем с первого и самого базового шага. Переходим к следующей главе: 10. Первая нормальная форма 1NF.