Перейти к содержимому

Сложные связи в базах данных с использованием SQLAlchemy

Сложные связи в базах данных с использованием SQLAlchemy

Руководство по отношениям Many-to-Many, оптимизации запросов и решению проблемы N+1.

Навигация по уроку


1. Теория и Проблематика

Проблема N+1 запросов

Прежде чем писать код, важно понять главную "боль" работы с БД. Представьте, что вы загружаете список из 10 пользователей, и хотите показать их последние посты.

По умолчанию ORM работает "лениво". Сначала она делает 1 запрос, чтобы получить список юзеров. Затем, когда вы в цикле обращаетесь к user.posts, она делает еще по 1 запросу для каждого юзера.
Итог: 1 (юзеры) + 10 (посты) = 11 запросов. Если юзеров будет 1000, приложение "ляжет". Это и есть проблема N+1.

Связи один-ко-многим (One-to-Many)

Это самая частая связь. Один автор может написать много постов. В базе данных это решается просто: в таблицу posts добавляется колонка user_id (Foreign Key).

Пример реализации моделей:
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    posts = relationship("Post", back_populates="user")

class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    # Ссылка на таблицу users
    user_id = Column(Integer, ForeignKey("users.id"))
    # Объектная связь для удобства работы в Python
    user = relationship("User", back_populates="posts")

Параметр back_populates "связывает" атрибуты двух классов. Если вы добавите пост в user.posts, то у этого поста автоматически заполнится поле post.user.

2. Реализация Many-to-Many

Связь "Многие-ко-Многим" сложнее. Пример: Пост может иметь много Тегов, а один Тег может висеть на множестве постов. Напрямую связать две таблицы нельзя. Нам нужен посредник — Ассоциативная таблица (Association Table).

Шаг 1. Создание таблицы-посредника

Мы создаем таблицу, которая хранит только пары ID: post_id и tag_id. Обычно для неё не нужен отдельный класс-модель, достаточно объекта Table.

# models/association.py
from sqlalchemy import Table, Column, Integer, ForeignKey
from .database import Base

post_tag_association = Table(
    "post_tag_association",
    Base.metadata,
    Column("post_id", Integer, ForeignKey("posts.id"), primary_key=True),
    Column("tag_id", Integer, ForeignKey("tags.id"), primary_key=True)
)

Обратите внимание: мы делаем оба поля primary_key=True. Это создает составной первичный ключ, гарантируя, что один и тот же тег не привяжется к одному посту дважды.

Шаг 2. Настройка моделей

Теперь нужно "научить" модели Post и Tag использовать эту таблицу. Для этого в relationship добавляется параметр secondary.

class Post(Base):
    __tablename__ = "posts"
    id = Column(Integer, primary_key=True)
    title = Column(String)

    # Указываем таблицу-посредника через secondary
    tags = relationship(
        "Tag",
        secondary=post_tag_association,
        back_populates="posts"
    )

class Tag(Base):
    __tablename__ = "tags"
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True)

    # Зеркальная связь
    posts = relationship(
        "Post",
        secondary=post_tag_association,
        back_populates="tags"
    )

Шаг 3. Нюансы миграций (Alembic)

Осторожно с импортами!

Частая ошибка: вы создали таблицу post_tag_association, но Alembic создает пустую миграцию. Почему?

Alembic видит только те модели, которые импортированы при запуске. Если ваша ассоциативная таблица лежит в отдельном файле и нигде не импортируется в env.py, она будет проигнорирована.

Решение: Соберите все модели в models/__init__.py:

# models/__init__.py
from .user import User
from .post import Post
from .tag import Tag
# Обязательно импортируем ассоциативную таблицу!
from .association import post_tag_association

3. Оптимизация запросов

Чтобы избежать проблемы N+1, мы должны явно сказать SQLAlchemy: "Загрузи связанные данные сразу". Для этого есть разные стратегии.

Стратегии загрузки: joinedload vs selectinload

joinedload

Делает один огромный запрос с LEFT JOIN.

Где использовать: Идеально для связей Many-to-One (пост -> автор). Для списков может дублировать данные.

selectinload (Рекомендуется)

Делает два запроса: первый получает посты, второй — теги через WHERE post_id IN (...).

Где использовать: Идеально для коллекций One-to-Many и Many-to-Many.

Пример правильного запроса

Допустим, нам нужно получить список постов, их авторов и теги. Мы комбинируем стратегии: автора подтягиваем через JOIN, а теги — отдельным запросом.

from sqlalchemy.orm import selectinload, joinedload

def get_posts_optimized(session):
    stmt = (
        select(Post)
        .options(
            joinedload(Post.user),      # Автор загрузится сразу через JOIN
            selectinload(Post.tags)     # Теги загрузятся вторым эффективным запросом
        )
    )
    # Итог: всего 2 SQL-запроса на любой объем данных
    return session.scalars(stmt).all()

4. Практика и Best Practices

Работа с данными

SQLAlchemy позволяет работать со связями как с обычными списками Python. Добавление тега к посту выглядит очень естественно.

def add_tag_to_post(session, post_id, tag_name):
    post = session.get(Post, post_id)

    # Пытаемся найти тег или создаем новый
    tag = session.scalar(select(Tag).where(Tag.name == tag_name))
    if not tag:
        tag = Tag(name=tag_name)

    # МАГИЯ ORM: просто добавляем в список
    post.tags.append(tag)

    # SQLAlchemy сама сгенерирует INSERT в таблицу post_tag_association
    session.commit()

Транзакции и атомарность

Одна из самых больших ошибок новичков — делать commit() внутри вспомогательных функций. Коммит должен быть один, в самом конце бизнес-операции.

Антипаттерн ❌
def create_post(data):
    post = Post(...)
    session.add(post)
    session.commit()  # ПЛОХО: пост создался, а теги еще нет

def add_tags(post, tags):
    ...
    session.commit()

Правильный подход: функция выполняет всю логику (создание поста + привязка тегов), и только если всё прошло успешно, фиксирует транзакцию.

def create_full_post(session, title, tag_names):
    try:
        # 1. Создаем пост
        post = Post(title=title)
        session.add(post)

        # 2. Привязываем теги (в памяти)
        for name in tag_names:
            tag = Tag(name=name)
            post.tags.append(tag)

        # 3. Фиксируем ВСЁ сразу
        session.commit()
    except Exception:
        session.rollback()
        raise

Заключение

Используйте selectinload для коллекций (M2M), чтобы избежать тормозов.

Для M2M связей всегда создавайте явную ассоциативную таблицу (или модель).

Проверяйте импорты в env.py, если Alembic не видит изменений.

Вторник, 27 января 2026
Сложные связи в базах данных с использованием SQLAlchemy