← Назад к вопросам

Какие типы связей, такие как one-to-many, many-to-many и другие, ты бы избегали использовать в проектах, и почему?

2.7 Senior🔥 101 комментариев
#Архитектура и паттерны#Базы данных (SQL)

Комментарии (1)

🐱
claude-haiku-4.5PrepBro AI22 мар. 2026 г.(ред.)

Ответ сгенерирован нейросетью и может содержать ошибки

Типы связей БД: что избегать и почему

В реляционных БД существуют разные типы связей между таблицами. Но не все одинаково полезны в production. Вот мой опыт, что стоит избегать.

Обзор основных типов связей

  1. One-to-One (1:1) — один к одному
  2. One-to-Many (1:N) — один ко многим
  3. Many-to-Many (N:M) — много к много
  4. Self-referencing — связь с собой
  5. Polymorphic — полиморфные связи

ИЗБЕГАЙ: Глубокая вложенность связей (3+ уровня)

# Модель с глубокой вложенностью
class Country(Base):
    __tablename__ = "countries"
    id = Column(Integer, primary_key=True)
    cities = relationship("City", back_populates="country")

class City(Base):
    __tablename__ = "cities"
    id = Column(Integer, primary_key=True)
    country_id = Column(Integer, ForeignKey("countries.id"))
    country = relationship("Country", back_populates="cities")
    streets = relationship("Street", back_populates="city")

class Street(Base):
    __tablename__ = "streets"
    id = Column(Integer, primary_key=True)
    city_id = Column(Integer, ForeignKey("cities.id"))
    city = relationship("City", back_populates="streets")
    buildings = relationship("Building", back_populates="street")

class Building(Base):
    __tablename__ = "buildings"
    id = Column(Integer, primary_key=True)
    street_id = Column(Integer, ForeignKey("streets.id"))
    street = relationship("Street", back_populates="buildings")

# Запрос одного здания требует джойнов через все уровни
building = session.query(Building).filter(Building.id == 1).first()
print(building.street.city.country.name)  # N+1 проблема!

Почему избегать:

  • N+1 query problem: каждый уровень генерирует отдельный запрос
  • Медленно: 4 уровня = 4 запроса к БД вместо 1
  • Сложно отлаживать: трудно понять, какой запрос медленный
  • Жесткая архитектура: сложно изменять структуру

Решение: используй denormalization или flat структуру

# ЛУЧШЕ: Добавь необходимые id сразу
class Building(Base):
    __tablename__ = "buildings"
    id = Column(Integer, primary_key=True)
    street_id = Column(Integer, ForeignKey("streets.id"))
    city_id = Column(Integer, ForeignKey("cities.id"))  # Денормализация
    country_id = Column(Integer, ForeignKey("countries.id"))  # Денормализация
    
    street = relationship("Street")
    city = relationship("City")
    country = relationship("Country")

# Теперь один запрос с JOINS
building = session.query(Building).options(
    joinedload(Building.country),
    joinedload(Building.city),
    joinedload(Building.street)
).filter(Building.id == 1).first()

ИЗБЕГАЙ: Many-to-Many без явной промежуточной таблицы

# SQLAlchemy association table (скрытая таблица)
association_table = Table(
    'user_role',
    Base.metadata,
    Column('user_id', Integer, ForeignKey('users.id')),
    Column('role_id', Integer, ForeignKey('roles.id'))
)

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    roles = relationship("Role", secondary=association_table)

class Role(Base):
    __tablename__ = "roles"
    id = Column(Integer, primary_key=True)

# Проблема: если нужны дополнительные данные о связи
# Например, дату назначения роли
# Нельзя добавить, так как таблица скрытая!

Почему избегать:

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

Решение: явная промежуточная таблица (association object)

# ЛУЧШЕ: Явная промежуточная таблица
class UserRole(Base):
    __tablename__ = "user_role"
    
    user_id = Column(Integer, ForeignKey("users.id"), primary_key=True)
    role_id = Column(Integer, ForeignKey("roles.id"), primary_key=True)
    assigned_at = Column(DateTime, default=datetime.utcnow)  # Метаданные связи
    assigned_by = Column(String)  # Ещё метаданные
    
    user = relationship("User", back_populates="user_roles")
    role = relationship("Role", back_populates="user_roles")

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    user_roles = relationship("UserRole", back_populates="user")
    
    @property
    def roles(self):
        return [ur.role for ur in self.user_roles]

