Retour au cours

data / sqlalchemy

SQLAlchemy async

Leçon 111 exercice

Explication

Ce que vous allez apprendre

  • Comprendre pourquoi l'asynchrone rend du temps à l'application pendant une attente d'I/O
  • Créer un moteur et une session async avec create_async_engine/AsyncSession
  • Ajouter await sur chaque opération qui touche la base (execute, commit, get)
  • Comprendre pourquoi l'eager loading devient presque obligatoire en async
  • Éviter de partager une AsyncSession entre plusieurs tâches asyncio exécutées en parallèle

Dans quel contexte ?

Une API FastAPI à fort trafic sert des dizaines de requêtes simultanées, chacune interrogeant la base de données. En mode synchrone, chaque requête bloque un worker entier pendant l'attente de la réponse SQL, ce qui limite le nombre de requêtes traitées en parallèle aux nombres de workers disponibles. Passer à AsyncSession permet à un même worker de traiter d'autres requêtes pendant qu'il attend la base, sans changer la logique métier.

D'abord, le problème du temps d'attente bloqué

Dans un serveur web classique (synchrone), pendant qu'une requête attend une réponse de la base de données, tout le "worker" reste bloqué et ne peut traiter aucune autre demande pendant ce temps.

La solution : rendre ce temps mort à l'application

En asynchrone, ce temps d'attente est rendu à l'application, qui peut traiter d'autres requêtes entre-temps. Pour des applications à fort trafic avec beaucoup d'opérations d'entrée-sortie, cela permet de servir bien plus de requêtes avec les mêmes ressources.

Étape 1 : ce qui change concrètement dans le code

La version async (AsyncSession) reprend exactement les mêmes concepts que la session synchrone — unit of work, identity map, select(). La différence tient en un mot-clé : chaque opération qui touche la base (execute, commit, get) doit être précédée d'await, qui signale à Python "cette opération peut prendre du temps, laisse la main à d'autres tâches pendant ce temps".

SynchroneAsync
SessionAsyncSession
session.execute(stmt)await session.execute(stmt)
session.commit()await session.commit()
accès lazy à une relation possible hors sessionlève une erreur : eager loading requis

Un piège spécifique à l'async

En mode synchrone, un accès lazy à une relation non chargée (leçon 7) déclenche simplement une requête SQL supplémentaire, même après coup. En async, ce n'est pas possible : await ne peut pas être inséré automatiquement au milieu d'un accès normal à un attribut Python.

La conséquence directe

Tenter d'accéder à une relation lazy hors session lève une erreur en async. L'eager loading (selectinload, vu en leçon 7) devient donc presque obligatoire, pas seulement recommandé pour la performance comme en synchrone.

Un dernier piège à connaître

Une AsyncSession ne peut pas être partagée entre plusieurs tâches asyncio exécutées en parallèle (asyncio.gather) : chacune doit ouvrir la sienne, sous peine de corruption d'état interne.

Piège fréquent

Passer la MÊME instance d'AsyncSession à plusieurs coroutines lancées avec asyncio.gather() corrompt son état interne, car une session n'est pas conçue pour un accès concurrent. Ouvrez systématiquement une session distincte par tâche asynchrone.

Vers la suite

Après avoir vu comment exécuter des requêtes efficacement, la prochaine leçon change d'échelle : comment structurer tout un projet pour que la logique métier ne dépende plus directement de SQLAlchemy.

Commandes & code

SQLAlchemy async

python
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy import select
from sqlalchemy.orm import selectinload

# Driver async : asyncpg pour PostgreSQL, aiosqlite pour SQLite
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/technologik")
AsyncSessionLocal = async_sessionmaker(bind=engine, expire_on_commit=False)

async def creer_utilisateur(email: str, nom: str) -> Utilisateur:
    async with AsyncSessionLocal() as session:
        utilisateur = Utilisateur(email=email, nom=nom)
        session.add(utilisateur)
        await session.commit()
        await session.refresh(utilisateur)  # recharge les champs générés côté base
        return utilisateur

async def lister_actifs() -> list[Utilisateur]:
    async with AsyncSessionLocal() as session:
        stmt = select(Utilisateur).where(Utilisateur.est_actif == True)
        resultat = await session.execute(stmt)
        return list(resultat.scalars().all())

# Eager loading obligatoire en async : accéder à une relation lazy hors session lève une erreur
async def lister_auteurs_avec_articles() -> list[Auteur]:
    async with AsyncSessionLocal() as session:
        stmt = select(Auteur).options(selectinload(Auteur.articles))
        resultat = await session.execute(stmt)
        return list(resultat.scalars().all())

# Dependency FastAPI async
from collections.abc import AsyncGenerator

async def get_async_db() -> AsyncGenerator[AsyncSession, None]:
    async with AsyncSessionLocal() as session:
        yield session

# @app.get("/utilisateurs")
# async def route(db: AsyncSession = Depends(get_async_db)):
#     resultat = await db.execute(select(Utilisateur))
#     return resultat.scalars().all()

# Requêtes concurrentes : chaque tâche a besoin de SA PROPRE session (non partageable)
async def charger_en_parallele():
    async def charger_utilisateur(uid: int):
        async with AsyncSessionLocal() as session:
            return await session.get(Utilisateur, uid)

    resultats = await asyncio.gather(
        charger_utilisateur(1),
        charger_utilisateur(2),
        charger_utilisateur(3),
    )
    return resultats

# Transaction async explicite
async def transferer(source_id: int, dest_id: int, montant: float):
    async with AsyncSessionLocal() as session:
        async with session.begin():
            source = await session.get(Compte, source_id)
            dest = await session.get(Compte, dest_id)
            source.solde -= montant
            dest.solde += montant
            if source.solde < 0:
                raise ValueError("Solde insuffisant")   # déclenche un rollback automatique

# run_sync : exécuter du code synchrone (legacy) dans un contexte async
async def utiliser_api_synchrone():
    async with AsyncSessionLocal() as session:
        def operation_sync(sync_session):
            return sync_session.execute(select(Utilisateur)).scalars().all()
        return await session.run_sync(operation_sync)

Résumé

  • create_async_engine + AsyncSession nécessitent un driver async (asyncpg, aiosqlite).
  • Toute requête (execute, commit, get) doit être await-ée ; l'accès lazy à une relation hors session échoue -- charger en eager (selectinload).
  • Chaque tâche asyncio concurrente doit ouvrir sa propre AsyncSession : une session n'est pas partageable entre coroutines.
  • session.run_sync(fn) permet d'exécuter ponctuellement du code utilisant l'API synchrone dans un contexte async.

Exercices pratiques

1 disponible
1

Mission : une AsyncSession partagée qui corrompt des requêtes parallèles

Objectif : Diagnostiquer un bug de corruption causé par le partage d'une AsyncSession entre coroutines, et le corriger.

Contexte

Un développeur écrit une fonction charger_en_parallele qui ouvre UNE SEULE AsyncSession, puis la passe à trois coroutines lancées avec asyncio.gather() pour charger trois utilisateurs "en parallèle". En test manuel isolé ça semble marcher, mais sous charge réelle, l'application lève des erreurs internes incompréhensibles et retourne parfois des données mélangées entre utilisateurs.

Résoudre l’exercice →