Раздел 2 — Реляционная модель, таблицы, ключи и связи

Из чего состоит реляционная база и как таблицы связываются между собой

Прогресс курса Раздел 2 из 13

Что вы освоите в этом разделе

6 академических часов: 2 часа теории, 4 часа практики. Задания по-прежнему выполняются на бумаге — вы описываете структуру, а не пишете код. Команды CREATE TABLE из примеров запускать пока не нужно, к ним вернёмся в разделе 7.
01

Реляционная модель: термины

Реляционную модель предложил Эдгар Кодд в 1970 году в статье «A Relational Model of Data for Large Shared Data Banks». В основе — математическое понятие отношения, откуда и название: relation, а не «связь между таблицами», как часто думают.

У модели три словаря: математический, стандарта SQL и разговорный. Они описывают одно и то же.

Математика (Кодд)SQLОбиход
отношение (relation)таблица (table)таблица
кортеж (tuple)строка (row)запись
атрибут (attribute)столбец (column)поле
домен (domain)тип данных + ограничениятип
степень (degree)число столбцов
кардинальность (cardinality)число строк

Пример на учебной базе: отношение users имеет степень 5 (пять атрибутов) и кардинальность 10 (десять кортежей). Степень задаётся при проектировании и меняется редко; кардинальность меняется при каждой вставке.

Домен — не то же самое, что тип данных. Тип integer допускает любое целое число; домен «возраст пользователя» — только положительные числа примерно до 120. Домен уже типа, и разницу закрывают ограничением: age integer CHECK (age > 0). В PostgreSQL домен можно объявить и явно — командой CREATE DOMAIN.
02

Свойства отношения

Из математического определения следуют четыре свойства. Каждое имеет прямое практическое последствие.

Свойства и что из них следует

  • Значения атомарны. В ячейке — одно неделимое значение, а не список. Нельзя хранить телефоны клиента строкой "+7900…, +7911…". Следствие: множественные значения выносятся в отдельную таблицу.
  • Строки не дублируются. В отношении не может быть двух одинаковых кортежей. Следствие: у таблицы должен быть ключ.
  • Порядок строк не определён. Строки — множество, а не список. Следствие: без ORDER BY порядок результата не гарантирован, даже если сегодня он выглядит стабильным.
  • Порядок столбцов не определён. Следствие: обращаться к столбцу по номеру — плохая практика, обращайтесь по имени.
Реальные СУБД отступают от модели: в PostgreSQL таблица без первичного ключа допускает полные дубликаты строк, а столбцы физически упорядочены. Модель описывает, как должно быть спроектировано; СУБД лишь не мешает вам спроектировать плохо.

Нарушение атомарности — самая частая ошибка начинающих. Сравните два варианта хранения городов, в которых работает мастер:

-- Плохо: значение не атомарно
CREATE TABLE masters (
    id     integer PRIMARY KEY,
    name   varchar(100),
    cities varchar(500)   -- 'Москва, Казань, Тверь'
);

-- Найти всех мастеров из Твери — только поиском по подстроке,
-- индекс не работает, «Тверь» найдётся внутри «Тверьская»
SELECT * FROM masters WHERE cities LIKE '%Тверь%';
-- Хорошо: одно значение в одной ячейке
CREATE TABLE master_cities (
    master_id integer NOT NULL REFERENCES masters(id),
    city      varchar(100) NOT NULL,
    PRIMARY KEY (master_id, city)
);

-- Поиск точный и по индексу
SELECT master_id FROM master_cities WHERE city = 'Тверь';
03

Типы данных PostgreSQL

Тип данных — первое и самое дешёвое ограничение целостности. Правильно выбранный тип не даст записать в базу мусор без единой строки кода.

Основные типы

  • Целые: smallint (±32 тыс.), integer (±2 млрд), bigint (±9 квинтиллионов). По умолчанию берите integer, для идентификаторов растущих таблиц — bigint.
  • Точные дробные: numeric(p, s)p всего цифр, s после запятой. Единственный правильный тип для денег.
  • Приблизительные дробные: real, double precision. Для измерений и координат, но не для сумм.
  • Строки: varchar(n) с ограничением длины, text без ограничения. В PostgreSQL они работают одинаково быстро; varchar(n) берут, когда ограничение осмысленно.
  • Дата и время: date, time, timestamp, timestamptz (с часовым поясом), interval.
  • Логический: booleantrue, false, NULL.
  • Прочие: uuid, jsonb, bytea, массивы, inet для IP-адресов.
