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

Async SQLAlchemy

Async SQLAlchemy: где ленивость взрывается

Async-версия SQLAlchemy - частый выбор нового FastAPI-сервиса, и у неё свои острые углы, которых нет в sync: MissingGreenlet на ленивой связи, обязательный async-драйвер, сессия на запрос. Собес проверяет, понимаешь ли ты, почему ленивая загрузка в async просто не работает.

Типовые формулировки: «что такое MissingGreenlet и почему?», «какой драйвер для async?», «можно ли шарить AsyncSession между задачами».

Async-движок и запрет ленивых связей

Async SQLAlchemy это create_async_engine с АСИНХРОННЫМ драйвером (asyncpg, не psycopg2) и AsyncSession, где execute, commit, get идут через await. Синхронный драйвер в async-стеке блокирует event loop, и вся конкурентность теряется это первая и главная ошибка.

Вторая - ленивые связи. Доступ к ленивому атрибуту это скрытый I/O, а в async его нельзя выполнить незаметно: получаешь MissingGreenlet. Поэтому связи грузят ЗАРАНЕЕ eager - selectinload или joinedload. И часто ставят expire_on_commit=False, чтобы обращение к объекту после commit не пыталось сходить в БД.

// Под капотом async-фасад стыкуется с синхронным ядром через greenlet - отсюда и имя ошибки MissingGreenlet при неявном I/O.

engine = create_async_engine('postgresql+asyncpg://...')
# ленивое a.books -> MissingGreenlet; грузи заранее:
await session.execute(select(Author).options(selectinload(Author.books)))
AsyncSession
сессия с await у операций ввода-вывода
MissingGreenlet
ошибка неявного I/O при ленивой связи в async

Сессия на запрос, не на всех

AsyncSession создают НА ЗАПРОС - через async_sessionmaker и зависимость с yield. Одну и ту же сессию нельзя шарить между конкурентными задачами: если раздать её в asyncio.gather нескольким корутинам, они полезут в неё разом и получат гонки на внутреннем состоянии. Каждой задаче - своя сессия.

Пул соединений в async тоже есть и работает так же. А синхронный код, которому нужно живое соединение - например, Alembic-миграции - мостят через conn.run_sync. И трезвая оценка: async окупается на I/O-нагрузке с высокой конкурентностью; при редких запросах выигрыш мал, а сложности (eager-связи, await везде) прибавляется.

// Практический вывод: не тащи async ради моды - он оправдан, когда реально много одновременного I/O.

asyncpg
асинхронный драйвер Postgres для async-движка
run_sync
мост для запуска синхронного кода на async-соединении

Как отвечать: «Почему ленивая связь в async SQLAlchemy падает?»

Потому что обращение к ленивому атрибуту это скрытый поход в БД, то есть I/O, а в async его нельзя выполнить незаметно, синхронно, посреди доступа к полю. Async-фасад SQLAlchemy стыкуется с синхронным ядром через greenlet, и когда ленивая загрузка пытается сделать неявный I/O вне этого контекста, я получаю MissingGreenlet. Лечение - грузить связи заранее, eager: selectinload для коллекций, joinedload для «многие к одному». Плюс обычно ставлю expire_on_commit=False, чтобы чтение объекта после commit не триггерило ещё один поход в базу. То есть в async весь I/O должен быть явным и заранее спланированным.

Объяснена причина через природу async (неявный I/O невозможен) и greenlet-мост, дано конкретное лечение eager + expire_on_commit и обобщён принцип «I/O явный» - глубокое понимание, а не заученный симптом.

