Learning Platform
Глоссарий Troubleshooting
Урок 20.02 · 21 мин
Продвинутый
capstoneoltpnormalizationddl

Капстоун: проектирование OLTP-схемы

В прошлом уроке мы разобрали бизнес-кейс проката велосипедов, нашли пять сущностей (Customer, Station, Bike, Rental, Payment), связи и бизнес-правила, и собрали conceptual model. Теперь следующий шаг сквозного проекта: превратить концептуальную модель в OLTP-схему — рабочую базу данных для самой системы проката, нормализованную до 3NF/BCNF, с ключами, типами и constraints.

Напомним из модуля про OLTP: это система с короткими частыми транзакциями (взять велосипед, вернуть, оплатить), высокой конкурентностью и приоритетом целостности. Под такую нагрузку правильна нормализованная схема. Наша задача — пройти от conceptual model к physical DDL, на каждом шаге применяя то, что вы изучили.


Шаг 1: от сущностей к таблицам и ключам

Каждая сущность conceptual model становится таблицей. Первое решение для каждой — primary key.

Из модуля про ключи: у нас выбор между natural key и surrogate key. У клиента есть «естественный» кандидат — email (уникален). Но email может меняться, бывает длинным, и завязывать на него все foreign keys рискованно. Поэтому для всех таблиц берём surrogate key — компактный целочисленный идентификатор. Это стандартный выбор для OLTP: узкий ключ ускоряет JOIN и изолирует схему от изменений бизнес-данных. Email при этом не выбрасываем — он остаётся атрибутом с ограничением UNIQUE (это alternate key).

Начнём с простых сущностей — Station и Customer, у них нет внешних зависимостей:

CREATE TABLE station (
    station_id    SERIAL PRIMARY KEY,        -- surrogate key
    name          VARCHAR(100) NOT NULL,
    address       VARCHAR(200) NOT NULL,
    capacity      INTEGER      NOT NULL CHECK (capacity > 0)
);

CREATE TABLE customer (
    customer_id   SERIAL PRIMARY KEY,        -- surrogate key
    full_name     VARCHAR(150) NOT NULL,
    email         VARCHAR(150) NOT NULL UNIQUE,  -- natural key как alternate
    phone         VARCHAR(20),
    registered_at TIMESTAMP    NOT NULL DEFAULT now()
);

Уже здесь работают бизнес-правила из урока 1, превращённые в constraints: capacity > 0 (станция не может иметь неположительную ёмкость), email ... UNIQUE (клиент регистрируется один раз), NOT NULL на обязательных полях.


Шаг 2: foreign keys для связей

Сущность Bike зависит от Station — у велосипеда есть домашняя станция. Из модуля про связи: связь 1:N реализуется foreign key на стороне «многих». Велосипедов много, станция одна — значит, FK ставится в таблицу bike.

CREATE TABLE bike (
    bike_id          SERIAL PRIMARY KEY,
    bike_type        VARCHAR(20) NOT NULL
                     CHECK (bike_type IN ('regular', 'electric')),
    home_station_id  INTEGER NOT NULL
                     REFERENCES station(station_id),
    status           VARCHAR(20) NOT NULL DEFAULT 'available'
                     CHECK (status IN ('available', 'rented', 'maintenance'))
);

CHECK (bike_type IN ('regular', 'electric')) — это бизнес-правило «тип велосипеда обычный или электро», выраженное в схеме. REFERENCES station(station_id) — referential integrity: нельзя завести велосипед на несуществующей станции.

Теперь Rental — центральная и самая интересная таблица. Аренда связана с тремя сущностями: клиент, велосипед и станция, причём станция в двух ролях (старт и конец) — это role-playing из модуля про dimension-таблицы, проявившийся уже в OLTP. Две роли = два отдельных foreign key на одну и ту же таблицу:

CREATE TABLE rental (
    rental_id          SERIAL PRIMARY KEY,
    customer_id        INTEGER NOT NULL REFERENCES customer(customer_id),
    bike_id            INTEGER NOT NULL REFERENCES bike(bike_id),
    start_station_id   INTEGER NOT NULL REFERENCES station(station_id),
    end_station_id     INTEGER          REFERENCES station(station_id),
    started_at         TIMESTAMP NOT NULL,
    ended_at           TIMESTAMP,
    CHECK (ended_at IS NULL OR ended_at > started_at)
);

