11. Вторая нормальная форма 2NF

Анатомия составных ключей и частичных зависимостей

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

Следующий качественный шаг в архитектурном проектировании схем — Вторая нормальная форма (2NF).

В академических учебниках определение 2NF звучит безупречно строго: Отношение находится во второй нормальной форме, если оно удовлетворяет требованиям 1NF, и каждый неключевой атрибут функционально полно (неприводимо) зависит от всего первичного ключа.

В практическом инженерном преломлении это правило формулируется предельно наглядно:
Если у вас составной Первичный ключ (Primary Key, PK), состоящий из двух или более колонок, то все остальные атрибуты строки обязаны описывать связь этих колонок целиком, а не зависеть лишь от одного фрагмента ключа.


Проблема: Частичная зависимость

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

| student_id (PK) | course_id (PK) | grade | course_name | course_duration_hours |
| :--- | :--- | :--- | :--- | :--- | :--- |
| 1 | 101 | A | Go Backend | 40 |
| 1 | 102 | B | System Design | 20 |
| 2 | 101 | C | Go Backend | 40 |

В этой таблице первичный ключ является составным: кортеж (student_id, course_id). Только комбинация студента и курса уникально адресует конкретную оценку (grade).

В чем же заключается грубое нарушение 2NF?

  • Атрибут grade (оценка) зависит от всего составного ключа целиком: оценка выставляется конкретному студенту по конкретному курсу. Здесь всё корректно.
  • Однако атрибуты course_name (название курса) и course_duration_hours (продолжительность) функционально зависят только от course_id. Как называется курс и сколько часов он длится, абсолютно никак не зависит от того, какой именно студент на него записался!

Такая ситуация в реляционной алгебре называется Частичной зависимостью (Partial Dependency): неключевой столбец зависит лишь от части составного первичного ключа.

Mechanical Sympathy: Почему это убивает базу?

Неискушенному инженеру может показаться: ну дублируется строка "Go Backend" и число 40 в тысячах записей разных студентов, современные диски дешевы. В чем реальная опасность для высоконагруженной СУБД?

  1. Write Penalty (Тяжелый штраф на запись): Если методисты решат изменить длительность курса "Go Backend" с 40 до 50 часов, СУБД придется выполнить массивный запрос:
    UPDATE enrollments SET course_duration_hours = 50 WHERE course_id = 101.
    Если на курс записано 50 000 человек, база будет вынуждена переписать 50 000 строк! Это сгенерирует мегабайты дельт в журнале предзаписи 8. WAL. Write Ahead Log, перегрузит сеть репликации и наложит эксклюзивные блокировки на строки (Row-level Lock), заблокировав параллельные транзакции студентов.
  2. I/O Вампиризм и деградация кэша: Дублирующиеся текстовые поля раздувают физический размер каждого кортежа. В результате на одну 8-килобайтную страницу ОЗУ помещается в 3–4 раза меньше полезных записей. Чтобы рассчитать средний балл студентов, СУБД придется вычитывать с диска в разы больше страниц, провоцируя Cache Miss и вымывая из Buffer Pool «горячие» данные.

Приведение к 2NF: Выделение сущностей

Чтобы привести схему к строгой Второй нормальной форме, мы обязаны хирургически отсечь атрибуты с частичной зависимостью и вынести их в отдельную родительскую таблицу.

В нашем сценарии мы выделяем независимую таблицу courses, а исходная таблица enrollments очищается от дублирования и превращается в чистую таблицу связей (Junction Table / Join Table):

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 Нарушение 2NF
        Bad["Таблица enrollments<br>PK student_id<br>PK course_id<br>grade<br>course_name<br>course_duration"]:::process
    end

    subgraph Архитектура 2NF
        Courses["Таблица courses<br>PK id<br>name<br>duration_hours"]:::process
        Enroll["Таблица enrollments<br>PK, FK student_id<br>PK, FK course_id<br>grade"]:::process

        Courses -->|"1 ко многим"| Enroll
    end
  

Теперь таблица enrollments хранит исключительно атрибуты самой связи (оценку grade). Названия и характеристики курсов хранятся в таблице courses строго в одном экземпляре. Любое изменение метаданных курса превращается в молниеносный UPDATE ровно одной строки.



Архитектура Many-to-Many в Go

Взаимодействуя с нормализованной связью «Многие-ко-многим» в Go, мы формируем SQL-запрос с операцией JOIN, чтобы получить необходимые агрегированные данные за одно сетевое обращение к пулу СУБД:

Go
package main

import (
    "context"
    "database/sql"
    "fmt"
)

// CourseRecord - структура, отражающая данные из связи Многие-ко-многим
type CourseRecord struct {
    CourseID   int64
    CourseName string
    Grade      sql.NullString // Оценки может еще не быть
}

func GetStudentCourses(ctx context.Context, db *sql.DB, studentID int64) ([]CourseRecord, error) {
    // Декларативный SQL-запрос, собирающий нормализованные данные
    // Оптимизатор использует индекс (student_id, course_id) в таблице enrollments
    query := `
        SELECT c.id, c.name, e.grade
        FROM enrollments e
        JOIN courses c ON e.course_id = c.id
        WHERE e.student_id = $1
    `

    rows, err := db.QueryContext(ctx, query, studentID)
    if err != nil {
        return nil, fmt.Errorf("ошибка запроса курсов: %w", err)
    }
    defer rows.Close()

    var records []CourseRecord
    for rows.Next() {
        var rec CourseRecord
        // Маппим данные прямо в структуру
        if err := rows.Scan(&rec.CourseID, &rec.CourseName, &rec.Grade); err != nil {
            return nil, fmt.Errorf("ошибка сканирования: %w", err)
        }
        records = append(records, rec)
    }

    if err := rows.Err(); err != nil {
        return nil, fmt.Errorf("ошибка итерации: %w", err)
    }

    return records, nil
}

Итог

  1. Вторая нормальная форма (2NF) ликвидирует частичные функциональные зависимости: неключевые атрибуты обязаны зависеть от составного первичного ключа целиком.
  2. Для таблиц с простым первичным ключом из одной колонки выполнение 1NF автоматически гарантирует соблюдение 2NF.
  3. Нарушение 2NF влечет серьезный Write Penalty, блокировки строк (Row-level Lock), взрывной рост WAL и деградацию дискового ввода-вывода.
  4. Лекарство от нарушения 2NF — декомпозиция схемы с выделением классической таблицы связей (Junction Table) со связью «Многие-ко-многим».
  5. Помните о Leftmost Prefix Rule для составных B-Tree индексов: порядок колонок в составном ключе диктует доступность индекса для поисковых предикатов.

Мы устранили частичные зависимости от фрагментов ключа. Но что произойдет, если неключевой атрибут зависит от первичного ключа не напрямую, а через другой неключевой атрибут? В следующей главе мы разберем главный финальный рубеж нормализации для большинства Highload-систем: переходим к 12. Третья нормальная форма 3NF.