SQLAlchemySeniorCoding
Как решить проблему N+1 запросов в SQLAlchemy с помощью selectinload или joinedload?
selectinload делает отдельный SELECT IN для коллекций, joinedload добавляет JOIN для one-to-one/many-to-one. При async-сессии ленивая загрузка невозможна — всегда задавайте eager-опции явно.
Проблема N+1 в SQLAlchemy
Проблема N+1 возникает, когда ORM сначала загружает список объектов (1 запрос), а затем для каждого объекта отдельно обращается к связанным данным (N запросов). При 1000 заказов это 1001 запрос вместо одного-двух.
Стратегии загрузки
SQLAlchemy 2.x предлагает несколько стратегий через функции-опции selectinload, joinedload, subqueryload и lazyload.
- selectinload — выполняет отдельный
SELECT … WHERE id IN (…)для коллекции. Не раздувает строки, безопасен для коллекций и many-to-many. - joinedload — добавляет
LEFT OUTER JOINк основному запросу. Хорош для загрузки единственного связанного объекта (many-to-one / one-to-one), но раздувает результат при коллекциях. - subqueryload — использует подзапрос; устарел, предпочитайте selectinload.
- lazyload — поведение по умолчанию, отправляет запрос при первом обращении к атрибуту (источник N+1).
Пример
from sqlalchemy import select
from sqlalchemy.orm import selectinload, joinedload
from sqlalchemy.ext.asyncio import AsyncSession
# selectinload: загружаем список заказов + их позиции
async def get_orders_with_items(session: AsyncSession):
stmt = (
select(Order)
.options(selectinload(Order.items)) # отдельный SELECT IN
.where(Order.status == "active")
)
result = await session.execute(stmt)
return result.scalars().all()
# joinedload: загружаем заказы + автора (many-to-one)
async def get_orders_with_user(session: AsyncSession):
stmt = (
select(Order)
.options(joinedload(Order.user)) # JOIN
.where(Order.status == "active")
)
result = await session.execute(stmt)
# scalars().unique() — важно при join, чтобы убрать дубли строк
return result.scalars().unique().all()
# Вложенная загрузка: orders -> items -> product
async def get_deep(session: AsyncSession):
stmt = (
select(Order)
.options(
selectinload(Order.items)
.joinedload(OrderItem.product)
)
)
result = await session.execute(stmt)
return result.scalars().all()
Диагностика N+1
Включите SQL-логирование и посчитайте запросы:
import logging
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
Или используйте sqlalchemy-utils / pytest-sqlalchemy-mock и считайте вызовы через event.listen на before_cursor_execute.
Когда что выбирать
- Коллекция (one-to-many, many-to-many) →
selectinload. - Единственный связанный объект (many-to-one) →
joinedload. - Большой граф с несколькими уровнями →
selectinloadна каждом уровне отдельно, чтобы не перемножать строки. - Нужен только один столбец связанного объекта →
contains_eagerпосле явногоjoin()или простоselect()с нужными столбцами.
Подводные камни
- joinedload на коллекциях дублирует строки родителя — без
.unique()получите повторы объектов в списке. - lazy="raise" в production — полезная защита, но сломает код, если забыть добавить опцию загрузки хоть для одного маршрута.
- selectinload с очень большим IN-списком (10 000+ id) может замедлить PostgreSQL из-за переполнения плана; разбивайте батчами или переходите на subqueryload.
- AsyncSession + lazyload бросает
MissingGreenlet— в async-коде ленивая загрузка физически невозможна безAsyncAttrsили явного eager-options. - contains_eager без явного join не работает — ORM не знает, что данные уже есть в строке результата.
- Вложенный selectinload × joinedload: порядок имеет значение — joinedload внутри selectinload работает, обратное может дать неожиданный SQL.
- expire_on_commit=True (по умолчанию): после коммита все атрибуты помечаются устаревшими, и следующее обращение снова вызовет SELECT — даже если вы уже загрузили данные через eager.
- Профилирование только в dev: убедитесь, что echo=True отключён в prod, иначе логирование каждого SQL-запроса само станет узким местом.
Common mistakes
- Описывать n plus one loading только как термин и не показывать механизм на минимальном примере.
- Игнорировать ошибки, пустые данные, конкурентный доступ или границы транзакции.
- Не связывать поведение с официальным контрактом SQLAlchemy и реальной эксплуатацией.
What the interviewer is testing
- Объясняет n plus one loading через последовательность действий, а не через набор ключевых слов.
- Приводит короткий кодовый пример или production-сценарий с ожидаемым поведением.
- Называет хотя бы один риск: производительность, безопасность, транзакции, память или сопровождение.
- Умеет обсудить отказ, наблюдаемость и rollback без изменения публичного контракта.