Разберём решения. start_station_idNOT NULL (аренда всегда где-то началась), end_station_id — допускает NULL (аренда может быть в процессе, велосипед ещё не вернули). ended_at тоже NULL, пока аренда идёт. CHECK (ended_at IS NULL OR ended_at > started_at) ловит абсурд — возврат раньше старта. Два FK на station — это и есть role-playing в реляционной схеме.

Наконец Payment. Связь Аренда-Платёж — 1:0..1 (у аренды ноль или один платёж). Реализовать опциональный 1:1 можно так: FK на rental в таблице payment плюс ограничение UNIQUE на этом FK — это гарантирует «не больше одного платежа на аренду».

CREATE TABLE payment (
    payment_id   SERIAL PRIMARY KEY,
    rental_id    INTEGER NOT NULL UNIQUE       -- UNIQUE даёт 1:0..1
                 REFERENCES rental(rental_id),
    amount       DECIMAL(8,2) NOT NULL CHECK (amount >= 0),
    paid_at      TIMESTAMP    NOT NULL DEFAULT now(),
    method       VARCHAR(20)  NOT NULL
                 CHECK (method IN ('card', 'cash', 'app'))
);

rental_id ... UNIQUE — ключевая деталь: без UNIQUE связь была бы 1:N (много платежей на аренду), с UNIQUE — ровно 1:0..1, как требует бизнес-правило.

OLTP-схема проката: таблицы и foreign keys
customerКлиенты. PK customer_id, email UNIQUE
FK customer_id
rentalЦентральная таблица аренд. FK на customer, bike, station x2
FK rental_id UNIQUE
paymentПлатежи. FK rental_id с UNIQUE даёт связь 1:0..1
rental ссылается на bike и station
bikeВелосипеды. FK home_station_id на station
FK home_station_id
stationСтанции. PK station_id, ёмкость с CHECK > 0

Шаг 3: проверяем нормализацию

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

1NF — все значения атомарны, нет повторяющихся групп. Проверяем: в наших таблицах нет ячеек со списками, нет «телефон1, телефон2». Каждое поле атомарно. 1NF выполнена.

2NF — нет partial dependencies (зависимостей от части составного ключа). 2NF актуальна только при составном primary key. У нас все PK — одиночные surrogate keys (rental_id, payment_id, …). Раз составных ключей нет — partial dependency невозможна в принципе. 2NF выполнена автоматически.

3NF — нет transitive dependencies: non-prime атрибут не зависит от другого non-prime. Вот здесь нужна внимательность. Проверим rental: зависят ли started_at, ended_at от чего-то, кроме rental_id? Нет — они свойства самой аренды. А не закрался ли transitive? Представим, что мы по ошибке добавили в rental столбец customer_email. Тогда: rental_id -> customer_id -> customer_emailcustomer_email зависит от customer_id (non-prime) транзитивно. Это нарушение 3NF. Мы такой столбец не добавляли — customer_email живёт только в customer. 3NF выполнена.

BCNF — для каждой нетривиальной FD детерминант является superkey. BCNF строже 3NF. В наших таблицах каждый non-prime атрибут зависит только от полного PK (он же единственный superkey), «опасных» FD с non-superkey-детерминантом нет. Схема в BCNF.

TIP

Заметьте практичный момент: последовательное использование surrogate keys как одиночных PK автоматически снимает вопрос 2NF (нет составных ключей — нет partial dependency). Это не значит, что нормализация «не нужна» — 3NF и BCNF по-прежнему надо проверять, и главный риск 3NF — соблазн продублировать чужой атрибут (как customer_email в rental) ради удобства. Именно этот соблазн нормализация и запрещает в OLTP.

dbt-тесты как проверка нормализации и FK в аналитическом пайплайне
Нормальная формаЧто проверялиРезультат
1NFАтомарность, нет повторяющихся группВыполнена
2NFНет partial dependenciesВыполнена (PK одиночные)
3NFНет transitive dependenciesВыполнена (нет дублей чужих атрибутов)
BCNFДетерминант каждой FD — superkeyВыполнена

