Связи в базах данных: один-ко-многим и многие-ко-многим
Разбираемся с фундаментальными типами связей в реляционных базах данных. Узнайте, как правильно проектировать связи один-ко-многим и многие-ко-многим, создавать промежуточные таблицы, работать с внешними ключами и оптимизировать запросы. Примеры на SQL и TypeORM, разбор типичных ошибок и best practices для разработчиков.
Зачем вообще нужны связи?
Представьте, что вы разрабатываете интернет-магазин. У вас есть пользователи, которые делают заказы. Можно, конечно, хранить все данные в одной таблице, дублируя информацию о пользователе в каждом заказе. Но это плохая идея по нескольким причинам:
Во-первых, вы будете дублировать данные. Если у пользователя изменится email или адрес, придётся обновлять записи во всех его заказах. Во-вторых, это занимает больше места в базе данных. В-третьих, легко допустить ошибку и получить несогласованные данные.
Связи решают эту проблему, позволяя хранить данные в отдельных таблицах и связывать их через внешние ключи.

Связь один-ко-многим (One-to-Many)
Это самый распространённый тип связи в базах данных. Суть проста: одна запись в первой таблице может быть связана с несколькими записями во второй таблице, но запись во второй таблице связана только с одной записью в первой.
Классические примеры
Пользователи и заказы. Один пользователь может сделать много заказов, но каждый заказ принадлежит только одному пользователю.
Категории и товары. Одна категория может содержать много товаров, но каждый товар относится только к одной категории.
Авторы и статьи. Один автор может написать много статей, но у каждой статьи один автор.
Реализация в SQL
Давайте создадим таблицы для связи пользователей и заказов:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(50) DEFAULT 'pending',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Здесь user_id в таблице orders — это внешний ключ, который ссылается на id в таблице users. Параметр ON DELETE CASCADE означает, что при удалении пользователя все его заказы тоже удалятся автоматически.
Работа с данными
Добавим пользователя и несколько его заказов:
INSERT INTO users (name, email)
VALUES ('Алексей Иванов', 'alexey@example.com');
INSERT INTO orders (user_id, total_amount, status)
VALUES
(1, 1500.00, 'completed'),
(1, 2300.50, 'pending'),
(1, 890.00, 'completed');Чтобы получить все заказы пользователя вместе с его данными, используем JOIN:
SELECT
users.name,
users.email,
orders.id as order_id,
orders.total_amount,
orders.status
FROM users
INNER JOIN orders ON users.id = orders.user_id
WHERE users.id = 1;Работа в приложении
Если вы используете ORM, например TypeORM или Sequelize, связь один-ко-многим настраивается декларативно:
// TypeORM
@Entity()
class User {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column()
email: string;
@OneToMany(() => Order, order => order.user)
orders: Order[];
}
@Entity()
class Order {
@PrimaryGeneratedColumn()
id: number;
@Column('decimal')
totalAmount: number;
@Column()
status: string;
@ManyToOne(() => User, user => user.orders)
user: User;
@Column()
userId: number;
}Теперь можно легко получить пользователя со всеми его заказами:
const user = await userRepository.findOne({
where: { id: 1 },
relations: ['orders']
});
console.log(user.orders); // Массив всех заказов пользователя
Связь многие-ко-многим (Many-to-Many)
Связь многие-ко-многим немного сложнее. Здесь одна запись в первой таблице может быть связана с несколькими записями во второй таблице, и наоборот.
Типичные сценарии
Студенты и курсы. Один студент может записаться на несколько курсов, и на одном курсе может учиться много студентов.
Товары и теги. Один товар может иметь несколько тегов, и один тег может быть применён к нескольким товарам.
Пользователи и роли. Один пользователь может иметь несколько ролей, и одна роль может быть назначена нескольким пользователям.
Промежуточная таблица
В реляционных базах данных многие-ко-многим реализуется через промежуточную таблицу, которая содержит внешние ключи обеих связываемых таблиц.
Давайте создадим систему тегов для товаров:
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price DECIMAL(10, 2) NOT NULL,
description TEXT
);
CREATE TABLE tags (
id SERIAL PRIMARY KEY,
name VARCHAR(50) UNIQUE NOT NULL
);
CREATE TABLE product_tags (
product_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
PRIMARY KEY (product_id, tag_id),
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE
);Таблица product_tags — это и есть промежуточная таблица. Она связывает товары и теги. Обратите внимание на составной первичный ключ: комбинация product_id и tag_id должна быть уникальной, что предотвращает добавление одного и того же тега к товару дважды.
Добавление данных
-- Добавляем товары
INSERT INTO products (name, price, description)
VALUES
('MacBook Pro', 150000, 'Ноутбук для разработчиков'),
('iPhone 15', 80000, 'Смартфон последнего поколения'),
('iPad Air', 60000, 'Планшет для работы');
-- Добавляем теги
INSERT INTO tags (name)
VALUES
('Apple'),
('Электроника'),
('Для работы'),
('Премиум');
-- Связываем товары с тегами
INSERT INTO product_tags (product_id, tag_id)
VALUES
(1, 1), -- MacBook Pro - Apple
(1, 2), -- MacBook Pro - Электроника
(1, 3), -- MacBook Pro - Для работы
(1, 4), -- MacBook Pro - Премиум
(2, 1), -- iPhone 15 - Apple
(2, 2), -- iPhone 15 - Электроника
(2, 4); -- iPhone 15 - ПремиумЗапросы к данным
Получим все товары с определённым тегом:
SELECT products.*
FROM products
INNER JOIN product_tags ON products.id = product_tags.product_id
INNER JOIN tags ON product_tags.tag_id = tags.id
WHERE tags.name = 'Apple';Получим все теги для конкретного товара:
SELECT tags.*
FROM tags
INNER JOIN product_tags ON tags.id = product_tags.tag_id
WHERE product_tags.product_id = 1;Найдём товары, у которых есть два конкретных тега:
SELECT products.*
FROM products
WHERE id IN (
SELECT product_id
FROM product_tags
INNER JOIN tags ON product_tags.tag_id = tags.id
WHERE tags.name IN ('Apple', 'Для работы')
GROUP BY product_id
HAVING COUNT(DISTINCT tags.id) = 2
);ORM и многие-ко-многим
С ORM работать с такими связями становится проще:
@Entity()
class Product {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@Column('decimal')
price: number;
@ManyToMany(() => Tag, tag => tag.products)
@JoinTable({
name: 'product_tags',
joinColumn: { name: 'product_id' },
inverseJoinColumn: { name: 'tag_id' }
})
tags: Tag[];
}
@Entity()
class Tag {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@ManyToMany(() => Product, product => product.tags)
products: Product[];
}Работа с данными:
// Создаём товар с тегами
const product = new Product();
product.name = 'MacBook Pro';
product.price = 150000;
const tag1 = await tagRepository.findOne({ where: { name: 'Apple' } });
const tag2 = await tagRepository.findOne({ where: { name: 'Премиум' } });
product.tags = [tag1, tag2];
await productRepository.save(product);
// Получаем товар со всеми тегами
const productWithTags = await productRepository.findOne({
where: { id: 1 },
relations: ['tags']
});Промежуточная таблица с дополнительными данными
Иногда в промежуточной таблице нужно хранить не только связи, но и дополнительную информацию. Например, в системе курсов нам может понадобиться знать дату записи студента и его прогресс:
CREATE TABLE students (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL
);
CREATE TABLE courses (
id SERIAL PRIMARY KEY,
title VARCHAR(200) NOT NULL,
duration_hours INTEGER NOT NULL
);
CREATE TABLE enrollments (
id SERIAL PRIMARY KEY,
student_id INTEGER NOT NULL,
course_id INTEGER NOT NULL,
enrolled_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
progress INTEGER DEFAULT 0,
completed BOOLEAN DEFAULT false,
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE,
UNIQUE(student_id, course_id)
);Здесь enrollments — это уже не просто связующая таблица, а полноценная сущность со своими атрибутами.
В TypeORM такую связь нужно описывать явно:
@Entity()
class Enrollment {
@PrimaryGeneratedColumn()
id: number;
@ManyToOne(() => Student, student => student.enrollments)
student: Student;
@ManyToOne(() => Course, course => course.enrollments)
course: Course;
@Column()
enrolledAt: Date;
@Column()
progress: number;
@Column()
completed: boolean;
}
@Entity()
class Student {
@PrimaryGeneratedColumn()
id: number;
@Column()
name: string;
@OneToMany(() => Enrollment, enrollment => enrollment.student)
enrollments: Enrollment[];
}
@Entity()
class Course {
@PrimaryGeneratedColumn()
id: number;
@Column()
title: string;
@OneToMany(() => Enrollment, enrollment => enrollment.course)
enrollments: Enrollment[];
}Производительность и оптимизация
Индексы
Для внешних ключей всегда создавайте индексы. Это критично для производительности JOIN-запросов:
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_product_tags_product_id ON product_tags(product_id);
CREATE INDEX idx_product_tags_tag_id ON product_tags(tag_id);N+1 проблема
Это классическая проблема при работе со связями. Представьте, что вы получаете список из 100 пользователей, а затем для каждого загружаете его заказы отдельным запросом. Получается 101 запрос к базе данных.
Решение — использовать eager loading:
// Плохо - N+1 проблема
const users = await userRepository.find();
for (const user of users) {
user.orders = await orderRepository.find({ where: { userId: user.id } });
}
// Хорошо - один запрос с JOIN
const users = await userRepository.find({
relations: ['orders']
});Ленивая загрузка vs жадная загрузка
Не всегда нужно загружать все связанные данные. Если у пользователя тысячи заказов, а вам нужна только базовая информация о нём, не загружайте все заказы сразу. Делайте это по требованию:
// Загружаем только пользователя
const user = await userRepository.findOne({ where: { id: 1 } });
// Позже, если нужно, загружаем заказы
if (needOrders) {
const orders = await orderRepository.find({
where: { userId: user.id },
take: 10,
order: { createdAt: 'DESC' }
});
}Распространённые ошибки
Отсутствие ограничений целостности
Если не использовать FOREIGN KEY и ON DELETE CASCADE, можно получить "висячие" записи — заказы, которые ссылаются на несуществующих пользователей.
-- Плохо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2)
);
-- Хорошо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);Неправильный выбор типа связи
Иногда разработчики используют многие-ко-многим там, где достаточно один-ко-многим, усложняя структуру без необходимости. Или наоборот, пытаются обойтись один-ко-многим, хотя бизнес-логика требует многие-ко-многим.
Всегда анализируйте предметную область: может ли сущность А иметь несколько связей с сущностью Б, и наоборот?
Дублирование данных
Начинающие разработчики иногда дублируют данные вместо создания связей:
-- Плохо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_name VARCHAR(100),
user_email VARCHAR(100),
user_phone VARCHAR(20),
total_amount DECIMAL(10, 2)
);
-- Хорошо
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL,
total_amount DECIMAL(10, 2),
FOREIGN KEY (user_id) REFERENCES users(id)
);Когда денормализация оправдана
Несмотря на все преимущества нормализации, иногда имеет смысл немного денормализовать данные для производительности. Например, если вам постоянно нужно знать количество заказов пользователя, можно добавить счётчик:
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
email VARCHAR(100) UNIQUE NOT NULL,
orders_count INTEGER DEFAULT 0
);
-- Триггер для автоматического обновления счётчика
CREATE OR REPLACE FUNCTION update_orders_count()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
UPDATE users SET orders_count = orders_count + 1
WHERE id = NEW.user_id;
ELSIF TG_OP = 'DELETE' THEN
UPDATE users SET orders_count = orders_count - 1
WHERE id = OLD.user_id;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER orders_count_trigger
AFTER INSERT OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION update_orders_count();Но помните: денормализация — это компромисс между скоростью чтения и сложностью поддержки. Используйте её только там, где это действительно нужно.
Заключение
Понимание связей между таблицами — это фундамент работы с реляционными базами данных. Связь один-ко-многим покрывает большинство сценариев и проста в реализации. Многие-ко-многим требует промежуточной таблицы, но даёт гибкость в моделировании сложных отношений.
Ключевые моменты, которые стоит запомнить: всегда используйте внешние ключи для поддержания целостности данных, создавайте индексы для оптимизации запросов, будьте внимательны к N+1 проблеме и выбирайте правильный тип связи, исходя из бизнес-логики вашего приложения.
Приложение Кодик — это образовательная платформа для начинающих разработчиков, где вы найдете структурированные курсы по Python, JavaScript, HTML, CSS и другим технологиям программирования.
Мы создали активное сообщество в Telegram, где разработчики помогают друг другу решать задачи, делятся опытом и обсуждают новые технологии. Присоединяйтесь к Кодику, чтобы учиться программированию в комфортном темпе с поддержкой опытных наставников и единомышленников!
Практикуйтесь на реальных задачах, и со временем вы будете интуитивно понимать, какая структура данных лучше подходит для конкретной ситуации. Удачи в разработке!