SQLAlchemySeniorTechnical

В чём разница между стратегиями загрузки lazy='select', lazy='joined' и lazy='selectin'?

lazy='select' — отдельный SELECT при обращении к атрибуту (N+1, не работает в async); lazy='joined' — один JOIN-запрос (рискует декартовым произведением для коллекций); lazy='selectin' — два запроса с IN-условием, рекомендован для async и больших коллекций.

Стратегии загрузки связанных объектов в SQLAlchemy

SQLAlchemy предоставляет несколько стратегий загрузки для relationship-атрибутов. Выбор стратегии напрямую влияет на количество SQL-запросов и производительность приложения. Ниже разобраны три ключевых варианта.

lazy='select' (по умолчанию)

При обращении к атрибуту впервые выполняется отдельный SELECT. Это классический «ленивый» режим.

from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(primary_key=True)
    name: Mapped[str]
    orders: Mapped[list["Order"]] = relationship(lazy="select")  # default

class Order(Base):
    __tablename__ = "orders"
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column()

# Синхронный код — работает:
with Session(engine) as session:
    users = session.execute(select(User)).scalars().all()
    for user in users:
        print(user.orders)  # N дополнительных SELECT-ов — N+1 проблема!

# В async-сессии lazy='select' ЗАПРЕЩЁН:
async with AsyncSession(engine) as session:
    users = (await session.execute(select(User))).scalars().all()
    # Следующая строка вызовет MissingGreenlet или greenlet_spawn error:
    # print(users[0].orders)  # ОШИБКА!

Lazy select — источник N+1 проблем. Для async ORM он фактически неприменим без явного eager-загрузчика.

lazy='joined' / joinedload

Выполняет LEFT OUTER JOIN к основному запросу. Все данные приходят в одном SQL-запросе.

from sqlalchemy.orm import joinedload

async with AsyncSession(engine) as session:
    stmt = (
        select(User)
        .options(joinedload(User.orders))
        .where(User.id == 1)
    )
    result = await session.execute(stmt)
    # unique() обязателен при joinedload с коллекциями — дедупликация строк после JOIN
    user = result.unique().scalar_one()
    print(user.orders)  # уже загружены, без дополнительных запросов

Плюс: 1 запрос. Минус: при больших коллекциях JOIN возвращает декартово произведение — если у 100 пользователей по 50 заказов, вернётся 5000 строк. Лучше для связей «один к одному» или небольших коллекций.

lazy='selectin' / selectinload

Выполняет два SELECT: первый загружает родительские объекты, второй — все дочерние объекты с WHERE parent_id IN (...).

from sqlalchemy.orm import selectinload

async with AsyncSession(engine) as session:
    stmt = (
        select(User)
        .options(selectinload(User.orders))
    )
    result = await session.execute(stmt)
    users = result.scalars().all()
    # 2 запроса суммарно, независимо от числа пользователей
    for user in users:
        print(user.orders)  # загружены без N+1

Оптимален для коллекций с умеренным числом элементов. Не раздувает результирующий набор, как JOIN.

Сравнительная таблица

  • lazy='select': N+1 запросов, не работает в async без явного eager, хорош только для случаев, когда коллекция нужна редко.
  • lazy='joined' / joinedload: 1 запрос с JOIN, риск декартова произведения при больших коллекциях, хорош для one-to-one и небольших many-to-one.
  • lazy='selectin' / selectinload: 2 запроса с IN-условием, безопасен для больших коллекций, рекомендован для async ORM.

Вложенная загрузка

from sqlalchemy.orm import selectinload, joinedload

# User -> orders -> items
stmt = (
    select(User)
    .options(
        selectinload(User.orders).selectinload(Order.items)
    )
)

# Смешанная: joinedload для ManyToOne, selectinload для коллекций
stmt = (
    select(Order)
    .options(
        joinedload(Order.user),        # один пользователь — JOIN OK
        selectinload(Order.items),     # коллекция — selectin лучше
    )
)

Подводные камни

  • MissingGreenlet в async: lazy='select' в asyncio вызывает sqlalchemy.exc.MissingGreenlet при попытке обратиться к незагруженному атрибуту. Всегда указывайте eager-загрузчик явно.
  • unique() после joinedload: result.scalars().all() после joinedload с коллекцией возвращает дубликаты. Нужно result.unique().scalars().all().
  • Декартово произведение: joinedload по нескольким коллекциям одновременно (orders и tags) создаёт произведение строк. Используйте selectinload для второй коллекции.
  • IN-список при selectin: при очень большом числе родительских объектов (>10 000) IN-запрос может стать медленным — проверяйте план запроса.
  • lazy на уровне модели vs опция запроса: настройка lazy= в relationship определяет поведение по умолчанию, но .options(selectinload(...)) перекрывает её для конкретного запроса. Предпочитайте явные опции запроса.
  • raiseload для безопасности: используйте lazy='raise' или raiseload('*') чтобы явно запретить ленивую загрузку и поймать N+1 на стадии разработки.
  • subqueryload устарел: в SQLAlchemy 2.0 subqueryload менее производителен, чем selectinload — не используйте его в новом коде.

Common mistakes

  • Описывать loading strategies только как термин и не показывать механизм на минимальном примере.
  • Игнорировать ошибки, пустые данные, конкурентный доступ или границы транзакции.
  • Не связывать поведение с официальным контрактом SQLAlchemy и реальной эксплуатацией.

What the interviewer is testing

  • Объясняет loading strategies через последовательность действий, а не через набор ключевых слов.
  • Приводит короткий кодовый пример или production-сценарий с ожидаемым поведением.
  • Называет хотя бы один риск: производительность, безопасность, транзакции, память или сопровождение.
  • Умеет обсудить отказ, наблюдаемость и rollback без изменения публичного контракта.

Sources

Related topics