А.4 Реляционные базы данных: таблицы, ключи, связи, ER-диаграммы
Зачем это аналитику
Почти каждая функция системы — это чтение и изменение данных в таблицах. Аналитик, который понимает модель данных, видит последствия требований: что будет с историей при изменении справочника, как хранить статусы, почему нельзя «просто удалить» покупателя. ER-диаграмма — основной язык разговора с разработчиками о данных.
Готовность к модулю
Модуль опирается на темы ниже. Если какая-то из них незнакома или её вопросы для самопроверки даются с трудом, начните с неё.
Куда ведёт
Тема подробнее раскрывается в модулях:
Что понять
- Таблица, строка, столбец, тип столбца. Сущность и атрибут в терминах аналитики.
- Первичный ключ: зачем он нужен, естественный и суррогатный ключ.
- Внешний ключ и связи: один к одному, один ко многим, многие ко многим через связующую таблицу.
- Ограничения: NOT NULL, UNIQUE, CHECK, внешний ключ. Что будет при удалении связанной записи.
- Нормализация на практике: зачем устранять дублирование и когда его допускают сознательно.
- Справочники и история: как хранить изменения цен и статусов во времени.
- ER-диаграмма: нотации (вороньи лапки), как читать и рисовать.
- Транзакция на уровне идеи: несколько изменений, которые проходят вместе или не проходят вовсе.
Урок
Реляционная база данных (relational database) хранит данные в таблицах и следит, чтобы таблицы не противоречили друг другу. Весь урок построен на модели данных небольшого интернет-магазина; та же модель используется в модуле А.5.
Таблица, строка, столбец
Таблица (table) похожа на лист Excel с жёсткими правилами. У неё есть имя (customers — покупатели), набор столбцов (columns) и строки (rows).
- Столбец описывает одно свойство: имя, email, город. У каждого столбца есть тип (data type), и база не даст записать в него значение другого типа. В столбец с датой нельзя положить слово «вчера», в числовой — «пять».
- Строка — один конкретный объект: один покупатель, один заказ. Порядок строк не гарантирован, пока его явно не попросили.
Для аналитика это знакомые понятия: сущность (entity) обычно становится таблицей, атрибут (attribute) — столбцом, экземпляр — строкой. Сущность «Покупатель» с атрибутами «ФИО», «email», «город» — это таблица customers со столбцами full_name, email, city.
Самые ходовые типы в PostgreSQL:
| Тип | Что хранит | Пример в магазине |
|---|---|---|
integer, bigint |
целые числа | количество, идентификатор |
numeric(10,2) |
точные дробные числа | цена, сумма |
text |
строка любой длины | имя, email, артикул |
boolean |
да/нет | адрес по умолчанию |
timestamptz |
момент времени с учётом часового пояса | время создания заказа |
Деньги хранят в numeric, а не в «плавающих» типах (real, double precision): те хранят числа приближённо, и копейки начинают теряться при сложении.
Тип — это уже требование: для «суммы заказа» разработчик спросит, сколько знаков после запятой, бывает ли она отрицательной и в какой валюте.
Первичный ключ
Первичный ключ (primary key, PK) — столбец или набор столбцов, значение которого однозначно определяет строку. Два покупателя могут быть полными тёзками из одного города, но у них разные customer_id. База гарантирует, что значение ключа уникально и не пустое.
Ключ нужен, чтобы на строку можно было сослаться: «Анна Смирнова из Казани» — ненадёжный адрес, customer_id = 1 — надёжный.
Ключи бывают двух видов.
- Естественный (natural key) — атрибут, который существует в предметной области: артикул товара, ИНН компании, номер паспорта, email. Удобен тем, что понятен людям.
- Суррогатный (surrogate key) — число или случайный идентификатор, который генерирует сама база и который ничего не значит в бизнесе.
На практике почти всегда берут суррогатный ключ, а естественный объявляют просто уникальным. Причина: естественные ключи меняются. Покупатель меняет email, у человека меняется паспорт. Если email — первичный ключ, его смена потребует обновить все заказы и платежи, которые на него ссылаются. Суррогатный ключ бизнесу трогать незачем.
Ключ может состоять из нескольких столбцов — это составной ключ (composite key). Он появится ниже в таблице позиций заказа.
Внешний ключ и виды связей
Внешний ключ (foreign key, FK) — столбец, который ссылается на первичный ключ другой таблицы. В таблице заказов есть столбец customer_id, и база проверяет: заказ можно сохранить, только если покупатель с таким номером существует. Это называется ссылочной целостностью (referential integrity) — в базе не бывает «висячих» ссылок на несуществующее.
Связи между сущностями бывают трёх видов.
Один ко многим (one-to-many). У покупателя много заказов, у заказа один покупатель. Внешний ключ ставится на стороне «многих»: в orders есть customer_id, а в customers никакого списка заказов нет.
Многие ко многим (many-to-many). В заказе много товаров, и один товар бывает во многих заказах. Одним внешним ключом это не выразить: в заказ нельзя положить «список товаров» в одну ячейку. Решение — связующая таблица (junction table). У нас это order_items, позиции заказа: каждая строка связывает один заказ с одним товаром. Связь «многие ко многим» раскладывается на две связи «один ко многим».
Связующая таблица часто перестаёт быть технической и получает собственные атрибуты. У позиции заказа есть количество и цена — это факты не о заказе и не о товаре, а об их встрече.
CREATE TABLE order_items (
order_id bigint NOT NULL REFERENCES orders (order_id) ON DELETE CASCADE,
product_id bigint NOT NULL REFERENCES products (product_id) ON DELETE RESTRICT,
quantity integer NOT NULL CHECK (quantity > 0),
price numeric(10,2) NOT NULL CHECK (price > 0),
PRIMARY KEY (order_id, product_id)
);
Здесь REFERENCES объявляет внешний ключ, а PRIMARY KEY (order_id, product_id) — составной ключ: один товар попадает в заказ одной строкой, а количество хранится в quantity. Это решение, и его стоит проговорить в постановке: если бизнесу нужно два одинаковых товара с разными скидками в одном заказе, ключ придётся делать другим.
Один к одному (one-to-one). Каждой строке одной таблицы соответствует не больше одной строки другой. Например, у заказа бывает одно отправление в службу доставки с трек-номером. Реализуется внешним ключом с ограничением уникальности:
CREATE TABLE shipments (
shipment_id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id bigint NOT NULL UNIQUE REFERENCES orders (order_id),
tracking_no text NOT NULL
);
UNIQUE на order_id не даст создать второе отправление для того же заказа. Данные выносят в таблицу «один к одному», когда они есть не у всех строк или появляются позже. Если бизнес скажет «заказ можно отправить двумя посылками», связь станет «один ко многим» — этот вопрос лучше задать заранее.
GENERATED ALWAYS AS IDENTITY — способ сказать PostgreSQL «нумеруй сам»: база выдаёт следующее число при каждой вставке.
ER-диаграмма
ER-диаграмма (entity-relationship diagram) показывает сущности, их атрибуты и связи. Самая распространённая нотация — «вороньи лапки» (crow’s foot): на конце линии рисуется значок, который говорит, сколько строк может быть на этой стороне связи.
| Значок на конце линии (запись Mermaid для правого конца) | Читается как |
|---|---|
две черты || |
ровно один |
кружок и черта o| |
ноль или один |
черта и лапка |{ |
один или много |
кружок и лапка o{ |
ноль или много |
На левом конце линии те же значки пишутся зеркально: |o, }|, }o.
Читать связь нужно дважды, с каждой стороны. Линия между customers и orders с || у покупателя и o{ у заказов означает: у покупателя ноль или много заказов; у заказа ровно один покупатель. Кружок («ноль») на практике означает необязательность: покупатель без заказов допустим, а заказ без покупателя — нет, поэтому customer_id в заказах объявлен NOT NULL.
Модель магазина, на которой построены этот модуль и модуль А.5:
erDiagram
categories |o--o{ categories : "родитель"
categories |o--o{ products : "содержит"
customers ||--o{ customer_addresses : "имеет"
customers ||--o{ orders : "оформляет"
orders ||--|{ order_items : "состоит из"
products ||--o{ order_items : "входит в"
products ||--o{ product_prices : "история цен"
orders ||--o{ order_status_history : "история статусов"
customers {
bigint customer_id PK
text full_name
text email UK
text phone
text city
}
orders {
bigint order_id PK
bigint customer_id FK
text status
timestamptz created_at
}
order_items {
bigint order_id PK, FK
bigint product_id PK, FK
int quantity
numeric price
}
products {
bigint product_id PK
text sku UK
text name
bigint category_id FK
numeric price
int stock_qty
}
Несколько деталей, которые стоит прочитать на этой диаграмме:
orders ||--|{ order_items— у заказа хотя бы одна позиция. Внешний ключ этого не проверит: он гарантирует лишь, что позиция ссылается на существующий заказ. Правило «заказ не бывает пустым» обеспечивает приложение, и это надо указать в постановке.categories |o--o{ products— категория у товара необязательна (кабель пока не разложили по категориям).categories |o--o{ categories— связь таблицы с самой собой: у категории «Смартфоны» родитель «Электроника», а у корневой «Электроники» родителя нет. Так хранят иерархии.
Диаграмму рисуют на двух уровнях. Концептуальная (conceptual) — для бизнеса: сущности и связи без типов и ключей, «многие ко многим» одной линией. Физическая (physical) — для разработчиков: столбцы, типы, ключи, связующие таблицы.
Ограничения и удаление связанных записей
Ограничения (constraints) — правила, которые база проверяет при каждой записи, откуда бы она ни пришла: из интерфейса, интеграции или ручного скрипта. Проверку в коде можно обойти, ограничение — нет.
NOT NULL— значение обязательно. Заказ без покупателя не сохранится.UNIQUE— значение не повторяется. Два товара с одним артикулом не заведутся. Пустые значения (NULL) при этом повторяться могут: двух покупателей без email база допустит.CHECK— произвольное условие на строку: количество больше нуля, остаток не отрицательный, статус из разрешённого списка.FOREIGN KEY— ссылка указывает на существующую строку.
Попытка нарушить правило заканчивается ошибкой, и запись не происходит.
Самый важный для аналитика случай — удаление родительской записи. Что будет с заказами, если удалить покупателя, решает правило ON DELETE у внешнего ключа.
| Правило | Что происходит | Где уместно в магазине |
|---|---|---|
RESTRICT или NO ACTION (по умолчанию) |
удаление запрещено, пока есть ссылки | покупатель с заказами, товар в заказах |
CASCADE |
дочерние строки удаляются вместе с родителем | адреса покупателя, позиции заказа |
SET NULL |
в дочерних строках ссылка обнуляется | категория товара |
В нашей модели попытка удалить покупателя с заказами получит отказ:
DELETE FROM customers WHERE customer_id = 1;
-- ERROR: update or delete on table "customers" violates foreign key constraint
-- "orders_customer_id_fkey" on table "orders"
И это правильно: заказы — учётные документы, их нельзя терять вместе с аккаунтом. Требование «покупатель удалил аккаунт» выполняют иначе:
- Мягкое удаление (soft delete): строку помечают столбцом
deleted_atили статусом, и все экраны и отчёты обязаны эту пометку учитывать. - Обезличивание (anonymization): имя, email и телефон затирают, а строка с ключом остаётся, чтобы заказы не потеряли владельца. Так часто выполняют требования о персональных данных.
CASCADE опасен там, где дочерние данные ценны сами по себе: каскад с покупателя на заказы уничтожил бы историю продаж.
Нормализация: зачем убирать дублирование
Представьте, что заказы ведут одной таблицей, как в Excel: номер заказа, дата, ФИО, email, телефон, товар, цена, количество. Строка — позиция заказа, и данные покупателя повторяются в каждой.
Такая таблица порождает аномалии (anomalies):
- Обновления. Покупатель сменил телефон — его надо исправить в сотне строк, и если одну забыть, у человека окажется два телефона.
- Вставки. Нельзя завести покупателя, пока он ничего не заказал.
- Удаления. Удалили единственный заказ покупателя — пропали и его контакты.
Нормализация (normalization) — разложение данных по таблицам так, чтобы каждый факт хранился в одном месте, а связи шли через ключи. Теория описывает это нормальными формами (normal forms); для практики достаточно трёх ориентиров:
- В ячейке одно значение, а не список вроде «товары: 1, 5, 7» — список нельзя проверить внешним ключом.
- Столбец зависит от всего ключа, а не от его части. Название товара не лежит в позициях заказа: оно зависит только от товара.
- Столбец не зависит от другого неключевого столбца. Если в заказе есть
city_id, название города живёт в справочнике городов.
Когда дублирование допускают сознательно. Нормализация оптимизирует запись и целостность. Иногда важнее другое.
- Снимок на момент события. Цена в
order_items.priceдублирует цену товара, но это не ошибка, а требование: заказ должен хранить цену, по которой его оформили. Это не копия текущего значения, а отдельный исторический факт. - Скорость чтения. Сумму заказа можно вычислить из позиций, но её иногда хранят в заказе, чтобы список строился быстрее. Цена — пересчёт при каждом изменении позиций.
- Аналитические хранилища. В хранилищах данных для отчётов (DWH) денормализация — норма: там данные в основном читают, а не меняют. Это тема модуля 9.2.
Правило для постановки: у любого сознательного дублирования описано, кто и когда его синхронизирует.
Справочники и история изменений
Справочник (reference table, lookup) — таблица с относительно стабильным списком значений: категории, города, способы доставки. Другие таблицы ссылаются на справочник внешним ключом, и опечатка «Москва» и «москва» становится невозможной.
Короткий неизменный список, например статусы заказа, можно ограничить через CHECK, как в нашей модели. Если у статуса появляются свои атрибуты (название для экрана, порядок, признак «финальный»), его выносят в отдельную таблицу-справочник.
Главная ловушка справочников — изменения во времени. Если цену в заказе берут из карточки товара, после подорожания все старые заказы «подорожают» задним числом. Поэтому данные, которые должны остаться такими, как в момент события, копируют в документ. Цена в позиции заказа — ровно этот случай. Ответ на вопрос «какую цену показывать в старом заказе» — ту, что сохранена в позиции, а не текущую.
Если нужна полная история цены товара — для аналитики, претензий, проверки акций, — её хранят в таблице product_prices с периодом действия valid_from — valid_to. У товара 1 там две строки: 28000 с 1 января по 1 марта и 30000 с 1 марта с пустым valid_to — «действует сейчас». Цена на дату находится условием «дата попала в период»:
SELECT price
FROM product_prices
WHERE product_id = 1
AND valid_from <= '2026-02-15'
AND (valid_to IS NULL OR valid_to > '2026-02-15');
Граница периода включается с одной стороны и не включается с другой: valid_from входит в период, valid_to — уже нет. Так у соседних периодов нет ни дыр, ни пересечений. Это правило надо явно записать в постановке — иначе один разработчик сделает включительно, другой нет.
В нашей модели текущая цена хранится и в products.price, и в последней строке истории. Это сознательное дублирование ради удобства: смена цены обязана обновить оба места одной транзакцией.
Со статусами то же самое: orders.status отвечает на вопрос «где заказ сейчас», а order_status_history со временем каждой смены — на вопросы «когда его оплатили» и «сколько он пролежал на складе».
Транзакция: всё или ничего
Оформление заказа — это несколько изменений сразу: создать заказ, добавить позиции, уменьшить остаток на складе. Если после второго шага произойдёт сбой, в базе окажется заказ с товаром, который не списан со склада. Такое состояние хуже, чем отсутствие заказа.
Транзакция (transaction) — группа изменений, которую база выполняет как одно целое: либо применяются все, либо ни одно. Команда BEGIN начинает транзакцию, COMMIT фиксирует, ROLLBACK отменяет всё, что сделано после BEGIN.
BEGIN;
INSERT INTO orders (customer_id) VALUES (2) RETURNING order_id;
-- допустим, база вернула номер заказа 7
INSERT INTO order_items (order_id, product_id, quantity, price)
VALUES (7, 2, 1, 45000.00);
UPDATE products SET stock_qty = stock_qty - 1 WHERE product_id = 2;
COMMIT;
Если товара нет в наличии, UPDATE нарушит ограничение CHECK (stock_qty >= 0). В PostgreSQL после ошибки транзакция считается сорванной: последующий COMMIT на деле откатит всё, и заказ не появится. Ограничение ловит нарушение, транзакция не даёт сохранить половину операции.
Для аналитика отсюда вопрос к любой операции в постановке: какие изменения должны пройти вместе? Всё, что бизнес считает одним действием, должно быть одной транзакцией. Оговорка: транзакция работает внутри одной базы. Операцию, которая затрагивает магазин и внешний платёжный сервис, одной транзакцией не покрыть.
Что дальше
Запросы к этой модели разбирает модуль А.5. Свойства транзакций и одновременный доступ к данным — тема модуля Б.4, а как база физически хранит строки — модуля 2.1. Моделирование аналитических хранилищ, где от нормализации отступают системно, — модуль 9.2.
Типичные ошибки
- Делать первичным ключом email, телефон или номер документа. Они меняются, и смена превращается в массовое обновление связанных таблиц.
- Брать цену, адрес или название из справочника при показе старого документа. История «переписывается» задним числом. Всё, что должно остаться как было, копируется в документ.
- Не указывать в постановке, что происходит при удалении родительской записи. Разработчик выберет сам — иногда
CASCADE, который стирает заказы. - Полагаться только на проверки в интерфейсе: данные приходят и из интеграций, и без ограничений в базе в них окажется мусор.
- Рисовать ER-диаграмму без кратностей или путать концы линии: «ноль или много» и «один или много» — разные требования.
- Описывать многошаговую операцию без требования атомарности. Сбой посередине оставит данные в состоянии, которого не должно существовать.
Как говорить об этом на собеседовании
На собеседовании системного аналитика часто просят спроектировать модель на ходу: «нарисуйте таблицы для интернет-магазина». Уровень показывает не количество таблиц, а то, что вы проговариваете: кратность каждой связи, связующую таблицу, почему ключ суррогатный, какие данные хранятся снимком («цену в позиции заказа храню копией, иначе старые заказы изменятся при смене цены»).
Хорошо звучат ответы, где техническое решение связано с бизнес-требованием: «Удалить покупателя с заказами база не даст — внешний ключ с запретом. Удаление аккаунта реализуем обезличиванием, заказы остаются для учёта».
Коротко
- Сущность становится таблицей, атрибут — столбцом, экземпляр — строкой. Тип столбца — часть требований.
- Первичный ключ однозначно определяет строку. Обычно он суррогатный, а естественные идентификаторы объявляются уникальными.
- Внешний ключ ставится на стороне «многих». «Многие ко многим» раскладывается через связующую таблицу, «один к одному» — внешним ключом с
UNIQUE. - Ограничения проверяются базой всегда, откуда бы ни пришли данные. Правило удаления (
RESTRICT,CASCADE,SET NULL) выбирается для каждой связи и записывается как требование. Ценные данные не удаляют, а помечают или обезличивают. - Нормализация убирает аномалии обновления, вставки и удаления. Отступление от неё — осознанное решение с описанным способом синхронизации.
- То, что должно остаться неизменным в документе, копируется в документ. Историю цен и статусов хранят отдельными таблицами с датами.
- Транзакция делает несколько изменений неделимыми: всё или ничего.
Читать дальше
- Официальная документация PostgreSQL: глава «Tutorial», разделы о таблицах и внешних ключах.
- К. Дж. Дейт, «Введение в системы баз данных» — классика, первые главы.
На system-design.space:
Практика
- Спроектировать модель данных интернет-магазина: покупатели, адреса, товары, категории, заказы, позиции заказа, статусы. Нарисовать ER-диаграмму.
- Добавить в модель историю цен товаров и ответить: какую цену показывать в старом заказе после изменения цены?
- Для каждой связи в модели решить, что должно произойти при удалении родительской записи, и записать это как требование.
Вопросы для самопроверки
- Что такое первичный и внешний ключ?
- Как в реляционной модели хранится связь «многие ко многим»?
- Зачем нужна нормализация и когда от неё отступают?
- Что будет при попытке удалить покупателя, у которого есть заказы, и как это решить?
- Как хранить историю изменения цены товара?
- Что такое транзакция и зачем она нужна при оформлении заказа?