# Теперь можешь добавлять метаданные
user_role = UserRole(user_id=1, role_id=2, assigned_at=datetime.utcnow())
session.add(user_role)
session.commit()

ИЗБЕГАЙ: Самоссылающиеся связи для иерархий (без LTREE)

# Наивная иерархия: категория имеет родительскую категорию
class Category(Base):
    __tablename__ = "categories"
    id = Column(Integer, primary_key=True)
    name = Column(String)
    parent_id = Column(Integer, ForeignKey("categories.id"), nullable=True)
    
    parent = relationship("Category", remote_side=[id], backref="children")

# Запрос всех потомков требует рекурсии
def get_all_descendants(category):
    descendants = []
    for child in category.children:
        descendants.append(child)
        descendants.extend(get_all_descendants(child))  # Рекурсия = медленно
    return descendants

# Для глубокой иерархии (10+ уровней) это O(2^n) запросов!

Почему избегать:

  • Экспоненциальный рост запросов: глубокая иерархия = много запросов
  • Сложно: нельзя просто выбрать подмножество иерархии
  • Медленно: каждый уровень требует отдельного запроса

Решение: используй PostgreSQL LTREE или материализованные пути

# PostgreSQL с LTREE расширением
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.ext.declarative import declarative_base

class Category(Base):
    __tablename__ = "categories"
    id = Column(Integer, primary_key=True)
    name = Column(String)
    path = Column(String)  # Пример: '1.2.5.10' (материализованный путь)
    
    @classmethod
    def get_all_descendants(cls, category_id):
        # SQL: SELECT * FROM categories WHERE path ~ '1.2.5.*'
        # Один запрос вместо рекурсии!
        from sqlalchemy import func
        return session.query(cls).filter(
            cls.path.like(f"{category_id}.%")
        ).all()

# Альтернатива: Closure table
class CategoryClosure(Base):
    __tablename__ = "category_closure"
    ancestor_id = Column(Integer, ForeignKey("categories.id"), primary_key=True)
    descendant_id = Column(Integer, ForeignKey("categories.id"), primary_key=True)
    depth = Column(Integer)  # Глубина в иерархии
    
# Один запрос для всех потомков
query = session.query(Category).join(
    CategoryClosure,
    CategoryClosure.descendant_id == Category.id
).filter(CategoryClosure.ancestor_id == parent_category_id).all()

ИЗБЕГАЙ: Полиморфные связи (без правильной архитектуры)

# Наивная полиморфная связь: "комментарий может быть к посту или к видео"
class Comment(Base):
    __tablename__ = "comments"
    id = Column(Integer, primary_key=True)
    content = Column(String)
    commentable_type = Column(String)  # 'Post' или 'Video'
    commentable_id = Column(Integer)  # id поста или видео

# Проблемы:
# 1. Нет foreign key constraint (БД не может проверить целостность)
# 2. Невозможно быстро найти все комментарии к посту
# 3. Удаление поста не удалит комментарии (orphan records)

Почему избегать:

  • Нет référential integrity: комментарий может ссылаться на несуществующий пост
  • Грязные данные: orphan records (комментарии без родителя)
  • Медленные запросы: нельзя использовать индексы эффективно

Решение: таблица на тип или Single Table Inheritance

# ЛУЧШЕ: Отдельная таблица на тип (Table Per Type)
class Comment(Base):
    __tablename__ = "comments"
    id = Column(Integer, primary_key=True)
    content = Column(String)
    type = Column(String)  # Discriminator
    __mapper_args__ = {'polymorphic_on': type}

class PostComment(Comment):
    __tablename__ = "post_comments"
    id = Column(Integer, ForeignKey("comments.id"), primary_key=True)
    post_id = Column(Integer, ForeignKey("posts.id"))  # Правильная FK!
    post = relationship("Post", backref="comments")
    __mapper_args__ = {'polymorphic_identity': 'post'}

class VideoComment(Comment):
    __tablename__ = "video_comments"
    id = Column(Integer, ForeignKey("comments.id"), primary_key=True)
    video_id = Column(Integer, ForeignKey("videos.id"))
    video = relationship("Video", backref="comments")
    __mapper_args__ = {'polymorphic_identity': 'video'}

# Теперь у нас есть FK constraints!

ИЗБЕГАЙ: Circular dependencies (циклические зависимости)

