SQLLab
Все статьи

Связи между таблицами в SQL: один-к-одному, один-ко-многим, многие-ко-многим

Типы связей в реляционных базах данных: один-к-одному, один-ко-многим, многие-ко-многим. Как реализовать через Foreign Key и junction table.

28 марта 2026 г.·5 мин чтения·

Правильно спроектированные связи между таблицами — основа надёжной базы данных. Выбор неверного типа связи приводит к дублированию данных, сложным запросам и трудноуловимым ошибкам. Разберём все три типа на конкретных примерах.

Один-к-одному (One-to-One)

Каждой записи в таблице A соответствует не более одной записи в таблице B, и наоборот.

Когда использовать:

  • Данные логически относятся к одной сущности, но редко нужны вместе.
  • Часть данных конфиденциальна (пароли, персональные данные) — разделение по таблицам упрощает управление правами.
  • Большая часть записей не имеет дополнительных данных (разрежённые атрибуты).

Пример: пользователь и его профиль

CREATE TABLE users (
  user_id    SERIAL PRIMARY KEY,
  email      TEXT NOT NULL UNIQUE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE TABLE user_profiles (
  user_id     INT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
  full_name   TEXT,
  avatar_url  TEXT,
  bio         TEXT,
  birth_date  DATE
);

Ключевой момент: в таблице user_profiles первичный ключ одновременно является внешним ключом. Это гарантирует уникальность — у каждого пользователя не может быть двух профилей.

-- Получить пользователя с профилем
SELECT u.email, p.full_name, p.bio
FROM users u
LEFT JOIN user_profiles p ON u.user_id = p.user_id
WHERE u.user_id = 42;

Один-ко-многим (One-to-Many)

Одной записи в таблице A соответствует много записей в таблице B. Это самый распространённый тип связи.

Пример: пользователь и его заказы

CREATE TABLE orders (
  order_id   SERIAL PRIMARY KEY,
  user_id    INT NOT NULL REFERENCES users(user_id) ON DELETE RESTRICT,
  status     TEXT NOT NULL DEFAULT 'pending',
  total      NUMERIC(12,2),
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Индекс на внешнем ключе — обязателен для производительности
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Все заказы пользователя
SELECT order_id, status, total, created_at
FROM orders
WHERE user_id = 42
ORDER BY created_at DESC;

-- Количество заказов и сумма по каждому пользователю
SELECT
  u.email,
  COUNT(o.order_id)  AS orders_count,
  SUM(o.total)       AS total_spent
FROM users u
LEFT JOIN orders o ON u.user_id = o.user_id
GROUP BY u.user_id, u.email
ORDER BY total_spent DESC NULLS LAST;

Многие-ко-многим (Many-to-Many)

Одной записи в таблице A соответствует много записей в таблице B, и наоборот. Реализуется через промежуточную таблицу (junction table, associative table, bridge table).

Пример: заказы и товары

CREATE TABLE products (
  product_id SERIAL PRIMARY KEY,
  name       TEXT    NOT NULL,
  price      NUMERIC(10,2) NOT NULL
);

-- Промежуточная таблица
CREATE TABLE order_items (
  order_id   INT REFERENCES orders(order_id)     ON DELETE CASCADE,
  product_id INT REFERENCES products(product_id) ON DELETE RESTRICT,
  quantity   INT     NOT NULL DEFAULT 1,
  unit_price NUMERIC(10,2) NOT NULL, -- цена на момент покупки
  PRIMARY KEY (order_id, product_id)
);

CREATE INDEX idx_order_items_product_id ON order_items(product_id);

Поле unit_price в промежуточной таблице — намеренная денормализация: фиксируем цену на момент покупки, чтобы историческая отчётность оставалась корректной.

-- Состав конкретного заказа
SELECT
  p.name,
  oi.quantity,
  oi.unit_price,
  oi.quantity * oi.unit_price AS line_total
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
WHERE oi.order_id = 101;

-- Самые популярные товары (по числу заказов)
SELECT
  p.name,
  COUNT(DISTINCT oi.order_id) AS times_ordered,
  SUM(oi.quantity)            AS units_sold
FROM order_items oi
JOIN products p ON oi.product_id = p.product_id
GROUP BY p.product_id, p.name
ORDER BY times_ordered DESC
LIMIT 10;

Поведение при удалении: ON DELETE

При объявлении внешнего ключа нужно явно выбрать, что происходит со связанными записями при удалении родительской:

ОпцияПоведение
RESTRICTЗапретить удаление, если есть дочерние записи
CASCADEУдалить все дочерние записи вместе с родительской
SET NULLПроставить NULL в дочерних записях
SET DEFAULTПроставить значение DEFAULT
NO ACTIONТо же, что RESTRICT, но проверка откладывается до конца транзакции
-- Каскадное удаление: удаляем пользователя → удаляются все его сессии
CREATE TABLE user_sessions (
  session_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id    INT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
  expires_at TIMESTAMPTZ NOT NULL
);

-- SET NULL: удаляем менеджера → его клиенты становятся «без менеджера»
CREATE TABLE clients (
  client_id  SERIAL PRIMARY KEY,
  name       TEXT NOT NULL,
  manager_id INT REFERENCES employees(employee_id) ON DELETE SET NULL
);

Практическое правило: используйте CASCADE с осторожностью. Случайное удаление родительской записи может удалить много дочерних данных. RESTRICT — более безопасный выбор по умолчанию; удаление потребует явной очистки дочерних записей.

Индексы на внешних ключах

PostgreSQL автоматически создаёт индекс только на первичном ключе и уникальных ограничениях. На столбцах с внешними ключами индексы нужно создавать вручную.

Без индекса поиск «всех заказов пользователя» выполняет Seq Scan по всей таблице заказов. С индексом — Index Scan по idx_orders_user_id.

-- Проверить: какие FK не имеют индекса (PostgreSQL)
SELECT
  conrelid::regclass AS table_name,
  conname            AS constraint_name,
  pg_get_constraintdef(oid) AS constraint_def
FROM pg_constraint
WHERE contype = 'f'
  AND NOT EXISTS (
    SELECT 1
    FROM pg_index i
    WHERE i.indrelid = conrelid
      AND (i.indkey::int[])[0:array_length(conkey, 1)-1] @> conkey::int[]
  );

Итог

  • 1:1 — разделение таблицы для изоляции редко используемых или чувствительных данных.
  • 1:M — стандартная связь «владелец → принадлежащие объекты».
  • M:M — всегда реализуется через промежуточную таблицу; в ней можно хранить атрибуты самой связи.
  • Всегда создавайте индексы на столбцах внешних ключей.
  • Явно указывайте ON DELETE поведение — не оставляйте его на усмотрение базы данных.

Закрепите знания о связях на практических задачах в нашем тренажёре SQL.

Похожие статьи

Попробуй на практике

Тренажёр с реальными задачами — бесплатно и без регистрации

Открыть тренажёр →