Проектирование базы начинается с сущностей предметной области и связей между ними: 1:1 реализуют внешним ключом, который одновременно первичный, 1:N — внешним ключом на стороне «многих», N:M — отдельной таблицей связи с составным первичным ключом. Для каждого внешнего ключа выбирают поведение при удалении: RESTRICT для истории, CASCADE для частей целого, SET NULL для необязательных ссылок. Нормализация до третьей нормальной формы убирает дубли и противоречия; денормализуют осознанно и так, чтобы база сама держала копию в согласии с источником.
После материала вы сможете
- перевести описание сервиса в сущности и реализовать связи 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 г.
Содержание
От предметной области к сущностям
Схему базы проектируют до первой строки кода: исправить её потом, когда в таблицах живые данные, намного дороже. Начните с описания бизнеса обычными словами. Для примера возьмём сервис онлайн-записи в салон: «Клиенты записываются к мастерам на услуги. Каждый мастер выполняет свой набор услуг. Запись — это конкретное время у конкретного мастера на одну услугу по цене на момент записи. У мастера есть профиль для сайта. Клиент может прийти по рекомендации другого клиента».
Существительные из такого описания — кандидаты в сущности (будущие таблицы): клиент, мастер, услуга, запись, профиль. Глаголы и слова «каждый», «свой», «один» подсказывают связи и их кратность. Атрибуты — то, что мы знаем о сущности: имя и телефон клиента, длительность и цена услуги, время записи.
- 01clients, masters, servicesНезависимые сущности: собственный первичный ключ `id`, у клиента уникальный телефон и необязательная ссылка на рекомендателя
- 02master_profiles — 1:1Первичный ключ `master_id` одновременно внешний ключ на `masters`: второго профиля у мастера быть не может
- 03master_services — N:MТаблица связи мастер ↔ услуга с составным первичным ключом `(master_id, service_id)`
- 04appointments — 1:NСсылается на клиента и на пару «мастер — услуга»; ограничение исключения не даёт пересечься записям одного мастера
Обратите внимание, что запись ссылается не на мастера и услугу по отдельности, а на их пару в 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. Оно помечено в документации как доверенное, поэтому его может установить владелец базы, а не только суперпользователь.
-- Сервис онлайн-записи: клиенты, мастера, услуги, записи
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);-- Вымышленные данные салона
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);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.sqlbooking=> \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Проверим правила на живых данных: второй профиль мастеру, запись на услугу, которую мастер не делает, наложение по времени. Интервал tstzrange(starts_at, ends_at) по умолчанию полуоткрытый — [): начало входит, конец нет. Поэтому запись на 13:00 сразу после записи до 13:00 проходит, а отменённая запись перестаёт занимать время благодаря условию WHERE (status <> 'cancelled').
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Ключи: суррогатные и естественные, 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
- Что происходит
- Ссылке присваивается значение по умолчанию; оно тоже должно существовать
- Где в нашей схеме
- Редко нужен
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)Обратите внимание на вторую ошибку: удаление мастера каскадом дошло до master_services, а там упёрлось в RESTRICT из appointments. Каскады и запреты работают цепочкой по всей схеме — это ещё одна причина продумывать их заранее. В PostgreSQL 18 текст этой ошибки уточнили: он сообщает, что нарушено именно правило RESTRICT (violates RESTRICT setting of foreign key constraint).
Удалять клиентов в реальном сервисе, как правило, не нужно: история записей нужна для отчётов и споров. По нашему опыту, вместо удаления клиента обезличивают — заменяют имя и телефон, оставляя строку и связи. RESTRICT на appointments.client_id как раз не даст случайно стереть историю вместе с клиентом.
Нормализация до 3НФ на примере заказов
Нормализация — это раскладка данных по таблицам так, чтобы каждый факт хранился в одном месте. Возьмём учёт заказов кофейного магазина, который вёлся в Excel: одна строка — одна позиция заказа, а данные клиента и товара повторяются в каждой строке.
- 01Плоская таблица
- 021НФ: одно значение в ячейке
- 032НФ: orders, order_items, products
- 043НФ: clients отдельно
-- Заказы, как их ведут в таблице 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);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
-- 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;$ 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Перенос выполняется одной транзакцией (флаг -1), поэтому неудачная попытка не оставила полузаполненных таблиц. В учебном примере клиентов сопоставляем по имени; в реальной миграции нужен надёжный признак — телефон или email — и ручная проверка дублей.
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)Когда денормализация оправдана
Денормализация — сознательное хранение копии или производного значения ради скорости чтения. Документация Microsoft о нормализации прямо признаёт, что третья нормальная форма не всегда практична, и советует, отступая от правил, заранее продумать, как приложение справится с дублями. Главное правило — у копии должен быть один источник правды и механизм, который держит их в согласии.
Безопасные способы
- Генерируемый столбец: значение из той же строки считает база, записать в него вручную нельзя
- Материализованное представление: тяжёлый отчёт хранится как таблица и обновляется командой
REFRESH - Снимок на момент события: цена, адрес, состав заказа
Рискованные способы
- Копия имени клиента в каждом заказе «чтобы не делать JOIN»
- Счётчики и суммы, которые обновляет код приложения, — расходятся при первой ошибке или гонке запросов
- Денормализация «на всякий случай», без замера медленного запроса
-- 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;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)Материализованное представление после нового заказа продолжает показывать старые цифры, пока его не обновят, — документация прямо говорит, что его данные не всегда актуальны. Для ежедневного отчёта это нормально, для остатка на складе — нет. В 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.