Никогда не храните деньги в real или double precision. Это двоичные дроби: 0.1 + 0.2 в них не равно 0.3. На тысяче операций расхождение станет заметным, и бухгалтерия придёт к вам. Для рублей — numeric(12, 2).

Отдельно про время. timestamp хранит «стену календаря» без пояса, timestamptz — момент времени, приведённый к UTC. Для событий (когда произошёл лайк, когда создан заказ) правильный выбор — timestamptz: он останется корректным при переезде сервера в другой часовой пояс и при переводе часов.

В учебной базе даты хранятся типом date — время суток для учебных задач не нужно. В боевой системе аналогичные столбцы были бы timestamptz.
04

NULL и трёхзначная логика

NULL — не ноль и не пустая строка. Это отметка «значение отсутствует»: неизвестно, неприменимо или ещё не задано.

Присутствие NULL превращает привычную логику из двузначной в трёхзначную: выражение может быть истинным, ложным или неопределённым (UNKNOWN).

ВыражениеРезультатПочему
5 = NULLUNKNOWNнеизвестно, равно ли пяти неизвестное
NULL = NULLUNKNOWNдва неизвестных не обязаны совпадать
NULL IS NULLTRUEпроверка на отсутствие — не сравнение
true OR NULLTRUEистина уже достигнута
false AND NULLFALSEложь уже достигнута
NULL + 10NULLарифметика с неизвестным даёт неизвестное

Практическое правило: WHERE отбирает строки, для которых условие истинно. UNKNOWN — не истина, поэтому такие строки в результат не попадают.

-- Допустим, у части пользователей город неизвестен (city IS NULL)

-- Не вернёт пользователей с city = NULL — и это ожидаемо
SELECT name FROM users WHERE city = 'Moscow';

-- Тоже не вернёт их — а вот это ловушка
SELECT name FROM users WHERE city <> 'Moscow';

-- Правильно, если «город не Москва или неизвестен»
SELECT name FROM users WHERE city IS DISTINCT FROM 'Moscow';
Два запроса с = и <> в сумме дают не всю таблицу. Строки с NULL не попадают ни в один из них. Это источник расхождений в отчётах, который ищут часами.

Полезные конструкции для работы с NULL:

-- Подставить значение вместо NULL
SELECT name, COALESCE(city, 'не указан') AS city FROM users;

-- Превратить значение в NULL (обратная операция)
SELECT NULLIF(city, '') FROM users;

-- Проверка на отсутствие — только через IS
SELECT name FROM users WHERE city IS NULL;

Лучший способ избежать этих сложностей — запретить NULL там, где он не нужен, ограничением NOT NULL. В учебной базе так сделано для всех обязательных столбцов: users.name, posts.title, likes.liked_at.

05

Ключи

Строки в отношении не дублируются — значит, существует набор атрибутов, однозначно определяющий строку. Это и есть ключ.

Виды ключей

  • Суперключ — любой набор атрибутов, однозначно определяющий строку. Вся строка целиком — тоже суперключ, но бесполезный.
  • Потенциальный ключ (candidate key) — минимальный суперключ: убери из него любой атрибут, и однозначность потеряется. Потенциальных ключей у таблицы может быть несколько.
  • Первичный ключ (primary key, PK) — один из потенциальных, выбранный главным. Не допускает NULL, в таблице он один.
  • Альтернативный ключ — остальные потенциальные ключи. Задаются ограничением UNIQUE.
  • Простой / составной — из одного атрибута или из нескольких.
  • Внешний ключ (foreign key, FK) — атрибут, ссылающийся на ключ другой таблицы. О нём — следующий пункт.

Пример. В таблице сотрудников потенциальными ключами являются tab_number (табельный номер), inn и email — каждый уникален. Первичным выбирают один, остальные объявляют UNIQUE:

CREATE TABLE employees (
    tab_number integer      PRIMARY KEY,     -- первичный ключ
    inn        char(12)     UNIQUE NOT NULL, -- альтернативный ключ
    email      varchar(200) UNIQUE,          -- альтернативный ключ
    name       varchar(100) NOT NULL
);
Разница между PRIMARY KEY и UNIQUE: первичный ключ запрещает NULL и может быть только один; UNIQUE допускает NULL (причём несколько строк с NULL не считаются дубликатами) и может стоять на нескольких столбцах.

Суррогатный или естественный

Главное решение при проектировании: взять ключом реальный атрибут предметной области или ввести искусственный номер.

Естественный ключ

Атрибут из реального мира: ИНН, номер паспорта, артикул, ISBN. Плюсы: осмыслен, не требует лишнего столбца, легко проверить глазами. Минусы: может измениться (фамилия, email), может оказаться неуникальным вопреки ожиданиям, часто длинный, а значит тяжёлый для внешних ключей.

