Как делать11 октября 2026 г.8 мин чтения
SQL и PostgreSQLШаг 5 из 9

Проектирование БД: связи, ключи и нормализация

Коротко

Проектирование базы начинается с сущностей предметной области и связей между ними: 1:1 реализуют внешним ключом, который одновременно первичный, 1:N — внешним ключом на стороне «многих», N:M — отдельной таблицей связи с составным первичным ключом. Для каждого внешнего ключа выбирают поведение при удалении: RESTRICT для истории, CASCADE для частей целого, SET NULL для необязательных ссылок. Нормализация до третьей нормальной формы убирает дубли и противоречия; денормализуют осознанно и так, чтобы база сама держала копию в согласии с источником.

Пошаговый урокУровень: СреднийПроектирование баз данныхПрактика: ~1,5–3 ч

После материала вы сможете

  • перевести описание сервиса в сущности и реализовать связи 1:1, 1:N и N:M внешними ключами и таблицей связи
  • выбрать первичный ключ (bigint, uuid или естественный) и правило ON DELETE для каждого внешнего ключа и объяснить выбор
  • привести плоскую таблицу к 3НФ и перенести в новую схему данные через INSERT … SELECT
  • отличить оправданную денормализацию (генерируемый столбец, материализованное представление, снимок цены) от источника аномалий

Проверено на

PostgreSQL 17.11 (postgres:17-alpine)PostgreSQL 18.6 — сверкаbtree_gist (contrib PostgreSQL 17)

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01От предметной области к сущностям
  2. 02Связи 1:1, 1:N и N:M в DDL
  3. 03Ключи: суррогатные и естественные, bigint или UUID
  4. 04Внешние ключи и поведение ON DELETE
  5. 05Нормализация до 3НФ на примере заказов
  6. 06Когда денормализация оправдана
  7. 07Частые ошибки проектирования и упражнения

От предметной области к сущностям

Схему базы проектируют до первой строки кода: исправить её потом, когда в таблицах живые данные, намного дороже. Начните с описания бизнеса обычными словами. Для примера возьмём сервис онлайн-записи в салон: «Клиенты записываются к мастерам на услуги. Каждый мастер выполняет свой набор услуг. Запись — это конкретное время у конкретного мастера на одну услугу по цене на момент записи. У мастера есть профиль для сайта. Клиент может прийти по рекомендации другого клиента».

Существительные из такого описания — кандидаты в сущности (будущие таблицы): клиент, мастер, услуга, запись, профиль. Глаголы и слова «каждый», «свой», «один» подсказывают связи и их кратность. Атрибуты — то, что мы знаем о сущности: имя и телефон клиента, длительность и цена услуги, время записи.

  1. 01clients, masters, servicesНезависимые сущности: собственный первичный ключ `id`, у клиента уникальный телефон и необязательная ссылка на рекомендателя
  2. 02master_profiles — 1:1Первичный ключ `master_id` одновременно внешний ключ на `masters`: второго профиля у мастера быть не может
  3. 03master_services — N:MТаблица связи мастер ↔ услуга с составным первичным ключом `(master_id, service_id)`
  4. 04appointments — 1:NСсылается на клиента и на пару «мастер — услуга»; ограничение исключения не даёт пересечься записям одного мастера
ER-схема сервиса записи по уровням: таблицы верхнего уровня ни на кого не ссылаются, нижние ссылаются на верхние — в этом же порядке таблицы создают и заполняют

Обратите внимание, что запись ссылается не на мастера и услугу по отдельности, а на их пару в master_services. Так база сама гарантирует правило «записать можно только на услугу, которую этот мастер выполняет» — без проверки в коде приложения.

Связи 1:1, 1:N и N:M в DDL

1:1

Пример в сервисе
мастер — профиль
Как реализовать
Внешний ключ, который одновременно первичный (или UNIQUE): master_profiles.master_id

1:N

Пример в сервисе
клиент — записи
Как реализовать
Внешний ключ на стороне «многих»: appointments.client_id

N:M

Пример в сервисе
мастера — услуги
Как реализовать
Таблица связи с двумя внешними ключами и составным первичным ключом: master_services

Ссылка на себя

Пример в сервисе
клиент — кто его порекомендовал
Как реализовать
Внешний ключ на ту же таблицу: clients.referred_by_id

