SQLAlchemy и FastAPI: сессии, время жизни и типичные ловушки
Сессия SQLAlchemy в FastAPI — объект с неочевидным жизненным циклом. Неправильное управление приводит к утечкам соединений, грязным чтениям и ошибкам под нагрузкой. Разбираем, как устроена сессия изнутри, когда она должна закрываться и почему middleware — не всегда подходящий выбор.

Почему сессия — не просто обёртка над соединением
Когда FastAPI-сервис под нагрузкой начинает отдавать TimeoutError: QueuePool limit of 5 overflow 10 is reached, первое подозрение — мало соединений в пуле. Увеличивают pool_size, деплоят, через день получают то же самое. Проблема не в размере пула, а в том, что сессии не возвращают соединения вовремя. Или не возвращают вообще.
Сессия в SQLAlchemy (Session) — это Unit of Work: она накапливает изменения, отслеживает identity map загруженных объектов и управляет транзакцией. Соединение из пула она берёт лениво — при первом запросе к базе — и отдаёт обратно только после явного close() или при выходе из контекстного менеджера. Если сессию не закрыть, соединение «зависает» до сборки мусора, а в асинхронном мире это может не случиться долго.
Три способа привязать сессию к запросу
В экосистеме FastAPI + SQLAlchemy 2.x сложились три подхода. Каждый решает задачу «одна сессия на один HTTP-запрос», но с разной ценой.
Dependency с yield
Самый распространённый вариант. Сессия создаётся в генераторе-зависимости, передаётся в эндпоинт и гарантированно закрывается в блоке finally.
# Python 3.11+, SQLAlchemy 2.0+, FastAPI 0.100+
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker
from fastapi import Depends, FastAPI
engine = create_async_engine(
"postgresql+asyncpg://user:YOUR_PASSWORD@localhost:5432/mydb",
pool_size=20,
max_overflow=5,
)
async_session_factory = async_sessionmaker(engine, expire_on_commit=False)
async def get_session() -> AsyncGenerator[AsyncSession, None]:
async with async_session_factory() as session:
try:
yield session
await session.commit() # коммит, если эндпоинт не упал
except Exception:
await session.rollback()
raise
# close() вызывается автоматически при выходе из async with
app = FastAPI()
@app.get("/users/{user_id}")
async def read_user(user_id: int, session: AsyncSession = Depends(get_session)):
result = await session.get(User, user_id)
return result
Проверка: после каждого запроса в логах SQLAlchemy (echo=True) должна появиться строка ROLLBACK или COMMIT, а следом — возврат соединения в пул. Если видите только BEGIN без пары — сессия утекла.
Middleware
Сессия создаётся в middleware, кладётся в request.state и закрывается после ответа. Подход популярен в Django-мире (request.db), но в FastAPI несёт риск: если эндпоинт поднимает HTTPException, middleware может не дойти до блока очистки в зависимости от версии Starlette и порядка обработки исключений.
Контекстная переменная (ContextVar)
Сессия хранится в contextvars.ContextVar и доступна из любой функции без передачи через аргументы. Удобно для глубоких call-стеков, но отлаживать сложнее: непонятно, кто и когда записал значение. В тестах приходится вручную управлять контекстом.
| Критерий | Dependency + yield | Middleware | ContextVar |
|---|---|---|---|
| Гарантия закрытия | Да, через finally генератора | Зависит от обработки исключений | Нужно закрывать вручную |
| Явность | Сессия — параметр функции | Скрыта в request.state | Скрыта в переменной |
| Тестируемость | Подменяется через dependency_overrides | Нужен тестовый middleware | Нужен ручной контекст |
| Подходит для WebSocket | Да | Нет без доработки | Да |
Dependency с yield — единственный вариант, где FastAPI сам вызывает блок очистки и при успехе, и при ошибке, и при отмене запроса клиентом. Остальные подходы требуют ручной дисциплины, которая ломается в пограничных случаях.
expire_on_commit и ленивая загрузка: где теряются данные
По умолчанию expire_on_commit=True: после commit() все атрибуты загруженных объектов помечаются как устаревшие. При следующем обращении SQLAlchemy пытается сходить в базу — но если сессия уже закрыта, вы получаете DetachedInstanceError.
В синхронном коде это проявляется при сериализации Pydantic-моделей: commit() прошёл, объект вернулся из зависимости, Pydantic читает атрибуты — сессия закрыта, атрибут expired, исключение. В асинхронном коде ещё хуже: ленивая загрузка связей (relationship) невозможна в принципе — asyncio не может выполнить синхронный SQL-вызов внутри __getattr__.
Решения:
- Выставить
expire_on_commit=Falseв фабрике сессий — объекты сохраняют значения после коммита, но вы читаете снимок, а не актуальное состояние. - Загружать связи явно через
selectinload()илиjoinedload()в запросе, чтобы данные были в памяти до закрытия сессии. - Не возвращать ORM-объекты из эндпоинтов напрямую — конвертировать в Pydantic-схему внутри блока, пока сессия жива.
Пул соединений: что крутить и чего не трогать
Движок SQLAlchemy управляет пулом через параметры pool_size, max_overflow, pool_timeout, pool_recycle. Типичная ошибка — задрать pool_size до 100, не проверив лимит max_connections в PostgreSQL (по умолчанию 100). В итоге один процесс FastAPI способен занять все слоты базы, а второй воркер не подключится вовсе.
Формула для старта: pool_size = max_connections PostgreSQL / количество воркеров — 2–3 соединения «на хозяйственные нужды» (миграции, мониторинг). Для четырёх воркеров Uvicorn и max_connections=100 получается примерно 23 на воркер, с max_overflow=5.
pool_recycle нужен, если между приложением и базой стоит PgBouncer или облачный прокси, который режет idle-соединения. Ставьте значение меньше, чем таймаут прокси, иначе приложение попытается отправить запрос в уже закрытый сокет и получит OperationalError.
Транзакция на запрос или транзакция на операцию
Подход «commit в зависимости» (как в примере выше) означает: весь эндпоинт — одна транзакция. Это удобно и безопасно для CRUD, но в длинных обработчиках с внешними HTTP-вызовами транзакция держит соединение, пока ждёт ответа стороннего API. Под нагрузкой пул заканчивается, хотя база почти не работает.
Альтернатива — явные короткие транзакции внутри эндпоинта:
async def create_order(session: AsyncSession, order_data: OrderCreate):
# Первая транзакция: резервируем товар
async with session.begin():
item = await session.get(Item, order_data.item_id, with_for_update=True)
item.reserved += order_data.quantity
# Внешний вызов — соединение уже вернулось в пул
payment_result = await payment_gateway.charge(order_data.amount)
# Вторая транзакция: фиксируем заказ
async with session.begin():
order = Order(item_id=order_data.item_id, status=payment_result.status)
session.add(order)
Цена: если payment_gateway упал между транзакциями, товар зарезервирован, а заказ не создан. Нужна компенсирующая логика или фоновая задача, которая снимает «зависшие» резервы. Это сложнее, чем одна транзакция, зато пул не голодает.
Где подход ломается
- Background tasks.
BackgroundTasksв FastAPI выполняются после отправки ответа. К этому моменту сессия из dependency уже закрыта. Если фоновая задача обращается к базе, ей нужна собственная сессия, созданная внутри задачи. - WebSocket с долгим соединением. Одна сессия на весь WebSocket-канал — это соединение с базой, висящее минуты или часы. Создавайте сессию на каждое сообщение или группу сообщений.
- Многопоточность.
Sessionне потокобезопасна. Если вы запускаетеrun_in_executorс доступом к той же сессии, результат непредсказуем. Каждый поток — своя сессия. - Тесты с общей транзакцией. Паттерн «оберни тест в транзакцию и откати» ломается при
session.begin_nested()внутри тестируемого кода и при использованииasync with session.begin()— вложенныеbegin()выбрасывают ошибку, если внешняя транзакция уже активна.
Сноска: Identity map — внутренний словарь сессии, который хранит по одному экземпляру каждого загруженного объекта, идентифицируемого по первичному ключу. Благодаря ему два запроса session.get(User, 1) возвращают один и тот же Python-объект, а не два разных с одинаковыми данными. Это экономит память и гарантирует консистентность внутри одной сессии, но превращается в ловушку, если сессия живёт дольше, чем нужно: map растёт, а данные в нём устаревают.
Что делать дальше
- Включите
echo="debug"на staging и посмотрите, сколькоBEGINбезCOMMIT/ROLLBACKу вас за час. Каждый непарный — утечка. - Добавьте метрику
pool.checkedoutчерез событиеPoolEvents.checkoutи выведите её в Prometheus или аналог. Если число занятых соединений приближается кpool_size + max_overflow— ищите долгие транзакции. - Проверьте, что
expire_on_commit=Falseустановлен, если вы сериализуете ORM-объекты после коммита. - Перечитайте раздел «Session Basics» в официальной документации SQLAlchemy 2.0 — с версии 1.4 изменилось поведение
begin(),execute()и автоматического AUTOBEGIN.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: SQLAlchemy 2.0 Documentation — Session Basics, FastAPI Documentation — Dependencies with yield
Частые вопросы
Можно ли использовать одну сессию на несколько запросов подряд?
Что будет, если не закрыть сессию?
Зачем expire_on_commit=False, если это даёт устаревшие данные?
Как передать сессию в BackgroundTask?
У меня другая версия SQLAlchemy (1.4) — что изменится?
Как понять, что в проде утекают соединения?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.