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

Sources

Related topics