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:

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 вы объявляете колонки в неоптимальном порядке:

SQL
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:

  1. Если размер строки превышает пороговое значение (в Postgres — около 2 КБ, TOAST_TUPLE_THRESHOLD), СУБД пробует прозрачно сжать данные столбца на лету (алгоритмами pglz или современным lz4).
  2. Если после сжатия строка все еще не помещается в допустимый лимит, СУБД физически «вырезает» большой атрибут из кортежа основной таблицы.
  3. Данные разрезаются на фрагменты по 2 КБ и сохраняются в отдельную скрытую служебную таблицу (TOAST-таблицу). В исходной строке остается лишь компактный 18-байтный указатель-дескриптор.

Ключи (Keys). Фундамент идентификации

Поскольку физический Heap — это неупорядоченное нагромождение кортежей, СУБД необходим математический гарант однозначной идентификации любой записи. Эту миссию выполняют Ключи.

В реляционной теории принята строгая иерархия ключей:

  1. Суперключ (Superkey): Любой набор столбцов, который уникально идентифицирует строку. Например, (ID, Email, Возраст) (хотя поле «Возраст» здесь явно избыточно, но уникальность гарантирована).
  2. Потенциальный ключ (Candidate Key): Минимальный суперключ, из которого нельзя удалить ни одного атрибута без потери свойства уникальности. Например, отдельно ID и отдельно Email.
  3. Первичный ключ (Primary Key / PK): Один из потенциальных ключей, выбранный инженером-проектировщиком в качестве канонического идентификатора строки. По стандарту SQL первичный ключ неявно накладывает ограничения UNIQUE NOT NULL и автоматически порождает уникальный B-Tree индекс.

Суррогатные vs Естественные ключи

Классическая дилемма проектирования реляционных схем:

  • Естественный ключ (Natural Key): Бизнес-атрибут, объективно существующий в реальном мире (номер паспорта, ИНН компании, VIN автомобиля).
    • Скрытая угроза: Бизнес-реальность переменчива. Формат ИНН может измениться при выходе на международный рынок, паспорт может быть утерян и заменен. Изменение естественного ключа требует тяжелых каскадных обновлений во всех дочерних таблицах, что под нагрузкой блокирует систему и приводит к дедлокам.
  • Суррогатный ключ (Surrogate Key): Искусственный идентификатор, лишенный какого-либо прикладного бизнес-смысла. Обычно это автоинкрементный BIGINT (Sequence / Identity) или UUID.
    • Индустриальный стандарт: Суррогатный ключ неизменен на протяжении всей жизни записи, что гарантирует абсолютную стабильность связей между таблицами.

Итог

  1. Таблица — логическая концепция; физически это неупорядоченный Heap-файл, состоящий из страниц по 8 КБ, организованных по принципу Slotted Page.
  2. Строка отягощена 24-байтовым служебным заголовком MVCC (HeapTupleHeaderData). Расположение колонок в DDL требует учета Data Alignment (выравнивания), иначе до 30% места на диске и в RAM съест невидимый паддинг.
  3. Столбцы большого объема выносятся в служебную область TOAST, превращая необдуманный SELECT * в источник разрушительного Random I/O.
  4. Ключи обеспечивают строгую адресацию строк. В современных распределенных системах оптимальным компромиссом между масштабируемостью и производительностью B-Tree является суррогатный ключ формата UUID v7 или классический BIGINT.

Мы разобрались с внутренней анатомией одной таблицы. Но как связать несколько таблиц в непротиворечивую целостную систему? В следующей статье мы разберем первичные и внешние ключи: переходим к 6. Первичные и внешние ключи.