Ниже — вся схема сервиса. Внешний ключ (REFERENCES) должен указывать на первичный ключ или на столбцы с ограничением UNIQUE — поэтому appointments может ссылаться на пару (master_id, service_id): это первичный ключ master_services. Ограничение исключения EXCLUDE запрещает две строки с одним мастером и пересекающимися интервалами времени; для сравнения bigint через = в индексе GiST нужно расширение btree_gist. Оно помечено в документации как доверенное, поэтому его может установить владелец базы, а не только суперпользователь.

db/booking.sql
-- Сервис онлайн-записи: клиенты, мастера, услуги, записи
CREATE EXTENSION IF NOT EXISTS btree_gist;

CREATE TABLE clients (
  id             bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name           text NOT NULL CHECK (name <> ''),
  phone          text NOT NULL UNIQUE,
  -- кто посоветовал салон: необязательная ссылка на другого клиента
  referred_by_id bigint REFERENCES clients (id) ON DELETE SET NULL,
  created_at     timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE masters (
  id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name      text    NOT NULL,
  is_active boolean NOT NULL DEFAULT true
);

-- 1:1 — первичный ключ профиля одновременно внешний ключ на мастера
CREATE TABLE master_profiles (
  master_id bigint PRIMARY KEY REFERENCES masters (id) ON DELETE CASCADE,
  bio       text,
  photo_url text
);

CREATE TABLE services (
  id           bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name         text          NOT NULL UNIQUE,
  duration_min integer       NOT NULL CHECK (duration_min > 0),
  price        numeric(10,2) NOT NULL CHECK (price > 0)
);

-- N:M — какие услуги выполняет мастер
CREATE TABLE master_services (
  master_id  bigint NOT NULL REFERENCES masters (id) ON DELETE CASCADE,
  service_id bigint NOT NULL REFERENCES services (id) ON DELETE CASCADE,
  PRIMARY KEY (master_id, service_id)
);

-- 1:N от клиента и от пары «мастер — услуга»
CREATE TABLE appointments (
  id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  client_id  bigint        NOT NULL REFERENCES clients (id) ON DELETE RESTRICT,
  master_id  bigint        NOT NULL,
  service_id bigint        NOT NULL,
  starts_at  timestamptz   NOT NULL,
  ends_at    timestamptz   NOT NULL,
  price      numeric(10,2) NOT NULL CHECK (price >= 0),
  status     text          NOT NULL DEFAULT 'booked'
             CHECK (status IN ('booked', 'done', 'cancelled', 'no_show')),
  CHECK (ends_at > starts_at),
  -- записать можно только на услугу, которую этот мастер выполняет
  FOREIGN KEY (master_id, service_id)
    REFERENCES master_services (master_id, service_id) ON DELETE RESTRICT,
  -- у мастера не может быть двух пересекающихся записей, кроме отменённых
  EXCLUDE USING gist (master_id WITH =, tstzrange(starts_at, ends_at) WITH &&)
    WHERE (status <> 'cancelled')
);

-- Внешний ключ не создаёт индекс на ссылающихся столбцах — добавляем сами
CREATE INDEX ON appointments (client_id);
CREATE INDEX ON appointments (master_id, service_id);
CREATE INDEX ON master_services (service_id);
CREATE INDEX ON clients (referred_by_id);
Схема сервиса записи: 6 таблиц, все правила бизнеса — в ограничениях
db/booking-seed.sql
-- Вымышленные данные салона
INSERT INTO clients (name, phone, referred_by_id) VALUES
  ('Aigerim Seitova',  '+7 700 000 0001', NULL),
  ('Madina Omarova',   '+7 700 000 0002', 1),
  ('Daniyar Akhmetov', '+7 700 000 0003', NULL);

INSERT INTO masters (name) VALUES ('Aliya'), ('Saule');
INSERT INTO master_profiles (master_id, bio) VALUES
  (1, 'Colorist'),
  (2, 'Nail artist');

INSERT INTO services (name, duration_min, price) VALUES
  ('Haircut',  60,  8000.00),
  ('Coloring', 120, 25000.00),
  ('Manicure', 90,  7000.00);

INSERT INTO master_services (master_id, service_id) VALUES
  (1, 1), (1, 2),
  (2, 3);

INSERT INTO appointments (client_id, master_id, service_id, starts_at, ends_at, price) VALUES
  (1, 1, 2, '2026-10-20 10:00+05', '2026-10-20 12:00+05', 25000.00),
  (3, 1, 1, '2026-10-20 12:00+05', '2026-10-20 13:00+05',  8000.00),
  (1, 2, 3, '2026-10-21 15:00+05', '2026-10-21 16:30+05',  7000.00);
Учебные данные: три клиента, два мастера, три услуги, три записи
bash
docker run -d --rm --name booking-db -e POSTGRES_PASSWORD=change-me-locally postgres:17-alpine

docker exec booking-db psql -U postgres \
  -c "CREATE ROLE booking_app LOGIN PASSWORD 'change-me'" \
  -c "CREATE DATABASE booking OWNER booking_app" \
  -c "CREATE DATABASE shop_design OWNER booking_app"

docker cp db booking-db:/db
docker exec booking-db psql -U booking_app -d booking -v ON_ERROR_STOP=1 \
  -f /db/booking.sql -f /db/booking-seed.sql
Отдельный контейнер для урока; роль, две базы и применение схемы. Пароли — учебные заглушки
terminal
booking=> \d appointments
                                 Table "public.appointments"
   Column   |           Type           | Collation | Nullable |           Default            
------------+--------------------------+-----------+----------+------------------------------
 id         | bigint                   |           | not null | generated always as identity
 client_id  | bigint                   |           | not null | 
 master_id  | bigint                   |           | not null | 
 service_id | bigint                   |           | not null | 
 starts_at  | timestamp with time zone |           | not null | 
 ends_at    | timestamp with time zone |           | not null | 
 price      | numeric(10,2)            |           | not null | 
 status     | text                     |           | not null | 'booked'::text
Indexes:
    "appointments_pkey" PRIMARY KEY, btree (id)
    "appointments_client_id_idx" btree (client_id)
    "appointments_master_id_service_id_idx" btree (master_id, service_id)
    "appointments_master_id_tstzrange_excl" EXCLUDE USING gist (master_id WITH =, tstzrange(starts_at, ends_at) WITH &&) WHERE (status <> 'cancelled'::text)
Check constraints:
    "appointments_check" CHECK (ends_at > starts_at)
    "appointments_price_check" CHECK (price >= 0::numeric)
    "appointments_status_check" CHECK (status = ANY (ARRAY['booked'::text, 'done'::text, 'cancelled'::text, 'no_show'::text]))
Foreign-key constraints:
    "appointments_client_id_fkey" FOREIGN KEY (client_id) REFERENCES clients(id) ON DELETE RESTRICT
    "appointments_master_id_service_id_fkey" FOREIGN KEY (master_id, service_id) REFERENCES master_services(master_id, service_id) ON DELETE RESTRICT
Что база запомнила об appointments: индексы, ограничение исключения, проверки и оба внешних ключа

Проверим правила на живых данных: второй профиль мастеру, запись на услугу, которую мастер не делает, наложение по времени. Интервал tstzrange(starts_at, ends_at) по умолчанию полуоткрытый — [): начало входит, конец нет. Поэтому запись на 13:00 сразу после записи до 13:00 проходит, а отменённая запись перестаёт занимать время благодаря условию WHERE (status <> 'cancelled').

terminal
booking=> INSERT INTO master_profiles (master_id, bio) VALUES (1, 'Second profile');
ERROR:  duplicate key value violates unique constraint "master_profiles_pkey"
DETAIL:  Key (master_id)=(1) already exists.
booking=> INSERT INTO appointments (client_id, master_id, service_id, starts_at, ends_at, price) VALUES (2, 2, 1, '2026-10-22 10:00+05', '2026-10-22 11:00+05', 8000.00);
ERROR:  insert or update on table "appointments" violates foreign key constraint "appointments_master_id_service_id_fkey"
DETAIL:  Key (master_id, service_id)=(2, 1) is not present in table "master_services".
booking=> INSERT INTO appointments (client_id, master_id, service_id, starts_at, ends_at, price) VALUES (2, 1, 1, '2026-10-20 11:30+05', '2026-10-20 12:30+05', 8000.00);
ERROR:  conflicting key value violates exclusion constraint "appointments_master_id_tstzrange_excl"
DETAIL:  Key (master_id, tstzrange(starts_at, ends_at))=(1, ["2026-10-20 06:30:00+00","2026-10-20 07:30:00+00")) conflicts with existing key (master_id, tstzrange(starts_at, ends_at))=(1, ["2026-10-20 05:00:00+00","2026-10-20 07:00:00+00")).
booking=> INSERT INTO appointments (client_id, master_id, service_id, starts_at, ends_at, price) VALUES (2, 1, 1, '2026-10-20 13:00+05', '2026-10-20 14:00+05', 8000.00);
INSERT 0 1
booking=> UPDATE appointments SET status = 'cancelled' WHERE id = 2;
UPDATE 1
booking=> INSERT INTO appointments (client_id, master_id, service_id, starts_at, ends_at, price) VALUES (2, 1, 1, '2026-10-20 12:00+05', '2026-10-20 13:00+05', 8000.00) RETURNING id;
 id 
----
  7
(1 row)

INSERT 0 1
Реальные ответы PostgreSQL 17.11: 1:1, составной внешний ключ и ограничение исключения

Ключи: суррогатные и естественные, bigint или UUID

Естественный ключ — значение из реального мира, которое уже уникально: email, телефон, ИИН. Суррогатный — номер, который выдаёт сама база и который ничего не значит вне неё. Для первичного ключа почти всегда берут суррогатный, а естественный защищают ограничением UNIQUE, как телефон клиента в нашей схеме.

Естественный ключ (телефон, email)

  • Меняется: клиент сменил номер — меняй ключ во всех таблицах, которые на него ссылаются
  • Бывает пустым или неизвестным, а первичный ключ обязан быть NOT NULL
  • Часто это персональные данные — они расходятся по внешним ключам, журналам и URL
  • Полезен как UNIQUE: не даёт завести одного клиента дважды

Суррогатный ключ (bigint или uuid)

  • Никогда не меняется и ничего не раскрывает
  • Компактный: внешние ключи и индексы по bigint занимают 8 байт на значение
  • Не защищает от дублей сам по себе — к нему нужен UNIQUE на естественном признаке
  • Для таблицы связи первичным ключом служит пара внешних ключей — отдельный id не обязателен

bigint GENERATED ALWAYS AS IDENTITY

Плюсы
8 байт, номера растут по порядку, удобно читать и отлаживать
Минусы
Номер раскрывает объёмы и угадывается перебором; выдаёт его только эта база

uuid версии 4 (gen_random_uuid())

Плюсы
Можно создать где угодно без обращения к базе; не угадывается
Минусы
16 байт; случайные значения попадают в разные места индекса

uuid версии 7 (uuidv7(), PostgreSQL 18)

Плюсы
Упорядочен по времени создания, остальные плюсы UUID сохраняются
Минусы
16 байт; из значения можно извлечь время создания

Внешние ключи и поведение ON DELETE

Внешний ключ не даёт ссылке указать в пустоту. Но что делать, если удаляют строку, на которую ссылаются? Это решает правило ON DELETE, и выбирать его нужно по смыслу связи. Документация PostgreSQL формулирует принцип так: если ссылающаяся строка — часть целого и без него не существует, подходит CASCADE; если это независимые объекты — RESTRICT или NO ACTION.

NO ACTION (по умолчанию)

Что происходит
Ошибка, если ссылки остались; проверку можно отложить до конца транзакции
Где в нашей схеме
—

RESTRICT

Что происходит
Ошибка сразу, отложить проверку нельзя
Где в нашей схеме
Записи → клиент и пара «мастер — услуга»: история посещений и выручки

CASCADE

Что происходит
Зависимые строки удаляются вместе с родительской
Где в нашей схеме
Профиль мастера, связи мастер — услуга

SET NULL

Что происходит
Ссылка обнуляется
Где в нашей схеме
referred_by_id: рекомендатель — необязательная информация

SET DEFAULT

Что происходит
Ссылке присваивается значение по умолчанию; оно тоже должно существовать
Где в нашей схеме
Редко нужен
terminal
booking=> DELETE FROM clients WHERE id = 1;
ERROR:  update or delete on table "clients" violates foreign key constraint "appointments_client_id_fkey" on table "appointments"
DETAIL:  Key (id)=(1) is still referenced from table "appointments".
booking=> DELETE FROM masters WHERE id = 1;
ERROR:  update or delete on table "master_services" violates foreign key constraint "appointments_master_id_service_id_fkey" on table "appointments"
DETAIL:  Key (master_id, service_id)=(1, 1) is still referenced from table "appointments".
booking=> INSERT INTO masters (name) VALUES ('Trainee') RETURNING id;
 id 
----
  3
(1 row)

INSERT 0 1
booking=> INSERT INTO master_profiles (master_id, bio) VALUES (3, 'Junior');
INSERT 0 1
booking=> INSERT INTO master_services (master_id, service_id) VALUES (3, 1);
INSERT 0 1
booking=> DELETE FROM masters WHERE id = 3;
DELETE 1
booking=> SELECT count(*) AS profiles_left FROM master_profiles WHERE master_id = 3;
 profiles_left 
---------------
             0
(1 row)

booking=> DELETE FROM appointments WHERE client_id = 1;
DELETE 2
booking=> DELETE FROM clients WHERE id = 1 RETURNING id, name;
 id |      name       
----+-----------------
  1 | Aigerim Seitova
(1 row)

DELETE 1
booking=> SELECT id, name, referred_by_id FROM clients ORDER BY id;
 id |       name       | referred_by_id 
----+------------------+----------------
  2 | Madina Omarova   |               
  3 | Daniyar Akhmetov |               
(2 rows)
Реальное поведение схемы: RESTRICT защищает историю, CASCADE убирает профиль и связи мастера, SET NULL обнуляет ссылку на удалённого рекомендателя

Обратите внимание на вторую ошибку: удаление мастера каскадом дошло до master_services, а там упёрлось в RESTRICT из appointments. Каскады и запреты работают цепочкой по всей схеме — это ещё одна причина продумывать их заранее. В PostgreSQL 18 текст этой ошибки уточнили: он сообщает, что нарушено именно правило RESTRICT (violates RESTRICT setting of foreign key constraint).

Удалять клиентов в реальном сервисе, как правило, не нужно: история записей нужна для отчётов и споров. По нашему опыту, вместо удаления клиента обезличивают — заменяют имя и телефон, оставляя строку и связи. RESTRICT на appointments.client_id как раз не даст случайно стереть историю вместе с клиентом.

Нормализация до 3НФ на примере заказов

Нормализация — это раскладка данных по таблицам так, чтобы каждый факт хранился в одном месте. Возьмём учёт заказов кофейного магазина, который вёлся в Excel: одна строка — одна позиция заказа, а данные клиента и товара повторяются в каждой строке.

  1. 01Плоская таблица
  2. 021НФ: одно значение в ячейке
  3. 032НФ: orders, order_items, products
  4. 043НФ: clients отдельно
Путь от плоской таблицы к схеме учебной базы магазина
db/sheet.sql
-- Заказы, как их ведут в таблице Excel: одна строка — одна позиция
CREATE TABLE order_sheet (
  order_no     integer,
  ordered_on   date,
  client_name  text,
  client_email text,
  client_city  text,
  product      text,
  category     text,
  price        numeric(10,2),
  quantity     integer
);

INSERT INTO order_sheet VALUES
  (1, '2026-09-02', 'Aigerim Seitova',  'aigerim@example.com', 'Almaty', 'Espresso Blend 1 kg',        'coffee', 12900.00, 1),
  (1, '2026-09-02', 'Aigerim Seitova',  'aigerim@example.com', 'Almaty', 'Paper filters V60',          'gear',    2500.00, 2),
  (2, '2026-09-05', 'Daniyar Akhmetov', NULL,                  'Astana', 'Ethiopia Yirgacheffe 250 g', 'coffee',  5600.00, 2),
  (3, '2026-09-14', 'Aigerim Seitova',  'aigerim@example.com', 'Almaty', 'Ethiopia Yirgacheffe 250 g', 'coffee',  5600.00, 1);
Плоская таблица без ключей и ограничений — как выгрузка из Excel
terminal
shop_design=> SELECT * FROM order_sheet;
 order_no | ordered_on |   client_name    |    client_email     | client_city |          product           | category |  price   | quantity 
----------+------------+------------------+---------------------+-------------+----------------------------+----------+----------+----------
        1 | 2026-09-02 | Aigerim Seitova  | aigerim@example.com | Almaty      | Espresso Blend 1 kg        | coffee   | 12900.00 |        1
        1 | 2026-09-02 | Aigerim Seitova  | aigerim@example.com | Almaty      | Paper filters V60          | gear     |  2500.00 |        2
        2 | 2026-09-05 | Daniyar Akhmetov |                     | Astana      | Ethiopia Yirgacheffe 250 g | coffee   |  5600.00 |        2
        3 | 2026-09-14 | Aigerim Seitova  | aigerim@example.com | Almaty      | Ethiopia Yirgacheffe 250 g | coffee   |  5600.00 |        1
(4 rows)

shop_design=> UPDATE order_sheet SET client_city = 'Astana' WHERE order_no = 3;
UPDATE 1
shop_design=> SELECT DISTINCT client_name, client_city FROM order_sheet ORDER BY 1, 2;
   client_name    | client_city 
------------------+-------------
 Aigerim Seitova  | Almaty
 Aigerim Seitova  | Astana
 Daniyar Akhmetov | Astana
(3 rows)
Аномалия обновления: город изменили только в одном заказе, и у клиентки теперь два города одновременно

Это аномалия обновления. В той же таблице живут ещё две: нельзя добавить товар, пока его никто не заказал (аномалия вставки), а удаление заказа 2 стирает всё, что мы знали о Данияре (аномалия удаления). Нормальные формы устраняют их по шагам:

1НФ

Правило
В ячейке одно значение, нет повторяющихся групп столбцов
Что делаем с таблицей
Не пишем «Espresso ×1, Filters ×2» в одну ячейку и не заводим столбцы product1, product2: одна позиция — одна строка. Наша таблица это уже соблюдает

2НФ

Правило
Каждый неключевой столбец зависит от всего ключа, а не от его части
Что делаем с таблицей
Ключ строки — (order_no, product). Дата и клиент зависят только от order_no → таблица orders; категория и цена каталога — только от товара → products; количество и цена продажи — от пары → order_items

3НФ

Правило
Неключевые столбцы не зависят друг от друга
Что делаем с таблицей
В orders город и email зависят от клиента, а не от номера заказа → таблица clients, в заказе остаётся client_id
db/normalize.sql
-- 3НФ: каждый факт хранится в одном месте
CREATE TABLE clients (
  id    bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name  text NOT NULL,
  city  text,
  email text UNIQUE
);

CREATE TABLE products (
  id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  name     text          NOT NULL UNIQUE,
  category text          NOT NULL,
  price    numeric(10,2) NOT NULL CHECK (price > 0)
);

CREATE TABLE orders (
  id         bigint PRIMARY KEY,
  client_id  bigint NOT NULL REFERENCES clients (id),
  ordered_on date   NOT NULL
);

CREATE TABLE order_items (
  order_id   bigint        NOT NULL REFERENCES orders (id) ON DELETE CASCADE,
  product_id bigint        NOT NULL REFERENCES products (id),
  quantity   integer       NOT NULL CHECK (quantity > 0),
  price      numeric(10,2) NOT NULL CHECK (price > 0),
  PRIMARY KEY (order_id, product_id)
);

-- Перенос данных из плоской таблицы
INSERT INTO clients (name, city, email)
SELECT DISTINCT client_name, client_city, client_email
FROM order_sheet
ORDER BY client_name;

INSERT INTO products (name, category, price)
SELECT DISTINCT product, category, price
FROM order_sheet
ORDER BY product;

INSERT INTO orders (id, client_id, ordered_on)
SELECT DISTINCT s.order_no, c.id, s.ordered_on
FROM order_sheet s
JOIN clients c ON c.name = s.client_name;

INSERT INTO order_items (order_id, product_id, quantity, price)
SELECT s.order_no, p.id, s.quantity, s.price
FROM order_sheet s
JOIN products p ON p.name = s.product;
Схема в 3НФ — это те же таблицы, что в учебной базе уроков SQL, без лишних столбцов — и перенос данных через INSERT … SELECT
terminal
$ docker exec booking-db psql -U booking_app -d shop_design -v ON_ERROR_STOP=1 -1 -f /db/normalize.sql; echo "exit $?"
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
psql:/db/normalize.sql:34: ERROR:  duplicate key value violates unique constraint "clients_email_key"
DETAIL:  Key (email)=(aigerim@example.com) already exists.
exit 3
shop_design=> UPDATE order_sheet SET client_city = 'Astana' WHERE client_name = 'Aigerim Seitova';
UPDATE 3
$ docker exec booking-db psql -U booking_app -d shop_design -v ON_ERROR_STOP=1 -1 -f /db/normalize.sql; echo "exit $?"
CREATE TABLE
CREATE TABLE
CREATE TABLE
CREATE TABLE
INSERT 0 2
INSERT 0 3
INSERT 0 3
INSERT 0 4
exit 0
Первый перенос остановлен ограничением UNIQUE: противоречие из Excel не попадёт в новую схему. После исправления исходных данных перенос проходит целиком

Перенос выполняется одной транзакцией (флаг -1), поэтому неудачная попытка не оставила полузаполненных таблиц. В учебном примере клиентов сопоставляем по имени; в реальной миграции нужен надёжный признак — телефон или email — и ручная проверка дублей.

terminal
SELECT o.id AS order_no, o.ordered_on, c.name AS client, c.city,
       p.name AS product, i.quantity, i.price
FROM orders o
JOIN clients c     ON c.id = o.client_id
JOIN order_items i ON i.order_id = o.id
JOIN products p    ON p.id = i.product_id
ORDER BY o.id, p.name;
 order_no | ordered_on |      client      |  city  |          product           | quantity |  price   
----------+------------+------------------+--------+----------------------------+----------+----------
        1 | 2026-09-02 | Aigerim Seitova  | Astana | Espresso Blend 1 kg        |        1 | 12900.00
        1 | 2026-09-02 | Aigerim Seitova  | Astana | Paper filters V60          |        2 |  2500.00
        2 | 2026-09-05 | Daniyar Akhmetov | Astana | Ethiopia Yirgacheffe 250 g |        2 |  5600.00
        3 | 2026-09-14 | Aigerim Seitova  | Astana | Ethiopia Yirgacheffe 250 g |        1 |  5600.00
(4 rows)

UPDATE clients SET city = 'Shymkent' WHERE email = 'aigerim@example.com';
UPDATE 1
INSERT INTO products (name, category, price) VALUES ('Kalita Wave 185', 'gear', 18500.00);
INSERT 0 1
DELETE FROM orders WHERE id = 2;
DELETE 1
SELECT name, city FROM clients ORDER BY id;
       name       |   city   
------------------+----------
 Aigerim Seitova  | Shymkent
 Daniyar Akhmetov | Astana
(2 rows)
JOIN собирает исходную «плоскую» картину, а смена города, новый товар без заказов и удаление заказа больше не ломают данные

Когда денормализация оправдана

Денормализация — сознательное хранение копии или производного значения ради скорости чтения. Документация Microsoft о нормализации прямо признаёт, что третья нормальная форма не всегда практична, и советует, отступая от правил, заранее продумать, как приложение справится с дублями. Главное правило — у копии должен быть один источник правды и механизм, который держит их в согласии.

Безопасные способы

  • Генерируемый столбец: значение из той же строки считает база, записать в него вручную нельзя
  • Материализованное представление: тяжёлый отчёт хранится как таблица и обновляется командой REFRESH
  • Снимок на момент события: цена, адрес, состав заказа

Рискованные способы

  • Копия имени клиента в каждом заказе «чтобы не делать JOIN»
  • Счётчики и суммы, которые обновляет код приложения, — расходятся при первой ошибке или гонке запросов
  • Денормализация «на всякий случай», без замера медленного запроса
db/denorm.sql
-- 1. Сумма строки считается самой базой и не может разойтись с quantity и price
ALTER TABLE order_items
  ADD COLUMN line_total numeric(12,2) GENERATED ALWAYS AS (quantity * price) STORED;

-- 2. Готовый отчёт: хранится как таблица, обновляется по команде
CREATE MATERIALIZED VIEW client_revenue AS
SELECT c.name, count(DISTINCT o.id) AS orders, sum(i.line_total) AS revenue
FROM clients c
JOIN orders o      ON o.client_id = c.id
JOIN order_items i ON i.order_id = o.id
GROUP BY c.name;
Два способа денормализации, где согласованность держит сама база
terminal
shop_design=> SELECT order_id, product_id, quantity, price, line_total FROM order_items ORDER BY 1, 2;
 order_id | product_id | quantity |  price   | line_total 
----------+------------+----------+----------+------------
        1 |          1 |        1 | 12900.00 |   12900.00
        1 |          3 |        2 |  2500.00 |    5000.00
        3 |          2 |        1 |  5600.00 |    5600.00
(3 rows)

shop_design=> UPDATE order_items SET line_total = 0;
ERROR:  column "line_total" can only be updated to DEFAULT
DETAIL:  Column "line_total" is a generated column.
shop_design=> SELECT * FROM client_revenue ORDER BY name;
      name       | orders | revenue  
-----------------+--------+----------
 Aigerim Seitova |      2 | 23500.00
(1 row)

shop_design=> INSERT INTO orders (id, client_id, ordered_on) VALUES (4, 2, '2026-10-01');
INSERT 0 1
shop_design=> INSERT INTO order_items (order_id, product_id, quantity, price) VALUES (4, 1, 3, 12900.00);
INSERT 0 1
shop_design=> SELECT * FROM client_revenue ORDER BY name;
      name       | orders | revenue  
-----------------+--------+----------
 Aigerim Seitova |      2 | 23500.00
(1 row)

shop_design=> REFRESH MATERIALIZED VIEW client_revenue;
REFRESH MATERIALIZED VIEW
shop_design=> SELECT * FROM client_revenue ORDER BY name;
       name       | orders | revenue  
------------------+--------+----------
 Aigerim Seitova  |      2 | 23500.00
 Daniyar Akhmetov |      1 | 38700.00
(2 rows)
Генерируемый столбец нельзя испортить; материализованное представление показывает данные на момент последнего REFRESH

Материализованное представление после нового заказа продолжает показывать старые цифры, пока его не обновят, — документация прямо говорит, что его данные не всегда актуальны. Для ежедневного отчёта это нормально, для остатка на складе — нет. В PostgreSQL 17 есть только хранимые (STORED) генерируемые столбцы, в 18 появились и виртуальные — они вычисляются при чтении и стали вариантом по умолчанию. Прежде чем денормализовать ради скорости, замерьте запрос и попробуйте индекс — об этом урок об индексах.

Частые ошибки проектирования и упражнения

Список id через запятую в одной ячейке ('1,4,7')

Чем плоха
Нет внешних ключей, нельзя сделать JOIN, легко сохранить несуществующий id
Как правильно
Таблица связи N:M

Столбцы phone1, phone2, phone3

Чем плоха
Повторяющаяся группа: четвёртый номер требует менять схему
Как правильно
Отдельная таблица телефонов 1:N

CASCADE на исторических данных

Чем плоха
Удаление клиента стирает его заказы и выручку из отчётов
Как правильно
RESTRICT и обезличивание вместо удаления

Телефон или email как первичный ключ

Чем плоха
Смена значения затрагивает все ссылающиеся таблицы
Как правильно
Суррогатный ключ + UNIQUE на естественном

Статус — свободный текст

Чем плоха
В базе появляются paid, Paid и «оплачен»
Как правильно
CHECK (status IN (…)) или отдельный справочник статусов

Внешний ключ без индекса

Чем плоха
Медленные удаления родителя и JOIN на больших таблицах
Как правильно
Индекс на ссылающихся столбцах
Как проверено6 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    db/booking.sql + db/booking-seed.sql (btree_gist, 1:1, N:M, составной FK, EXCLUDE, индексы)

    postgres:17-alpine (PostgreSQL 17.11), контейнер kb-pg-design (--rm), роль-владелец booking_app (не суперпользователь) — CREATE EXTENSION btree_gist прошёл; полный прогон скриптом run.sh, все выводы в статье извлечены генератором из run.log без правки. В статье контейнер называется booking-db

  • запущено, работает

    проверки связей и ON DELETE (дубль профиля, составной FK, пересечение, отмена записи, RESTRICT, CASCADE, SET NULL)

    реальный вывод psql 17.11, каждая команда отправлялась отдельно; приглашение booking=> добавлено к эху команды

  • запущено, работает

    db/sheet.sql, аномалия обновления, db/normalize.sql (две попытки в одной транзакции -1), JOIN после нормализации

    первая попытка — ошибка clients_email_key, exit 3, таблицы не созданы (откат); после исправления — exit 0, INSERT 2/3/3/4; вывод JOIN, UPDATE 1, вставка товара без заказа, удаление заказа без потери клиента

  • запущено, работает

    db/denorm.sql (генерируемый столбец STORED, материализованное представление) и демонстрация

    UPDATE line_total → «can only be updated to DEFAULT»; представление до и после REFRESH — реальный вывод

  • запущено, работает

    упражнения: ex-reviews.sql (1:1 + CASCADE) и ex-courses.sql (1:N, N:M с атрибутами)

    run-ex.sh на той же базе после демонстраций: дубль отзыва, оценка 6, каскадное удаление отзыва; дубль записи на курс, RESTRICT для курса, CASCADE для студента — вывод в решениях реальный

  • запущено, работает

    сверка на PostgreSQL 18

    тот же run.sh на postgres:18-alpine (18.6): вывод идентичен, кроме текста ошибок RESTRICT («violates RESTRICT setting of foreign key constraint … is referenced from table») — отмечено в статье

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.