сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Базы данных в Python

SQLAlchemy ORM

SQLAlchemy: два слоя и ленивые связи

SQLAlchemy - стандартный ORM Python-бэкенда, и собес по нему быстро упирается в две вещи: понимаешь ли ты разделение Core/ORM и знаешь ли, что связи по умолчанию ленивы - источник N+1 и DetachedInstanceError. Эти грабли ловят в проде постоянно.

Типовые формулировки: «чем Core отличается от ORM?», «почему список объектов делает кучу запросов?», «что за DetachedInstanceError».

Core и ORM, связи через relationship

SQLAlchemy двухслоен. Core - SQL-выражения поверх таблиц, близко к самому SQL. ORM - классы, маппленные на таблицы, с сессией и навигацией по связям; он построен НА Core. Понимание слоёв помогает спускаться к Core, когда ORM мешает выразить сложный запрос.

relationship() даёт объектную навигацию (author.books) поверх внешнего ключа; сам FK (foreign key) задаёт ForeignKey в колонке, а back_populates держит обе стороны связи синхронными.

// relationship это удобство навигации, а не сам внешний ключ; без ForeignKey в схеме навигировать нечему.

Core vs ORM
SQL-выражения против классов, маппленных на таблицы
relationship
объектная навигация между связанными записями

Ленивая загрузка: N+1 и Detached

По умолчанию связь ленива: SQL к ней уходит при ПЕРВОМ обращении. В цикле по объектам это классический N+1 - обращение к author.books на каждой итерации даёт запрос на каждого автора. Лечат жадной загрузкой: joinedload тянет связь одним JOIN (хорош для «многие к одному»), selectinload - вторым SELECT ... IN (хорош для коллекций, не раздувает строки).

Осторожно с joinedload на большой коллекции: JOIN «один ко многим» декартово размножает строки родителя - для «многих» бери selectinload. И вторая беда лени: обращение к связи ПОСЛЕ закрытия сессии - DetachedInstanceError, грузить уже нечем. Лечения - eager-загрузка заранее или expire_on_commit=False.

// Правило выбора: «многие к одному» → joinedload (один JOIN), «один ко многим»/коллекция → selectinload (второй запрос без дублей).

# N+1:
for a in session.query(Author):   # 1 запрос
    a.books                        # +N запросов
# fix — коллекция через selectinload:
select(Author).options(selectinload(Author.books))
joinedload/selectinload
жадные стратегии загрузки связей против N+1
DetachedInstanceError
доступ к ленивой связи вне живой сессии

Как отвечать: «Почему список объектов делает кучу запросов и как чинить?»

Это N+1 из-за ленивой загрузки связей. Первый запрос достаёт N объектов, а дальше на каждом при обращении к связи - author.books - SQLAlchemy шлёт отдельный запрос, итого N плюс один. В коде это незаметно, зато в логе SQL видно N однотипных селектов. Чиню жадной загрузкой заранее: для связи «многие к одному» - joinedload, он тянет одним JOIN; для коллекции «один ко многим» - selectinload, он делает второй SELECT ... IN и не раздувает строки декартовым произведением. joinedload на большой коллекции как раз и опасен этим раздуванием, поэтому там всегда selectinload.

Названа причина (ленивые связи), способ диагностики (лог SQL) и - главное - правильный выбор стратегии по типу связи с объяснением, почему joinedload плох на коллекциях.