Суррогатный ключ

Искусственный идентификатор без смысла: автоинкремент или UUID. Плюсы: никогда не меняется, компактен, единообразен. Минусы: лишний столбец, ничего не говорит человеку, не защищает от дубликатов по смыслу.

Практика сложилась такая: первичный ключ — суррогатный, естественный ключ объявляется как UNIQUE. Так вы получаете стабильные ссылки и при этом не теряете защиту от дубликатов.

Классическая ошибка — сделать первичным ключом email или телефон. Пользователь меняет email, и приходится каскадно переписывать ссылки во всех связанных таблицах. Ключ должен быть неизменяемым.

Составной ключ уместен, когда строка не существует сама по себе — она осмысленна только как пара. Типичный случай — связующая таблица:

-- Один пользователь состоит в одном сообществе не более одного раза
CREATE TABLE memberships (
    user_id      integer NOT NULL REFERENCES users(id),
    community_id integer NOT NULL REFERENCES communities(id),
    joined_at    date    NOT NULL,
    PRIMARY KEY (user_id, community_id)
);
06

Внешние ключи и ссылочная целостность

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

В учебной базе posts.community_id ссылается на communities.id:

CREATE TABLE posts (
    id           integer      PRIMARY KEY,
    community_id integer      NOT NULL REFERENCES communities(id),
    title        varchar(200) NOT NULL,
    score        integer      NOT NULL CHECK (score > 0)
);

Ссылочная целостность — гарантия СУБД, что ссылка никогда не ведёт в пустоту. Она работает в обе стороны:

Что запрещает внешний ключ

  • Вставить пост с community_id = 99, если сообщества с таким id нет.
  • Удалить сообщество, на которое ссылаются посты.
  • Изменить id сообщества, на которое есть ссылки.

Поведение при удалении и изменении родительской строки настраивается:

ПравилоЧто происходит при удалении родителяКогда применять
NO ACTION / RESTRICTудаление запрещенопо умолчанию; данные важны
CASCADEдочерние строки удаляютсядочерние не существуют без родителя: комментарии к посту
SET NULLссылка обнуляетсясвязь необязательна: заказ остаётся после увольнения менеджера
SET DEFAULTставится значение по умолчаниюредко; нужна строка-заглушка
-- Лайки исчезают вместе с постом
CREATE TABLE likes (
    id      integer PRIMARY KEY,
    user_id integer NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    post_id integer NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
    liked_at date   NOT NULL
);
ON DELETE CASCADE удобен и опасен. Удаление одного сообщества может каскадом снести посты, лайки и просмотры — тысячи строк одной командой, без предупреждения. Ставьте каскад только там, где дочерняя запись действительно теряет смысл без родителя.
Иногда предлагают не создавать внешние ключи «ради производительности» и проверять всё в коде приложения. Это плохой размен: проверка в коде работает, только пока в базу пишет ровно одно приложение и ни один человек не подключался напрямую. На практике целостность теряется в первый же месяц.
07

Виды связей

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

Один-ко-многим (1:М)

Самая частая связь. Одному сообществу принадлежит много постов; каждый пост принадлежит ровно одному сообществу.

Реализация: внешний ключ ставится на стороне «многих». Столбец community_id — в таблице posts, а не наоборот.

Как определить сторону: задайте вопрос «сколько?» в обе стороны. «Сколько постов у сообщества?» — много. «Сколько сообществ у поста?» — одно. Внешний ключ идёт туда, где ответ «одно».

Многие-ко-многим (М:М)

Пользователь лайкает много постов; пост собирает лайки многих пользователей. Напрямую в реляционной модели такая связь не выражается — она нарушила бы атомарность.

Реализация: вводится третья, связующая таблица с двумя внешними ключами. Именно этим и является likes:

-- Связующая таблица: users ←→ posts
CREATE TABLE likes (
    id       integer PRIMARY KEY,
    user_id  integer NOT NULL REFERENCES users(id),
    post_id  integer NOT NULL REFERENCES posts(id),
    liked_at date    NOT NULL,
    UNIQUE (user_id, post_id)   -- один пользователь лайкает пост один раз
);

Связь М:М всегда распадается на две связи 1:М. Связующая таблица часто несёт собственные атрибуты — здесь это liked_at, в заказе это было бы количество и цена на момент покупки.

Обратите внимание на UNIQUE (user_id, post_id). В учебной базе этого ограничения нет, и формально один пользователь может лайкнуть один пост дважды. Это упущение схемы — из тех, что находят на код-ревью.

