backend / fastapi
SQLAlchemy asynchrone
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.
| Situation | Comportement | Impact |
|---|---|---|
await oublié sur un appel async | Erreur visible (coroutine non attendue) | Facile à détecter |
Code bloquant (time.sleep, lib sync) dans une route async def | Aucune erreur, juste un ralentissement | Difficile à 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
pip install "sqlalchemy[asyncio]" asyncpg# 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# 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()# 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# 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# 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# 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+asyncpgnécessitentawaitsur 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— utiliserrun_in_threadpoolsi nécessaire.
Exercices pratiques
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.