10. Первая нормальная форма 1NF
Фундамент реляционной строгости
В предыдущей статье 9. Нормализация. Введение мы наглядно убедились, к каким разрушительным аномалиям и деградации оборудования приводит хранение неструктурированных, хаотичных данных. Чтобы навести в схеме идеальный инженерный порядок, мы должны пропустить ее через последовательную систему математических сит — Нормальные формы (Normal Forms).
Первая нормальная форма (1NF) — это краеугольный камень всей реляционной архитектуры. Если таблица не удовлетворяет требованиям 1NF, она в принципе не может считаться реляционной. С точки зрения математики Эдгара Кодда, 1NF определяет фундаментальную демаркационную линию: границу между сырым неструктурированным потоком байт и строгим математическим отношением (Relation).
Четыре правила 1NF
Чтобы СУБД признала таблицу находящейся в Первой нормальной форме, схема обязана безоговорочно отвечать четырем классическим правилам:
- Атомарность атрибутов (Скалярность): В каждой ячейке (на пересечении конкретной строки и конкретного столбца) должно храниться строго одно логически неделимое, атомарное значение. Никаких списков через запятую, битовых упаковок разнородных полей или вложенных массивов.
- Однородность домена: Все значения в пределах одного столбца обязаны строго принадлежать к единому домену (типу данных). Недопустимо хранить число
42вперемешку со строками «нет данных» или «неизвестно» в колонке типаINTEGER. - Уникальность кортежей: В отношении не может существовать двух абсолютно неразличимых строк-дубликатов. Это требование выполняется автоматически при назначении корректного Первичного ключа (см. 6. Первичные и внешние ключи).
- Независимость от порядка: Ни физический порядок кортежей на страницах Heap-файла, ни порядок объявления столбцов в DDL не должны влиять на результат выборки и корректность логики прикладного сервиса.
Наиболее часто разработчики спотыкаются о самое первое правило: требование Атомарности.
Антипаттерн: Мультизначные атрибуты
Представим систему подбора IT-персонала. Начинающий разработчик проектирует таблицу кандидатов. Привыкнув оперировать структурами языка Go (где слайс []string является совершенно естественной конструкцией), он не задумываясь переносит этот список в базу данных в виде строки с разделителями-запятыми:
| id | name | skills (Нарушение 1NF!) |
|---|---|---|
| 1 | Alice | Go, PostgreSQL, Docker |
| 2 | Bob | PHP, MySQL, Redis, Go |
С точки зрения кода на Go, этот прием кажется простым: вычитал строку из базы, вызвал стандартную функцию strings.Split(skills, ",") и получил готовый слайс. Однако с точки зрения архитектуры базы данных и дисковой подсистемы — это инженерная катастрофа.
Mechanical Sympathy: Почему списки убивают БД
Представьте, что перед нашим сервисом встала элементарная бизнес-задача: найти всех кандидатов, владеющих языком Go.
Поскольку навыки упакованы в сырой текст через запятую, единственный доступный SQL-запрос выглядит так:
SELECT * FROM candidates WHERE skills LIKE '%Go%';
Приведение к 1NF: Разделяй и властвуй
Чтобы привести структуру к строгой Первой нормальной форме, мы обязаны ликвидировать повторяющиеся группы значений внутри одной ячейки. Для этого мультизначный атрибут выносится в отдельную, дочернюю таблицу со связью «один-ко-многим», поддержанной внешним ключом:
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 До нормализации - Нарушение 1NF
BadTable["Таблица candidates<br>id, name, skills_list"]:::process
end
subgraph После нормализации - 1NF
Cand["Таблица candidates<br>id PK, name"]:::process
Skills["Таблица candidate_skills<br>id PK, candidate_id FK, skill"]:::process
Cand -->|"1 ко многим"| Skills
end
Теперь поиск кандидатов со знанием Go трансформируется в изящную операцию:
SELECT candidate_id FROM candidate_skills WHERE skill = 'Go';
Повесив обычный B-Tree индекс на колонку skill, СУБД выполнит этот запрос за считанные микросекунды (Index Scan), считав с диска ровно 1–2 страницы памяти.
Impedance Mismatch: Маппинг 1NF в Go
Здесь мы снова встречаемся с классическим конфликтом моделей: в реляционной базе данных навыки кандидата нормализованы и распределены по отдельным строкам дочерней таблицы, тогда как в Go-сервисе доменная модель требует единую структуру со слайсом []string.
Идиоматичный подход в Go при работе с пакетом database/sql заключается в выполнении объединяющего запроса JOIN и аккуратной потоковой агрегации строк с помощью вспомогательной хэш-таблицы (map):
package main
import (
"context"
"database/sql"
)
// Candidate представляет собой агрегат из двух нормализованных таблиц
type Candidate struct {
ID int64
Name string
Skills []string
}
func fetchCandidates(ctx context.Context, db *sql.DB) ([]Candidate, error) {
// JOIN нормализованных данных (1 ко многим)
query := `
SELECT c.id, c.name, s.skill
FROM candidates c
LEFT JOIN candidate_skills s ON c.id = s.candidate_id
ORDER BY c.id
`
rows, err := db.QueryContext(ctx, query)
if err != nil {
return nil, err
}
defer rows.Close()
// Используем мапу для агрегации слайса навыков (устранение дубликатов строк кандидата)
lookup := make(map[int64]*Candidate)
var result []Candidate
for rows.Next() {
var id int64
var name string
var skill sql.NullString // Учитываем LEFT JOIN (навыков может не быть)
if err := rows.Scan(&id, &name, &skill); err != nil {
return nil, err
}
cand, exists := lookup[id]
if !exists {
// Создаем нового кандидата
lookup[id] = &Candidate{ID: id, Name: name, Skills: make([]string, 0)}
cand = lookup[id]
}
if skill.Valid {
cand.Skills = append(cand.Skills, skill.String)
}
}
if err := rows.Err(); err != nil {
return nil, err
}
// Переливаем из мапы в итоговый слайс (теряя порядок мапы, но сохраняя логику)
for _, c := range lookup {
result = append(result, *c)
}
return result, nil
}
Итог
- Первая нормальная форма (1NF) — фундамент реляционной дисциплины, требующий скалярности (атомарности) значений в ячейках и уникальности строк.
- Хранение списков через запятую в текстовых полях — тяжелейший антипаттерн, делающий B-Tree индексы бессильными и обрекающий СУБД на изнурительный Sequential Scan.
- Приведение к 1NF осуществляется вынесением повторяющихся списков в отдельную таблицу со связью «один-ко-многим» и построением B-Tree индекса по значению.
- В Go-коде мы компенсируем Impedance Mismatch потоковой агрегацией строк из
JOINв слайсы сущностей через хэш-таблицы. - Использование типов
JSONBи массивов в PostgreSQL — это осознанная денормализация, допустимая только при наличии специализированных GIN-индексов.
Мы очистили ячейки от списков и гарантировали атомарность каждого поля. Однако внутри наших таблиц все еще может скрываться неочевидное дублирование, возникающее при использовании составных первичных ключей. В следующей главе мы избавимся от частичных функциональных зависимостей: переходим к 11. Вторая нормальная форма 2NF.