Один-к-одному (1:1)

Одной строке соответствует не более одной строки в другой таблице. Встречается редко и почти всегда означает, что таблицу разделили намеренно.

Реализация: внешний ключ с ограничением UNIQUE — либо сам внешний ключ является первичным.

-- Профиль пользователя вынесен в отдельную таблицу
CREATE TABLE user_profiles (
    user_id  integer PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
    bio      text,
    avatar   bytea
);

Зачем разделять: вынести редко читаемые тяжёлые столбцы, ограничить доступ к чувствительным данным на уровне прав, отделить необязательную часть сущности.

08

Разбор схемы учебной базы

Соберём всё вместе на базе «Социальная сеть», с которой вы будете работать весь курс.

Схема базы данных

users
idinteger PK
namevarchar NOT NULL
ageinteger CHECK
gendervarchar CHECK
cityvarchar
communities
idinteger PK
namevarchar NOT NULL
ratingnumeric(3,1)
posts
idinteger PK
community_idFK → communities
titlevarchar NOT NULL
scoreinteger CHECK
likes
idinteger PK
user_idFK → users
post_idFK → posts
liked_atdate NOT NULL
views
idinteger PK
user_idFK → users
community_idFK → communities
viewed_atdate NOT NULL

Связи в этой схеме:

СвязьТипЧерез что реализована
communities → posts1:Мposts.community_id
users ↔ postsМ:Мсвязующая таблица likes
users ↔ communitiesМ:Мсвязующая таблица views

Заметьте: likes и views — связующие таблицы с суррогатным первичным ключом id вместо составного. Так тоже делают: суррогатный ключ удобнее, если на связующую таблицу кто-то будет ссылаться. Плата — потеря автоматической защиты от дубликатов, которую пришлось бы вернуть ограничением UNIQUE.

Лайк поста и просмотр ленты сообщества — независимые события. Пользователь может открыть сообщество и ничего не лайкнуть, а может лайкнуть пост из рекомендаций, не заходя в сообщество. Из-за этого связать лайки и просмотры напрямую нельзя — в разделе 10 это станет источником нетривиальных запросов.
09

Реляционная алгебра — откуда взялся SQL

Кодд описал не только модель, но и набор операций над отношениями. Результат каждой операции — снова отношение, поэтому операции можно составлять в цепочки. Отсюда возможность вкладывать запросы друг в друга.

ОперацияЧто делаетВ SQL
Селекция (σ)отбирает строки по условиюWHERE
Проекция (π)оставляет нужные столбцысписок после SELECT
Декартово произведение (×)каждая строка с каждойCROSS JOIN
Соединение (⋈)произведение с условиемJOIN … ON
Объединение (∪)строки обоих отношенийUNION
Разность (−)строки первого без строк второгоEXCEPT
Пересечение (∩)строки, входящие в обаINTERSECT
Переименование (ρ)задаёт новое имяAS

Простой SELECT — это композиция проекции и селекции:

-- π(name, city) от σ(age > 20) над users
SELECT name, city
FROM   users
WHERE  age > 20;

Знать алгебру наизусть не требуется. Важно понимать следствие: SQL — декларативный язык. Вы описываете результат в терминах операций над множествами, а как его получить, решает оптимизатор СУБД. Именно поэтому один и тот же результат можно записать через JOIN, через подзапрос или через EXISTS — и СУБД зачастую выполнит их одинаково.

Ключевые выводы раздела

Запомните главное

  • Отношение = таблица, кортеж = строка, атрибут = столбец, домен = тип с ограничениями
  • Значения атомарны; список в ячейке — ошибка проектирования, выносится в отдельную таблицу
  • Порядок строк не определён — без ORDER BY он не гарантирован
  • Деньги — только numeric, никогда не real и не double precision
  • NULL — не значение, а его отсутствие; сравнение с ним даёт UNKNOWN, проверка только через IS NULL
  • Первичный ключ — суррогатный и неизменяемый; естественный ключ объявляют UNIQUE
  • Внешний ключ обеспечивает ссылочную целостность; ON DELETE CASCADE применять осознанно
  • 1:М — внешний ключ на стороне «многих»; М:М — только через связующую таблицу; 1:1 — внешний ключ с UNIQUE
  • SQL вырос из реляционной алгебры и потому декларативен
Практические задания — на учебном портале. Задания, критерии оценивания, сдача работ и оценки преподавателя — в курсе на portal.nevabit.ru. Учётную запись выдаёт преподаватель.
Раздел 1: Основные понятия Раздел 3: Проектирование и ER