5. Таблицы, строки, столбцы и ключи
От математики к железу: физика реляционной БД
В статье 4. Реляционная модель данных. Основы мы рассматривали базу данных как идеальную математическую вселенную, оперирующую абстрактными отношениями, кортежами и предикатами теории множеств. Но промышленная СУБД — это программно-аппаратный механизм, написанный на C/C++ (или Rust), который обязан подчиняться физическим законам кремния, ширины шины данных и механики накопителей (HDD/SSD/NVMe).
В этой главе мы спустимся с математических вершин в машинное отделение. Мы переведем теоретические понятия «отношение», «кортеж» и «атрибут» на инженерный язык физического хранения: таблицы, строки, столбцы и ключи, и детально изучим, как они устроены на уровне байтов в страницах памяти.
Таблица (Table) как структура хранения
На уровне SQL таблица предстает перед разработчиком логическим двухмерным контейнером. Однако на дисковом накопителе (на примере PostgreSQL, являющегося стандартом де-факто в современной Go-разработке) таблица организуется в структуру, именуемую Heap (Куча).
В Heap-файле нет и не может быть никакого естественного физического порядка. Если в запросе опущен модификатор ORDER BY, СУБД возвратит строки в том порядке, в котором их блоки попадутся на диске. Поиск по Heap-файлу без использования индексов всегда превращается в полное последовательное сканирование (Seq Scan), вынуждающее поднять в Buffer Pool весь файл таблицы от начала до конца.
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
Table["Таблица<br>Файл на диске"]:::process -->|"Разбивается на"| Page1["Страница 1<br>8 КБ"]
Table -->|"Разбивается на"| Page2["Страница 2<br>8 КБ"]
Table -->|"Разбивается на"| PageN["Страница N<br>8 КБ"]
Page1 -->|"Содержит"| ItemPointers["Массив указателей<br>ItemIds"]
Page1 -->|"Содержит"| Rows["Сами строки<br>Tuples"]
Каждая страница памяти (обычно 8 КБ) организована по схеме Slotted Page:
- В начале страницы располагается заголовок (
PageHeaderData) и массив указателей на строки (ItemIds), растущий сверху вниз. - Сами физические кортежи (
Tuples) укладываются с самого конца страницы снизу вверх. - Пространство между ними — это свободная память. Когда указатели и кортежи встречаются — страница считается заполненной.
Строка (Row) под капотом. Анатомия кортежа
В представлении начинающего разработчика строка — это просто полезные данные: (id=1, name='Bob'). В реальности же СУБД вынуждена хранить тяжелый массив служебных метаданных для каждой строки, чтобы обеспечить транзакционную изоляцию, видимость версий и блокировки.
В исходном коде PostgreSQL структура заголовка строки называется HeapTupleHeaderData. Она отнимает минимум 23 байта (а с учетом обязательного аппаратного выравнивания процессора — ровно 24 байта) до того, как начнутся ваши реальные пользовательские данные.
Mechanical Sympathy: Data Alignment (Выравнивание данных)
Здесь мы наблюдаем поразительное архитектурное единство между рантаймом языка Go и физическим движком СУБД. Речь идет о выравнивании данных в памяти (Memory Alignment и Padding).
Архитектура процессора устроена так, что чтение 64-битного целого числа (int64 / BIGINT) по невыровненному адресу (не кратному 8 байтам) требует либо двух циклов шины памяти, либо вызывает аппаратное исключение. Поэтому компилятор Go и подсистема хранения СУБД автоматически вставляют пустые холостые байты — Padding.
Взглянем на структуры в Go:
// ПЛОХАЯ СТРУКТУРА: Весит 24 байта из-за padding
type BadUser struct {
IsActive bool // 1 байт
// 7 байт мусора (padding)
ID int64 // 8 байт
RoleID int8 // 1 байт
// 7 байт мусора (padding)
}
// ХОРОШАЯ СТРУКТУРА: Весит 16 байт
type GoodUser struct {
ID int64 // 8 байт
IsActive bool // 1 байт
RoleID int8 // 1 байт
// 6 байт мусора в конце для выравнивания всей структуры
}
В реляционной СУБД разворачивается абсолютно та же драма!
Если при проектировании схемы DDL вы объявляете колонки в неоптимальном порядке:
CREATE TABLE bad_users (
is_active BOOLEAN, -- 1 байт + 7 байт padding!
id BIGINT, -- 8 байт
role_id SMALLINT -- 2 байта + 6 байт padding!
);
СУБД будет вынуждена вставлять пустые байты между атрибутами каждой отдельной строки!
На объеме в 100 миллионов записей невидимый padding раздует размер таблицы на 20–30 гигабайт чистого мусора. Чем больше физический размер Heap-файла — тем больше страниц приходится вычитывать с диска, тем меньше полезных строк помещается в Buffer Pool (RAM), тем выше процент промахов (Cache Miss) и тем медленнее работает весь бэкенд.
Столбцы (Columns) и техника TOAST
Каждый столбец имеет закрепленный тип данных (домен). Но что происходит, когда в колонку типа TEXT, BYTEA или JSONB сохраняется массивный полезный груз — например, JSON-документ размером 500 КБ?
Мы помним жесткое физическое ограничение: размер страницы в PostgreSQL равен ровно 8 КБ, и кортеж обязан размещаться внутри страницы. Чтобы обойти это препятствие, СУБД использует механизм TOAST (The Oversized-Attribute Storage Technique).
Алгоритм работы TOAST:
- Если размер строки превышает пороговое значение (в Postgres — около 2 КБ,
TOAST_TUPLE_THRESHOLD), СУБД пробует прозрачно сжать данные столбца на лету (алгоритмамиpglzили современнымlz4). - Если после сжатия строка все еще не помещается в допустимый лимит, СУБД физически «вырезает» большой атрибут из кортежа основной таблицы.
- Данные разрезаются на фрагменты по 2 КБ и сохраняются в отдельную скрытую служебную таблицу (TOAST-таблицу). В исходной строке остается лишь компактный 18-байтный указатель-дескриптор.
Ключи (Keys). Фундамент идентификации
Поскольку физический Heap — это неупорядоченное нагромождение кортежей, СУБД необходим математический гарант однозначной идентификации любой записи. Эту миссию выполняют Ключи.
В реляционной теории принята строгая иерархия ключей:
- Суперключ (Superkey): Любой набор столбцов, который уникально идентифицирует строку. Например,
(ID, Email, Возраст)(хотя поле «Возраст» здесь явно избыточно, но уникальность гарантирована). - Потенциальный ключ (Candidate Key): Минимальный суперключ, из которого нельзя удалить ни одного атрибута без потери свойства уникальности. Например, отдельно
IDи отдельноEmail. - Первичный ключ (Primary Key / PK): Один из потенциальных ключей, выбранный инженером-проектировщиком в качестве канонического идентификатора строки. По стандарту SQL первичный ключ неявно накладывает ограничения
UNIQUE NOT NULLи автоматически порождает уникальный B-Tree индекс.
Суррогатные vs Естественные ключи
Классическая дилемма проектирования реляционных схем:
- Естественный ключ (Natural Key): Бизнес-атрибут, объективно существующий в реальном мире (номер паспорта, ИНН компании, VIN автомобиля).
- Скрытая угроза: Бизнес-реальность переменчива. Формат ИНН может измениться при выходе на международный рынок, паспорт может быть утерян и заменен. Изменение естественного ключа требует тяжелых каскадных обновлений во всех дочерних таблицах, что под нагрузкой блокирует систему и приводит к дедлокам.
- Суррогатный ключ (Surrogate Key): Искусственный идентификатор, лишенный какого-либо прикладного бизнес-смысла. Обычно это автоинкрементный
BIGINT(Sequence / Identity) илиUUID.- Индустриальный стандарт: Суррогатный ключ неизменен на протяжении всей жизни записи, что гарантирует абсолютную стабильность связей между таблицами.
Итог
- Таблица — логическая концепция; физически это неупорядоченный Heap-файл, состоящий из страниц по 8 КБ, организованных по принципу Slotted Page.
- Строка отягощена 24-байтовым служебным заголовком MVCC (
HeapTupleHeaderData). Расположение колонок в DDL требует учета Data Alignment (выравнивания), иначе до 30% места на диске и в RAM съест невидимый паддинг. - Столбцы большого объема выносятся в служебную область TOAST, превращая необдуманный
SELECT *в источник разрушительного Random I/O. - Ключи обеспечивают строгую адресацию строк. В современных распределенных системах оптимальным компромиссом между масштабируемостью и производительностью B-Tree является суррогатный ключ формата
UUID v7или классическийBIGINT.
Мы разобрались с внутренней анатомией одной таблицы. Но как связать несколько таблиц в непротиворечивую целостную систему? В следующей статье мы разберем первичные и внешние ключи: переходим к 6. Первичные и внешние ключи.