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 в тысячах записей разных студентов, современные диски дешевы. В чем реальная опасность для высоконагруженной СУБД?
- 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), заблокировав параллельные транзакции студентов. - 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, чтобы получить необходимые агрегированные данные за одно сетевое обращение к пулу СУБД:
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
}
Итог
- Вторая нормальная форма (2NF) ликвидирует частичные функциональные зависимости: неключевые атрибуты обязаны зависеть от составного первичного ключа целиком.
- Для таблиц с простым первичным ключом из одной колонки выполнение 1NF автоматически гарантирует соблюдение 2NF.
- Нарушение 2NF влечет серьезный Write Penalty, блокировки строк (Row-level Lock), взрывной рост WAL и деградацию дискового ввода-вывода.
- Лекарство от нарушения 2NF — декомпозиция схемы с выделением классической таблицы связей (Junction Table) со связью «Многие-ко-многим».
- Помните о Leftmost Prefix Rule для составных B-Tree индексов: порядок колонок в составном ключе диктует доступность индекса для поисковых предикатов.
Мы устранили частичные зависимости от фрагментов ключа. Но что произойдет, если неключевой атрибут зависит от первичного ключа не напрямую, а через другой неключевой атрибут? В следующей главе мы разберем главный финальный рубеж нормализации для большинства Highload-систем: переходим к 12. Третья нормальная форма 3NF.