Проектирование связей
Три типа связей
- 1:1 — один к одному.
- 1:N — один ко многим.
- N:N — многие ко многим.
Правило: в реляционной БД любая связь реализуется через внешний ключ (FK).
Один к одному (1:1)
Когда:
- Разделение на «основное + детали» — редкие поля отдельно.
- Разделение по правам — секретные данные в отдельной таблице.
- Оптимизация — тяжёлые колонки не читать постоянно.
- Логические группы —
users+user_settings.
Реализация:
CREATE TABLE users (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
name TEXT NOT NULL
);
CREATE TABLE user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
bio TEXT,
avatar_url TEXT,
birth_date DATE
);
Ключевое:
user_id— и PK, и FK. Это гарантирует 1:1.ON DELETE CASCADE— профиль не нужен без пользователя.
⚠️ Если PK профиля — отдельный id, то возможны дубли. Не делайте так.
Альтернатива — общая таблица:
Если поля всегда читаются вместе — 1:1 не нужен. Держите в одной таблице.
Правило: 1:1 оправдано редко. Часто это преждевременная оптимизация.
Один ко многим (1:N)
Основной тип связи.
Пример: клиент → заказы.
CREATE TABLE customers (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE
);
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_orders_customer ON orders (customer_id);
Что важно:
- FK в дочерней таблице —
orders.customer_id. - Индекс на FK — для быстрого JOIN и CASCADE.
NOT NULL— если заказ всегда у клиента.
ON DELETE — выбор:
CASCADE— удалить заказы при удалении клиента.RESTRICT— запретить удаление.SET NULL— заказы станут без клиента (нужноNOT NULLубрать).
Правило:
- Комментарии, лайки, товары в заказе —
CASCADE. - Справочники (категории, бренды) —
RESTRICT. - Опциональная связь —
SET NULL.
Многие ко многим (N:N)
Реализация: через связующую (junction) таблицу.
Пример: посты ↔ теги.
CREATE TABLE posts (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
title TEXT NOT NULL
);
CREATE TABLE tags (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL UNIQUE
);
CREATE TABLE post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_post_tags_tag ON post_tags (tag_id);
Ключевое:
- Составной PK —
(post_id, tag_id)— гарантирует уникальность. - Индекс на второй ключ —
tag_id— для обратного поиска. ON DELETE CASCADEс обеих сторон.
Что даёт:
- Нет дублей связей.
- Можно добавить атрибуты связи (см. ниже).
Атрибуты связи
Связь может иметь собственные атрибуты:
Пример: заказ ↔ товар.
CREATE TABLE order_items (
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id) ON DELETE RESTRICT,
quantity INTEGER NOT NULL CHECK (quantity > 0),
price_at_purchase NUMERIC(10, 2) NOT NULL,
discount NUMERIC(10, 2) NOT NULL DEFAULT 0,
PRIMARY KEY (order_id, product_id)
);
Что здесь:
quantity— сколько.price_at_purchase— snapshot цены.discount— скидка на позицию.
Правило: связь — это тоже сущность. Если у неё есть атрибуты — они в связующей таблице.
Самореферентные связи
Пример: категории с родителями.
CREATE TABLE categories (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name TEXT NOT NULL,
parent_id BIGINT REFERENCES categories(id) ON DELETE SET NULL
);
CREATE INDEX idx_categories_parent ON categories (parent_id);
Что даёт: дерево категорий.
Запросы:
Все дети:
SELECT * FROM categories WHERE parent_id = 5;
Дерево (рекурсивный CTE):
WITH RECURSIVE tree AS (
SELECT id, name, parent_id, 1 AS depth, ARRAY[id] AS path
FROM categories WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id, t.depth + 1, t.path || c.id
FROM categories c
JOIN tree t ON c.parent_id = t.id
)
SELECT * FROM tree ORDER BY path;
Что даёт: полная иерархия одним запросом.
⚠️ Глубокие деревья (10+ уровней) — медленно. Для них — ltree (PostgreSQL) или materialized path.
Полиморфные связи
Проблема: комментарии могут быть у постов, товаров, статей.
Вариант 1 — nullable FK:
CREATE TABLE comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
post_id BIGINT REFERENCES posts(id),
product_id BIGINT REFERENCES products(id),
article_id BIGINT REFERENCES articles(id),
body TEXT NOT NULL,
CHECK (num_nonnulls(post_id, product_id, article_id) = 1)
);
Плюс: FK работают. Минус: много nullable колонок.
Вариант 2 — polymorphic (Rails-style):
CREATE TABLE comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
commentable_type TEXT NOT NULL,
commentable_id BIGINT NOT NULL,
body TEXT NOT NULL
);
CREATE INDEX idx_comments_poly ON comments (commentable_type, commentable_id);
Плюс: гибко. Минус: нет FK, возможны сироты.
Вариант 3 — отдельные таблицы:
post_comments
product_comments
article_comments
Плюс: целостность, свои поля. Минус: дублирование структуры.
Правило:
- Мало типов — вариант 1.
- Много, часто меняются — вариант 2 (без FK, но с CHECK).
- Разные поля — вариант 3.
Многие ко многим с атрибутами vs сущность
Ситуация: нужна «таблица подписок» — user ↔ channel, с датой и статусом.
Это уже сущность, а не связь.
CREATE TABLE subscriptions (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
channel_id BIGINT NOT NULL REFERENCES channels(id),
status TEXT NOT NULL DEFAULT 'active',
subscribed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
unsubscribed_at TIMESTAMPTZ,
UNIQUE (user_id, channel_id)
);
Что даёт:
id— проще ссылаться.- Больше атрибутов.
- История через
status.
Правило: если у связи > 3 атрибутов или сложная логика — делайте отдельную сущность с id.
Реализация FK
На уровне схемы (правильно):
CREATE TABLE orders (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE
);
На уровне приложения (плохо):
// Проверка в коде
if (!User::find($user_id)) throw new Exception();
$order = Order::create([...]);
// Гонка: user удалён между проверкой и insert
Правило: всегда FK в схеме. Проверка на уровне приложения — дополнительно, не вместо.
Отключение FK (для bulk load):
SET FOREIGN_KEY_CHECKS = 0;
-- массовый INSERT
SET FOREIGN_KEY_CHECKS = 1;
⚠️ Только для миграций и seeding. Проверяйте данные перед включением обратно.
Deferred constraints
PostgreSQL:
ALTER TABLE orders
ADD CONSTRAINT fk_orders_user
FOREIGN KEY (user_id) REFERENCES users(id)
DEFERRABLE INITIALLY DEFERRED;
Что даёт: проверка FK откладывается до конца транзакции. Можно вставить заказ до пользователя в той же транзакции.
Когда: циклические ссылки, сложные миграции.
Проблемы и решения
1. Циклические FK:
users.company_id → companies
companies.owner_id → users
Решение: DEFERRABLE или разрешить NULL.
2. Удаление с CASCADE по цепочке:
Удалили user → cascade удалил orders → cascade удалил order_items → cascade удалил ...
⚠️ Может занять время. И нельзя остановить.
Решение: soft delete + периодический hard delete.
3. FK на большую таблицу тормозит:
INSERT в orders проверяет users
Решение: индекс на FK обязателен.
4. Осиротевшие записи:
FK отключён → данные невалидны
Решение: проверка LEFT JOIN ... WHERE parent.id IS NULL.
5. Нет уникальности в N:N:
post_tags без PK → дубли
Решение: составной PK.
Практика: схемы связей
1. 1:1 — users + user_settings:
CREATE TABLE user_settings (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
theme TEXT NOT NULL DEFAULT 'light',
language TEXT NOT NULL DEFAULT 'ru',
notifications_enabled BOOLEAN NOT NULL DEFAULT TRUE
);
2. 1:N — customers + orders:
CREATE TABLE orders (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
...
);
CREATE INDEX idx_orders_customer ON orders (customer_id);
3. N:N — posts + tags:
CREATE TABLE post_tags (
post_id BIGINT NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);
CREATE INDEX idx_post_tags_tag ON post_tags (tag_id);
4. Самореферентная — categories:
CREATE TABLE categories (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
parent_id BIGINT REFERENCES categories(id) ON DELETE SET NULL
);
CREATE INDEX idx_categories_parent ON categories (parent_id);
5. Полиморфная — comments:
CREATE TABLE comments (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
commentable_type TEXT NOT NULL,
commentable_id BIGINT NOT NULL,
body TEXT NOT NULL,
CHECK (commentable_type IN ('post', 'product', 'article'))
);
CREATE INDEX idx_comments_poly ON comments (commentable_type, commentable_id);
Итог
- 1:1 —
user_idкак PK + FK. - 1:N — FK в дочерней + индекс.
- N:N — связующая таблица + составной PK.
- Атрибуты связи — в связующей таблице.
- Самореферентная —
parent_id+ рекурсивный CTE. - Полиморфная — 3 варианта, у каждого минусы.
- FK всегда в схеме.
- Индекс на FK обязателен.
DEFERRABLE— для циклических.
Дальше — индексы: глубокое погружение.