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, как работает пул соединений.