Retour au cours

backend / fastapi

SQLAlchemy asynchrone

Leçon 101 exercice

Explication

Ce que vous allez apprendre

  • Configurer un moteur et une session SQLAlchemy asynchrones avec asyncpg
  • Comprendre pourquoi une opération DB bloquante gèle tout l'event loop, pas qu'une requête
  • Précharger explicitement une relation en async, faute de lazy loading implicite
  • Gérer une transaction explicite avec async with db.begin()
  • Repérer et déporter du code bloquant caché avec run_in_threadpool

Dans quel contexte ?

Le service a migré vers des routes async def pour gérer plus de trafic simultané, mais l'équipe SRE remarque que le temps de réponse moyen se dégrade brutalement sous charge, bien plus qu'attendu. En creusant, un développeur a laissé un time.sleep(2) de débogage dans app/services/legacy_import.py, appelé depuis une route asynchrone. Ce genre de code bloquant caché dans une route async def gèle tout l'event loop pendant 2 secondes, ralentissant TOUTES les requêtes simultanées du serveur, pas seulement celle qui l'a déclenché.

Pourquoi une version asynchrone existe

La leçon précédente utilisait une session SQLAlchemy synchrone. Chaque requête vers la base de données BLOQUE le thread qui l'exécute jusqu'à ce que la réponse arrive.

Dans une route async def, ce blocage est particulièrement problématique. Il gèle tout l'event loop, potentiellement TOUTES les requêtes simultanées du serveur, pas seulement la requête en cours.

La version asynchrone corrige exactement ça. Avec AsyncSession et un driver compatible comme asyncpg, chaque opération réseau vers la base est précédée d'un await.

Pendant que la base traite la requête, l'event loop est libre de s'occuper d'autres requêtes HTTP en attente. C'est ce qui permet à un serveur FastAPI de gérer un grand nombre de connexions simultanées avec peu de ressources.

Ce changement a une conséquence importante à connaître : la disparition du lazy loading implicite. En SQLAlchemy synchrone, accéder à une relation non chargée déclenche automatiquement une requête supplémentaire.

En async, ce mécanisme implicite n'existe plus. Il faudrait un await que Python ne peut pas insérer tout seul au moment de l'accès à un attribut.

Il faut donc précharger explicitement les relations dont on aura besoin, avec selectinload par exemple. Et ce, AVANT de quitter la session, sinon l'accès à la relation échoue.

Il reste un dernier piège à connaître, le plus dangereux de tous : le code bloquant caché. Le risque principal en async n'est pas l'oubli d'un await, qui provoque en général une erreur visible.

C'est plutôt l'inverse : appeler par inadvertance du code SYNCHRONE et bloquant, comme un time.sleep, à l'intérieur d'une route async def. Ce genre de bug est sournois car il ne plante rien, il ralentit silencieusement TOUT le serveur.

SituationComportementImpact
await oublié sur un appel asyncErreur visible (coroutine non attendue)Facile à détecter
Code bloquant (time.sleep, lib sync) dans une route async defAucune erreur, juste un ralentissementDifficile à détecter, affecte tout le serveur

Piège fréquent

Quand du code bloquant est incontournable (une lib tierce sans version async, un calcul CPU lourd), la parade consiste à le déporter dans un threadpool avec run_in_threadpool, plutôt que de l'appeler directement dans une route asynchrone.

Commandes & code

SQLAlchemy asynchrone

bash
pip install "sqlalchemy[asyncio]" asyncpg
python
# app/core/database.py — moteur et session asynchrones
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from sqlalchemy.orm import DeclarativeBase

DATABASE_URL = "postgresql+asyncpg://user:password@localhost:5432/mydb"

engine = create_async_engine(
    DATABASE_URL,
    pool_size=10,
    max_overflow=20,
    pool_pre_ping=True,
)

AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)


class Base(DeclarativeBase):
    pass


