В чём разница между стратегиями загрузки 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 без изменения публичного контракта.