Какая mental model SQLAlchemy важнее всего: unit of work, change tracking, query generation, migrations или generated client?
Центральная mental model — Unit of Work: сессия накапливает изменения и сбрасывает их единым батчем. Всё остальное (change tracking, query generation, migrations) — следствие или надстройка над этой концепцией.
Unit of Work — центральная mental model SQLAlchemy
Из всех концепций SQLAlchemy самой важной для практической работы является Unit of Work. Остальные — change tracking, query generation, migrations, generated client — либо производные от неё, либо вспомогательные инструменты. Понимание Unit of Work объясняет, почему ORM ведёт себя именно так, как ведёт, и предотвращает большую часть типичных ошибок.
Что такое Unit of Work
Unit of Work — это паттерн (описан Мартином Фаулером), при котором объект-сессия отслеживает все изменения, внесённые за время транзакции, и сбрасывает их в БД единым согласованным батчем в момент коммита или явного flush(). SQLAlchemy Session является классической реализацией этого паттерна.
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from models import User, Address
engine = create_engine("postgresql+psycopg2://user:pw@localhost/db")
with Session(engine) as session:
# 1. Загружаем объект — он теперь "tracked"
user = session.get(User, 42)
# 2. Меняем атрибут — изменение запоминается в identity map
user.name = "Alice"
# 3. Добавляем связанный объект
addr = Address(city="Berlin", user=user)
session.add(addr)
# 4. flush() превращает накопленные изменения в SQL
# (происходит автоматически перед коммитом)
session.flush()
# INSERT INTO addresses ... + UPDATE users SET name=... выполняются здесь
session.commit()
# транзакция фиксируется; identity map сбрасывается (expire_on_commit=True)
Change Tracking как следствие Unit of Work
SQLAlchemy отслеживает изменения атрибутов через инструментирование (instrumented attributes). Каждый столбец модели заменяется дескриптором, который при записи помечает объект как «грязный» (dirty) в сессии. Это позволяет автоматически генерировать UPDATE только для изменившихся столбцов без явного указания.
from sqlalchemy import inspect
with Session(engine) as session:
user = session.get(User, 42)
user.email = "new@example.com"
insp = inspect(user)
print(insp.attrs.email.history) # History(added=['new@example.com'], unchanged=(), deleted=['old@example.com'])
print(session.dirty) # {<User id=42>}
Query Generation — инструмент, а не суть
ORM-запросы через select(User).where(User.age > 30) удобны, но это лишь DSL над SQL. Главное, что query generation работает внутри той же сессии и возвращает объекты, уже помещённые в identity map. Если вы загрузили объект через get() и потом делаете select() с тем же PK — вы получаете тот же Python-объект из памяти, а не новый из БД.
Migrations и Generated Client — отдельные слои
Alembic (migrations) работает независимо от сессии и Unit of Work — он сравнивает метаданные моделей с реальной схемой БД и генерирует DDL-скрипты. Generated client (например, через automap_base() или sqlc) — ещё один вспомогательный инструмент для случаев, когда схема первична.
Почему именно Unit of Work важнее всего
- Объясняет, зачем нужен
session.add()и что происходит приsession.commit(). - Помогает понять
expire_on_commit=True: после коммита все атрибуты помечаются устаревшими, следующее обращение — новый SELECT. - Объясняет
DetachedInstanceError: объект вне сессии не может лениво загружать атрибуты. - Объясняет, почему два
session.get(User, 42)подряд возвращают один объект (identity map). - Помогает правильно выбрать границы транзакции и избежать «фантомных» запросов.
Подводные камни
- expire_on_commit: после коммита каждое обращение к атрибуту вне открытой сессии вызывает отдельный SELECT. В async-коде это приводит к
MissingGreenlet/greenlet_spawnошибкам. - Autoflush: SQLAlchemy делает
flush()перед каждым запросом по умолчанию (autoflush=True). Это ломает ожидания, когда вы не готовы к записи в этот момент. - Identity map и stale data: объект в памяти не обновляется автоматически, если другой процесс изменил строку в БД.
session.refresh(obj)решает проблему. - Область жизни сессии: сессия не является потокобезопасной и не должна шариться между запросами. В FastAPI это решается через
async_scoped_sessionили dependency injection. - Cascade без осознания:
cascade="all, delete-orphan"удалит дочерние записи при удалении родителя — это не всегда желательное поведение. - Слишком долгая сессия: держать открытую сессию через несколько HTTP-запросов — антипаттерн; identity map разрастается и данные стареют.
- Detached objects: передача ORM-объектов за пределы сессии без eager load всех нужных атрибутов приводит к
DetachedInstanceErrorв продакшне. - bulk operations обходят Unit of Work:
session.execute(update(User).values(...))не обновит уже загруженные в identity map объекты.
What hurts your answer
- Знать термины SQLAlchemy, но не понимать связи между абстракциями
- Объяснять поведение через отдельные примеры вместо причинной модели
- Не связывать mental model с диагностикой ошибок
What they're listening for
- Понимает ключевые абстракции SQLAlchemy
- Может предсказывать поведение системы через mental model
- Связывает модель с debugging и production decisions