async def get_db():
    async with AsyncSessionLocal() as session:
        yield session
python
# Requêtes async — await sur chaque appel réseau vers la DB
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from typing import Annotated
from fastapi import Depends, APIRouter, HTTPException

router = APIRouter(prefix="/products", tags=["products"])


@router.get("/{product_id}")
async def get_product(product_id: int, db: Annotated[AsyncSession, Depends(get_db)]):
    product = await db.get(Product, product_id)
    if not product:
        raise HTTPException(status_code=404, detail="Produit introuvable")
    return product


@router.get("")
async def list_products(db: Annotated[AsyncSession, Depends(get_db)], min_price: float = 0):
    stmt = select(Product).where(Product.price >= min_price)
    result = await db.execute(stmt)
    return result.scalars().all()
python
# Création avec commit asynchrone
@router.post("")
async def create_product(data: ProductCreate, db: Annotated[AsyncSession, Depends(get_db)]):
    product = Product(**data.model_dump())
    db.add(product)
    await db.commit()
    await db.refresh(product)
    return product
python
# Eager loading en async — selectinload évite le lazy loading implicite (qui échoue en async)
from sqlalchemy.orm import selectinload

@router.get("/{product_id}/with-reviews")
async def get_product_with_reviews(product_id: int, db: Annotated[AsyncSession, Depends(get_db)]):
    stmt = select(Product).where(Product.id == product_id).options(selectinload(Product.reviews))
    result = await db.execute(stmt)
    product = result.scalar_one_or_none()
    if not product:
        raise HTTPException(status_code=404, detail="Introuvable")
    # product.reviews est déjà chargé : accéder à une relation NON préchargée lèverait une erreur
    # (le lazy loading synchrone implicite n'existe pas dans un contexte async)
    return product
python
# Transaction explicite regroupant plusieurs opérations
async def transfer_stock(db: AsyncSession, from_id: int, to_id: int, quantity: int):
    async with db.begin():  # commit automatique en sortie, rollback si exception
        source = await db.get(Product, from_id, with_for_update=True)
        target = await db.get(Product, to_id, with_for_update=True)

        if source.stock < quantity:
            raise ValueError("Stock insuffisant")

        source.stock -= quantity
        target.stock += quantity
python
# Pièges courants : NE PAS mélanger sync et async au hasard
# MAUVAIS : appeler du code sync bloquant dans une route async (bloque l'event loop)
@router.get("/bad-example")
async def bad_example():
    import time
    time.sleep(2)  # bloque TOUT le serveur pendant 2 secondes, pas seulement cette requête
    return {}

# BON : si du code bloquant est inévitable, le déporter dans un threadpool
from fastapi.concurrency import run_in_threadpool

@router.get("/good-example")
async def good_example():
    result = await run_in_threadpool(blocking_legacy_function)
    return result

def blocking_legacy_function():
    import time
    time.sleep(2)
    return {"ok": True}

Résumé

  • AsyncSession + asyncpg nécessitent await sur chaque appel réseau (db.get, db.execute, db.commit).
  • Le lazy loading implicite de SQLAlchemy ne fonctionne pas en async : précharger explicitement avec selectinload.
  • async with db.begin(): gère automatiquement commit/rollback pour une transaction.
  • Ne jamais appeler de code bloquant synchrone dans une route async def — utiliser run_in_threadpool si nécessaire.

Exercices pratiques

1 disponible
1

Mission : le serveur qui ralentit sous charge malgré l'async

Objectif : Traquer un appel bloquant caché qui gèle l'event loop et corriger un accès à une relation non préchargée en async.

Contexte

L'équipe SRE signale que le temps de réponse moyen se dégrade brutalement sous charge, alors que toutes les routes de app/services/legacy_import.py et app/routers/products.py sont déclarées en async def. Par ailleurs, l'endpoint GET /products/{id}/with-reviews plante avec une erreur au moment d'accéder à product.reviews.

Résoudre l’exercice →