Связи в базах данных: один-ко-многим и многие-ко-многим

Разбираемся с фундаментальными типами связей в реляционных базах данных. Узнайте, как правильно проектировать связи один-ко-многим и многие-ко-многим, создавать промежуточные таблицы, работать с внешними ключами и оптимизировать запросы. Примеры на SQL и TypeORM, разбор типичных ошибок и best practices для разработчиков.

РазработкаSQL

6 мин

Зачем вообще нужны связи?

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

Во-первых, вы будете дублировать данные. Если у пользователя изменится 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, где разработчики помогают друг другу решать задачи, делятся опытом и обсуждают новые технологии. Присоединяйтесь к Кодику, чтобы учиться программированию в комфортном темпе с поддержкой опытных наставников и единомышленников!

Практикуйтесь на реальных задачах, и со временем вы будете интуитивно понимать, какая структура данных лучше подходит для конкретной ситуации. Удачи в разработке!

Комментарии