На чём валят

  • Обращение к author.books в цикле при ленивой загрузке - запрос на каждого автора (N+1).
  • joinedload на большой коллекции размножает строки родителя (декартово) - для «многих» бери selectinload.
  • Вернуть объект со связями за пределы сессии и обратиться к ним - DetachedInstanceError.
  • Считать relationship внешним ключом: FK задаёт ForeignKey, relationship лишь навигирует.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 12, остальные разбираются в тренажёре.

  1. #orm_sqlalchemy1 / 5
    Что задаёт relationship() в модели SQLAlchemy?
    A)Правило каскадного удаления строк на стороне СУБД напрямую
    B)Физический внешний ключ и ограничение на уровне базы данных
    C)Навигацию между связанными объектами (author.books)
    D)Индекс по колонке для ускорения выборок по связи
    показать ответ и разбор
    +C)Навигацию между связанными объектами (author.books)

    // разбор: relationship() описывает объектную связь: через author.books SQLAlchemy подтянет связанные записи, а back_populates держит обе стороны синхронными. Физический внешний ключ задаёт ForeignKey на колонке; relationship — навигация поверх него на уровне объектов.

  2. #orm_sqlalchemy2 / 5
    По умолчанию связь author.books в SQLAlchemy загружается как?
    A)Лениво: отдельный запрос при первом обращении
    B)К связям приходится обращаться только через сырой SQL
    C)Один раз при старте приложения и дальше берётся из кэша
    D)Жадно: одним JOIN вместе с самим автором
    показать ответ и разбор
    +A)Лениво: отдельный запрос при первом обращении

    // разбор: По умолчанию relationship ленив: SQL к связанным записям уходит в момент обращения (author.books). Удобно, но в цикле по авторам даёт N+1. Жадные стратегии (joinedload, selectinload) грузят связь заранее и включаются явно в запросе.

  3. #orm_sqlalchemy3 / 5
    joinedload и selectinload — обе грузят связь заранее. В чём разница?
    A)joinedload делает один JOIN, selectinload — второй IN-запрос
    B)selectinload работает исключительно с базой PostgreSQL
    C)Разницы нет, это устаревший синоним одной и той же стратегии
    D)joinedload только для чтения, а selectinload — для записи связей
    показать ответ и разбор
    +A)joinedload делает один JOIN, selectinload — второй IN-запрос

    // разбор: joinedload тянет связь тем же запросом через JOIN — хорошо для «многие к одному». selectinload делает второй запрос SELECT ... WHERE fk IN (...) — лучше для коллекций («один ко многим»), потому что JOIN на коллекции размножает строки родителя (декартово раздувание).

  4. #orm_sqlalchemy4 / 5
    Объект получен в сессии, сессия закрыта, потом обращаемся к obj.books (ленивой связи). Что будет?
    A)Вернётся пустой список, раз данные не были загружены заранее
    B)Связь возьмётся из глобального кэша объектов приложения
    C)DetachedInstanceError — связь грузить уже нечем
    D)SQLAlchemy откроет новую сессию автоматически и догрузит связь
    показать ответ и разбор
    +C)DetachedInstanceError — связь грузить уже нечем

    // разбор: Ленивая связь грузится через сессию, к которой привязан объект. После закрытия/expunge объект detached, и обращение к незагруженной связи бросает DetachedInstanceError. Лечения: подгрузить заранее (eager), не выходить из сессии до использования, или expire_on_commit=False.

  5. #orm_sqlalchemy5 / 5
    Что делает ORM вроде SQLAlchemy?
    A)Отображает классы на таблицы, а объекты — на строки, пряча SQL
    B)Ускоряет запросы за счёт автоматического добавления индексов на все колонки
    C)Переводит SQL с диалекта одной базы на диалект другой без изменения кода приложения
    D)Заменяет базу данных, храня все объекты приложения прямо в оперативной памяти процесса
    показать ответ и разбор
    +A)Отображает классы на таблицы, а объекты — на строки, пряча SQL

    // разбор: ORM (Object-Relational Mapping) сопоставляет классы Python таблицам БД: атрибуты — колонкам, экземпляры — строкам, связи — внешним ключам. Вы работаете с объектами и методами, а ORM генерирует SQL. Это ускоряет разработку и уменьшает ручной SQL, но добавляет слой абстракции, который иногда полезно уметь обходить (сырой SQL для сложных запросов).

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.