На чём валят

  • Синхронный драйвер (psycopg2) в async-стеке: блокирует event loop, конкурентность теряется.
  • Ленивая связь в async: доступ к атрибуту даёт MissingGreenlet - грузи eager заранее.
  • Шарить одну AsyncSession между конкурентными задачами (gather) - гонки на состоянии сессии.
  • Тащить async ради моды: при редких запросах выигрыш мал, а сложность растёт.

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

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

  1. #async_sqlalchemy1 / 5
    В async-сессии обращение к ленивой связи объекта падает MissingGreenlet. Почему и как быть?
    A)Это баг SQLAlchemy: ленивая загрузка обязана работать в async точно так же, как в sync
    B)Нужно обернуть доступ к связи в await прямо в месте обращения к атрибуту объекта
    C)Ленивая подгрузка — скрытый I/O, в async так нельзя; связи грузят заранее (selectinload)
    D)Просто не хватает памяти под greenlet; лечится увеличением лимитов процесса приложения
    показать ответ и разбор
    +C)Ленивая подгрузка — скрытый I/O, в async так нельзя; связи грузят заранее (selectinload)

    // разбор: Ленивая загрузка выполняет SQL в момент доступа к атрибуту — это неявный I/O. В async его нельзя запустить из синхронного контекста доступа к полю, отсюда MissingGreenlet. Решение — грузить нужные связи заранее в запросе: selectinload/joinedload (eager). Реже — явный await session.refresh или AsyncAttrs. Правило: в async связи планируют вперёд, а не подтягивают по факту обращения.

  2. #async_sqlalchemy2 / 5
    Почему в async-приложениях у сессии часто ставят expire_on_commit=False?
    A)Флаг обязателен: без expire_on_commit=False async-сессия вообще не выполнит commit
    B)Иначе доступ к атрибуту после commit триггерит неявный I/O, недопустимый в async
    C)Это ускоряет commit, потому что при False транзакция фиксируется без записи на диск
    D)При True объекты после commit удаляются из сессии и теряются безвозвратно
    показать ответ и разбор
    +B)Иначе доступ к атрибуту после commit триггерит неявный I/O, недопустимый в async

    // разбор: По умолчанию expire_on_commit=True: после commit атрибуты истекают и при следующем доступе перечитываются из БД. В синхронном коде это просто лишний запрос, а в async доступ к полю не может незаметно выполнить await — будет ошибка неявного I/O. Поэтому в async обычно ставят expire_on_commit=False, чтобы объекты оставались годными после commit без скрытой подгрузки.

  3. #async_sqlalchemy3 / 5
    Можно ли одну AsyncSession использовать из нескольких одновременно запущенных задач (gather)?
    A)Можно, но лишь внутри одной транзакции; в разных её делить не выйдет
    B)Да, если все задачи только читают и ни одна из них ничего не пишет через сессию
    C)Нет: сессия не рассчитана на конкурентный доступ — каждой задаче своя сессия
    D)Да: AsyncSession потокобезопасна и спокойно обслуживает параллельные корутины разом
    показать ответ и разбор
    +C)Нет: сессия не рассчитана на конкурентный доступ — каждой задаче своя сессия

    // разбор: AsyncSession, как и обычная сессия, хранит изменяемое состояние (identity map, ожидающие операции, соединение) и не предназначена для конкурентного доступа. Запустив несколько корутин через gather на общей сессии, вы получите гонки и порчу состояния. Каждой параллельной единице работы — своя сессия (обычно из async_sessionmaker), а общий у них — пул движка.

  4. #async_sqlalchemy4 / 5
    Чем создают AsyncSession на каждый запрос в FastAPI?
    A)Прямым вызовом create_async_engine внутри каждого эндпоинта заново под конкретный запрос
    B)Обычным sessionmaker без async — FastAPI сам добавит асинхронность поверх него
    C)async_sessionmaker, а сессию отдают зависимостью с yield (setup/teardown)
    D)Одним глобальным экземпляром AsyncSession, создаваемым при старте приложения на всё время
    показать ответ и разбор
    +C)async_sessionmaker, а сессию отдают зависимостью с yield (setup/teardown)

    // разбор: Движок и async_sessionmaker создают один раз на приложение (пул соединений живёт там). На каждый запрос сессию выдаёт зависимость-генератор: async with sessionmaker() as session: yield session — код до yield открывает сессию, после (finally) закрывает/откатывает. Так каждый запрос получает свежую изолированную AsyncSession, а соединения переиспользуются из общего пула.

  5. #async_sqlalchemy5 / 5
    Нужно выполнить операцию, у которой нет async-варианта (например, часть работы Alembic). Как из async-движка?
    A)Через conn.run_sync(...): SQLAlchemy прогонит синхронный код поверх async-соединения
    B)Никак: из async-движка синхронные операции не выполнить
    C)Просто вызвать синхронную функцию напрямую — async-соединение поймёт её автоматически
    D)Открыть рядом второй, синхронный движок к той же базе и выполнить операцию через него
    показать ответ и разбор
    +A)Через conn.run_sync(...): SQLAlchemy прогонит синхронный код поверх async-соединения

    // разбор: Часть API (например, metadata.create_all или операции Alembic) синхронна. Из async-движка её запускают через await conn.run_sync(sync_callable): SQLAlchemy исполнит переданную синхронную функцию поверх async-соединения, обеспечив мост греенлетом. Это штатный способ переиспользовать sync-only код в async-стеке без второго движка.

дальше

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

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