Шаг 4: индексы под нагрузку

Последний штрих physical-модели — индексы. Из модуля про OLTP: foreign keys стоит индексировать, потому что по ним постоянно идут JOIN и проверки целостности.

-- Индексы на foreign keys: ускоряют JOIN и каскадные проверки
CREATE INDEX idx_rental_customer ON rental(customer_id);
CREATE INDEX idx_rental_bike     ON rental(bike_id);
CREATE INDEX idx_bike_station    ON bike(home_station_id);
-- Индекс под частый запрос "активные аренды клиента":
CREATE INDEX idx_rental_active   ON rental(customer_id) WHERE ended_at IS NULL;

PK и UNIQUE-ограничения СУБД индексирует сама. Добавляем индексы на FK и на типичные паттерны запросов (например, «активные аренды» — частичный индекс по ended_at IS NULL). Это уже физический уровень: одна и та же логическая модель, разные индексы под разную нагрузку.

OLTP-схема готова: пять нормализованных таблиц, ключи, referential integrity, бизнес-правила как constraints, индексы. На ней система проката может работать — корректно и быстро принимать аренды и платежи. Но для аналитики («выручка по месяцам и типам велосипедов») эта схема неудобна: ответы потребуют множества JOIN. Поэтому следующий урок берёт ту же предметную область и строит для неё другую модель — star schema.


Попробуй сам

Продолжите кейс онлайн-библиотеки из прошлого урока (читатель, книга, автор, жанр, выдача, штраф).

  1. Для каждой сущности создайте таблицу с surrogate key. Где есть natural key (ISBN книги?) — оставьте его атрибутом с UNIQUE.
  2. Расставьте foreign keys по правилу «FK на стороне многих». Учтите, что у книги есть автор и жанр.
  3. Добавьте constraints из бизнес-правил: CHECK на размер штрафа, NOT NULL на обязательных полях, ограничение «дата возврата не раньше даты выдачи».
  4. Проверьте схему по 1NF, 2NF, 3NF, BCNF. Особенно ищите соблазн продублировать чужой атрибут (имя автора в таблице книги?) — и не поддавайтесь.
  5. Перечислите, какие индексы вы создадите и почему.

Проверка знанийKnowledge check
Как conceptual model превращается в нормализованную OLTP-схему: что становится таблицами, ключами, foreign keys и constraints, и как проверяется нормализация?
ОтветAnswer
Каждая сущность conceptual model становится таблицей. Для primary key в OLTP берут surrogate key — компактный целочисленный идентификатор: узкий ключ ускоряет JOIN и изолирует схему от изменений бизнес-данных; natural key (например, email) при этом остаётся атрибутом с ограничением UNIQUE как alternate key. Связи 1:N реализуются foreign key на стороне "многих" (FK на station в таблице bike). Если сущность связана с одной таблицей в двух ролях (станция старта и станция конца аренды) — это role-playing, реализуется двумя отдельными FK на одну таблицу. Опциональная связь 1:0..1 реализуется через FK плюс ограничение UNIQUE на этом FK — UNIQUE гарантирует не больше одной связанной строки. Бизнес-правила из этапа требований превращаются в constraints: CHECK для допустимых значений и диапазонов, NOT NULL для обязательных полей, REFERENCES для referential integrity, UNIQUE для естественных ключей. Нормализацию проверяют по нормальным формам: 1NF — все значения атомарны, нет повторяющихся групп; 2NF — нет partial dependencies, она выполняется автоматически при одиночных surrogate-ключах, потому что без составного ключа partial dependency невозможна; 3NF — нет transitive dependencies, главный риск здесь соблазн продублировать чужой атрибут (customer_email в таблице rental создал бы transitive-зависимость rental_id -> customer_id -> customer_email); BCNF — детерминант каждой нетривиальной FD является superkey. Финальный штрих physical-модели — индексы на foreign keys (по ним идут JOIN и проверки целостности) и под частые паттерны запросов; PK и UNIQUE СУБД индексирует сама.

Проверьте понимание

Результат: 0 из 0
Прикладной
Вопрос 1 из 4. Почему в OLTP-схеме проката для primary key всех таблиц выбран surrogate key, а email клиента оставлен как атрибут с UNIQUE?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 5