Как в SQLAlchemy решить проблему N+1 запросов

Ответ

Проблема N+1 запросов возникает, когда ORM выполняет один запрос для получения основной коллекции объектов (1), а затем по одному дополнительному запросу для загрузки связанных данных для каждого из N объектов. Это приводит к значительному падению производительности.

В SQLAlchemy эта проблема решается с помощью стратегий немедленной загрузки (eager loading), которые указываются через опцию .options().

Основные стратегии:

  1. selectinload() (Рекомендуемый способ) Загружает связанные коллекции отдельным запросом, используя WHERE ... IN (...) для ID родительских объектов. Это самый эффективный способ для отношений "один-ко-многим".

    from sqlalchemy.orm import selectinload
    
    # Загрузит всех пользователей и одним доп. запросом все их адреса
    users = session.query(User).options(selectinload(User.addresses)).all()
  2. joinedload() Использует LEFT OUTER JOIN для загрузки связанных данных в том же запросе. Идеально подходит для отношений "многие-к-одному" или "один-к-одному". Может вызывать избыточность данных для коллекций.

    from sqlalchemy.orm import joinedload
    
    # Загрузит все комментарии и сразу присоединит их авторов (пользователей)
    comments = session.query(Comment).options(joinedload(Comment.user)).all()
  3. subqueryload() Работает аналогично selectinload, но использует подзапрос. В современных версиях SQLAlchemy selectinload обычно предпочтительнее.

Глобальная настройка

Стратегию загрузки можно задать по умолчанию в определении модели с помощью параметра lazy в relationship:

from sqlalchemy.orm import relationship

class User(Base):
    # ...
    # Всегда использовать `selectinload` при доступе к addresses
    addresses = relationship("Address", back_populates="user", lazy="selectin")

Ответ 18+ 🔞

Давай разберём эту классическую проблему, которая может превратить твоё приложение из шустрой ракеты в телегу с квадратными колёсами. Проблема N+1 запросов, ёпта!

Представь себе: ты запрашиваешь список пользователей — это один запрос. А потом в шаблоне или логике для каждого юзера ты обращаешься к его адресам (user.addresses). ORM, такая добрая душа, для КАЖДОГО пользователя делает ОТДЕЛЬНЫЙ запрос в базу, чтобы подтянуть его адреса. Получается 1 запрос на список + N запросов на адреса. Если пользователей 1000, то запросов будет 1001. Это пиздец, а не производительность. База захлебнётся, а приложение будет отвечать, как будто его сервера находятся на Марсе и связь через дозорного ворона.

К счастью, в SQLAlchemy с этим борются, как умеют. Есть штуки, которые называются стратегии немедленной загрузки (eager loading). Всё это хозяйство настраивается через .options() в запросе. Сейчас разжуём.

Основные приёмы, чтобы не выстрелить себе в ногу:

  1. selectinload() (Самый часто рекомендуемый) Это, блядь, почти волшебство. ORM сначала одним запросом вытащит всех пользователей, запомнит их ID, а потом ВТОРЫМ запросом вытащит ВСЕ адреса для этих ID, используя WHERE address.user_id IN (... список ID ...). Вместо 1001 запроса — всего 2. Красота! Идеально для отношений "один-ко-многим".

    from sqlalchemy.orm import selectinload
    
    # Загрузит всех юзеров и одним махом все их адреса
    users = session.query(User).options(selectinload(User.addresses)).all()
  2. joinedload() Старый добрый LEFT OUTER JOIN. Он присоединяет связанную таблицу прямо в основном запросе. Отлично работает для отношений "многие-к-одному" или "один-к-одному". Но вот для коллекций ("один-ко-многим") будь осторожен: если у одного пользователя 10 адресов, то в результатах будет 10 строк с дублирующейся информацией о пользователе. Может раздуть результат, но запрос всё равно один.

    from sqlalchemy.orm import joinedload
    
    # Загрузит все комменты и сразу прицепит к каждому автора
    comments = session.query(Comment).options(joinedload(Comment.user)).all()
  3. subqueryload() Похож на selectinload, но использует подзапрос. Раньше был в ходу, но сейчас selectinload чаще оказывается шустрее и надёжнее, особенно на больших данных. Но знать о его существовании не помешает.

А можно сделать это поведение по умолчанию?

А то можно! Чтобы не писать .options() в каждом втором запросе, можно прописать стратегию прямо в модели. Параметр lazy в relationship — твой друг.

from sqlalchemy.orm import relationship

class User(Base):
    # ...
    # Теперь при обращении к addresses всегда будет срабатывать selectinload
    addresses = relationship("Address", back_populates="user", lazy="selectin")

Вот и вся магия. Главное — не забывать об этом, а то потом сидишь и думаешь: "Чё это у меня всё так медленно, я же всего 1000 записей вывожу?". А там, оказывается, ORM втихаря настреляла ещё 100000 запросов. Пиздец, да и только.