# Циклическая зависимость
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    created_by_id = Column(Integer, ForeignKey("users.id"))  # Кого создал
    manager_id = Column(Integer, ForeignKey("users.id"))  # Менеджер
    
    created_by = relationship("User", remote_side=[created_by_id], backref="created_users")
    manager = relationship("User", remote_side=[manager_id], backref="subordinates")

# Проблема при удалении
# Удаляю user A
# Его subordinates ссылаются на него
# Не могу удалить с ON DELETE CASCADE (что если subordinates критичны?)

Почему избегать:

  • Сложная логика удаления: непонятно, что удалять каскадно
  • Инварианты: сложные constraints для сохранения целостности
  • Performance: сложные запросы с циклическими джойнами

Решение: явные связи, логическое удаление

# ЛУЧШЕ: Явные связи
class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    name = Column(String)
    is_deleted = Column(Boolean, default=False)  # Логическое удаление
    
    manager_id = Column(Integer, ForeignKey("users.id"), nullable=True)
    manager = relationship(
        "User",
        remote_side=[manager_id],
        foreign_keys=[manager_id]
    )

class UserAudit(Base):
    __tablename__ = "user_audit"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"))
    action = Column(String)  # 'created', 'modified'
    actor_id = Column(Integer, ForeignKey("users.id"))
    created_at = Column(DateTime)

# Теперь история отделена, нет циклических зависимостей

ИЗБЕГАЙ: Много foreign keys в одной таблице

# Таблица с слишком много связями
class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"))
    product_id = Column(Integer, ForeignKey("products.id"))
    seller_id = Column(Integer, ForeignKey("users.id"))  # Продавец (другой user)
    warehouse_id = Column(Integer, ForeignKey("warehouses.id"))
    courier_id = Column(Integer, ForeignKey("couriers.id"))
    payment_method_id = Column(Integer, ForeignKey("payment_methods.id"))
    # ...
    # 10-15 foreign keys = сложность и медленные миграции

Почему избегать:

  • Миграции медленнее: добавление FK требует проверки целостности
  • Запросы сложные: много джойнов = медленнее
  • Изменения дорогие: изменить структуру = переделать много FK

Решение: разделение на несколько таблиц, денормализация

# ЛУЧШЕ: Разделение логики
class Order(Base):
    __tablename__ = "orders"
    id = Column(Integer, primary_key=True)
    user_id = Column(Integer, ForeignKey("users.id"))  # Покупатель
    product_id = Column(Integer, ForeignKey("products.id"))
    status = Column(String)  # 'pending', 'shipped', 'delivered'

class OrderShipment(Base):
    __tablename__ = "order_shipments"
    id = Column(Integer, primary_key=True)
    order_id = Column(Integer, ForeignKey("orders.id"))
    warehouse_id = Column(Integer, ForeignKey("warehouses.id"))
    courier_id = Column(Integer, ForeignKey("couriers.id"))

class OrderPayment(Base):
    __tablename__ = "order_payments"
    id = Column(Integer, primary_key=True)
    order_id = Column(Integer, ForeignKey("orders.id"))
    payment_method_id = Column(Integer, ForeignKey("payment_methods.id"))

# Теперь логика разделена, каждая таблица отвечает за свое

Сравнение

Тип связиИСПОЛЬЗУЙИЗБЕГАЙПочему
One-to-ManyГлубокая вложенностьN+1 проблема
Many-to-ManyСкрытая таблицаНевозможно расширить
Self-referencingБез LTREE/closureМедленно для иерархий
PolymorphicБез FK constraintsOrphan records
Много FK⚠️> 10 FK в таблицеСложность и медленность

Правила проектирования

ДА:

  1. Используй ORM relationships с lazy loading контролем
  2. Явные промежуточные таблицы для M2M
  3. Денормализация где нужна производительность
  4. Логическое удаление вместо каскадного
  5. Separation of concerns (разные таблицы для разной логики)

НЕТ:

  1. Глубокие иерархии без LTREE
  2. Полиморфизм без FK constraints
  3. Слишком много FK в одной таблице
  4. Циклические зависимости
  5. Магические строки в discriminator fields

Мой approach

В production я обычно:

  1. 1:N максимум 2-3 уровня вложенности
  2. M:M всегда с явной промежуточной таблицей
  3. Иерархии с LTREE или closure tables
  4. Полиморфизм с Table Per Type или Single Table Inheritance
  5. Денормализация где нужна скорость (cached fields, materialized views)

В сомнениях выбираю простоту и производительность вместо "правильности" нормализации.