Урок по треку Python для веба: Django и FastAPI 8 мин

SQLAlchemy 2.0: Session, стратегии загрузки и async

Engine и Session, select-стиль 2.0, связи и стратегии загрузки, async-сессии и пул соединений.

SQLAlchemy это стандартный ORM вне Django: FastAPI, Flask, Litestar, скрипты и data-сервисы. Версия 2.0 изменила стиль запросов и типизацию, поэтому на собесе ценят, если вы пишете «по-новому» и понимаете Session и ленивую загрузку.

Engine, соединения, Session

  • Engine знает, как подключаться к базе, и держит пул соединений. Создаётся один раз на приложение.
  • Session это единица работы: отслеживает загруженные объекты (identity map), копит изменения и отправляет их в БД при flush и commit. Живёт недолго: обычно одна сессия на запрос или задачу.
engine = create_engine('postgresql+psycopg://...', pool_size=10, max_overflow=5)
SessionLocal = sessionmaker(engine, expire_on_commit=False)

with SessionLocal() as session, session.begin():   # commit или rollback сам
    session.add(User(email='a@b.ru'))

Модели в стиле 2.0

class Base(DeclarativeBase):
    pass

class User(Base):
    __tablename__ = 'users'
    id: Mapped[int] = mapped_column(primary_key=True)
    email: Mapped[str] = mapped_column(unique=True)
    orders: Mapped[list['Order']] = relationship(back_populates='user')

class Order(Base):
    __tablename__ = 'orders'
    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey('users.id'))
    user: Mapped[User] = relationship(back_populates='orders')

Аннотации Mapped[...] дают и схему (nullable выводится из Optional), и типы для mypy.

Запросы: select()

stmt = select(User).where(User.email.ilike('%@corp.ru')).order_by(User.id).limit(20)
users = session.scalars(stmt).all()           # список User
user = session.get(User, 42)                  # по первичному ключу, сначала identity map
row = session.execute(select(User.id, func.count(Order.id))
                      .join(User.orders).group_by(User.id)).all()

Старый session.query(User).filter(...) работает, но считается legacy. scalars() разворачивает строки из одной колонки или сущности.

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

По умолчанию связи грузятся лениво (lazy='select'): обращение к user.orders выполняет запрос. В цикле по пользователям это N+1. Стратегии загрузки:

select(User).options(selectinload(User.orders))     # второй запрос WHERE user_id IN (...)
select(Order).options(joinedload(Order.user))       # JOIN в том же запросе
select(User).options(raiseload('*'))                # любая ленивая загрузка бросит ошибку
  • selectinload для коллекций (аналог prefetch_related).
  • joinedload для связей «к одному» (аналог select_related). Для коллекций он размножает строки, и с ним нужен .unique().
  • raiseload превращает незамеченный N+1 в явную ошибку: удобно в тестах и async-коде.

Async

engine = create_async_engine('postgresql+asyncpg://...')
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async with SessionLocal() as session:
    users = (await session.scalars(select(User).options(selectinload(User.orders)))).all()

Главное правило async: ленивая загрузка не работает. Обращение к незагруженной связи пытается сделать синхронный запрос и падает с ошибкой MissingGreenlet. Поэтому в async всё нужное грузят заранее (selectinload), а после commit не трогают «протухшие» атрибуты (expire_on_commit=False или явное refresh). Одну AsyncSession нельзя использовать из нескольких задач одновременно.

Транзакции и flush

  • flush() отправляет накопленные изменения в БД внутри текущей транзакции (например, чтобы получить id), commit() фиксирует транзакцию.
  • После commit() объекты по умолчанию помечаются устаревшими и при следующем обращении перечитываются (expire_on_commit=True).
  • Блокировка строк: select(Account).where(...).with_for_update().

Частые ошибки

  • Одна глобальная Session на всё приложение или на несколько потоков.
  • Ленивая загрузка в цикле (N+1) и в async-коде (MissingGreenlet).
  • joinedload для коллекций без unique() и с пагинацией через limit.
  • Забытый commit или rollback и «висящая» транзакция, держащая соединение из пула.
  • Возврат ORM-объектов из закрытой сессии с незагруженными связями.

Что спросят на собесе

  • Чем Engine отличается от Session, сколько живёт Session.
  • Как писать запросы в стиле 2.0.
  • Как бороться с N+1: selectinload против joinedload.
  • Что меняется в async, откуда MissingGreenlet.
  • Что делают flush, commit, expire_on_commit, как работает пул соединений.

Закрепите за 5 минут

После урока в тренажёре 3-5 проверочных вопросов, дальше модуль уходит в повторения.