backend / fastapi
SQLAlchemy synchrone
Explication
Ce que vous allez apprendre
- Configurer un moteur SQLAlchemy et un pool de connexions adapté à une charge réelle
- Définir un modèle ORM avec la syntaxe
Mapped/mapped_columnde SQLAlchemy 2.0 - Injecter une session par requête via une dépendance FastAPI avec
yield - Diagnostiquer et corriger un problème N+1 avec
joinedload/selectinload - Comprendre le rôle d'Alembic pour versionner les migrations de schéma
Dans quel contexte ?
L'endpoint GET /products de app/routers/products.py devient anormalement lent en production dès que le catalogue dépasse quelques centaines de produits. En activant les logs SQL, l'équipe découvre que pour 300 produits affichés, 301 requêtes SQL partent vers la base : une pour la liste, puis une par produit pour charger son propriétaire (product.owner). C'est le problème N+1 classique, réglé en une ligne avec joinedload(Product.owner) qui précharge la relation en une seule requête.
D'abord, qu'est-ce qu'un ORM
Un ORM fait le pont entre deux mondes : les objets Python et le monde relationnel des bases de données. D'un côté des classes et des instances, de l'autre des tables et des lignes.
On pourrait écrire des requêtes SQL brutes en chaînes de caractères. Mais elles sont fragiles et propices aux erreurs de frappe comme aux injections.
SQLAlchemy permet à la place de manipuler des objets Python typés, comme Product ou User. Leurs attributs correspondent directement aux colonnes d'une table.
Une fois ce principe posé, il faut comprendre un concept central : la Session. Elle représente une conversation avec la base de données.
Elle garde en mémoire les objets chargés, détecte les modifications faites dessus, et les traduit en requêtes SQL au moment du commit(). Comprendre qu'une session a un cycle de vie précis — ouverte, utilisée, fermée — est essentiel.
C'est pour cette raison qu'elle est fournie via une dépendance FastAPI avec yield. Ça garantit sa fermeture après chaque requête HTTP, comme vu dans la leçon précédente.
Une fois la session comprise, il reste à savoir comment les connexions réseau sont gérées efficacement. Établir une connexion à une base de données a un coût réel, entre authentification et négociation.
Le pool de connexions maintient un ensemble de connexions déjà ouvertes et les réutilise entre les requêtes. pool_pre_ping=True ajoute une vérification légère avant chaque réutilisation, pour éviter une connexion devenue invalide.
Une fois les connexions gérées, un piège classique guette dès qu'on charge des relations : le "N+1". Charger une liste d'objets puis accéder à une relation de chacun déclenche, par défaut, UNE requête SQL supplémentaire PAR objet.
joinedload ou selectinload corrigent ça. Ils précisent explicitement quelles relations charger d'avance, en une seule requête optimisée.
Piège fréquent
Charger une liste de N objets puis accéder à une relation de chacun sans joinedload/selectinload déclenche N requêtes SQL supplémentaires (le problème "N+1"), invisible en développement avec peu de données mais catastrophique en production avec un vrai volume.
| Outil | Nombre de requêtes SQL | Cas d'usage |
|---|---|---|
| Accès direct à une relation (sans eager loading) | 1 + N (une par objet) | À éviter sur une liste |
joinedload | 1 (JOIN SQL) | Relation "un vers un" ou peu d'objets |
selectinload | 2 (une pour la liste, une pour la relation) | Relation "un vers plusieurs", grand volume |
Pour finir, un dernier outil mérite d'être connu : Alembic pour les migrations. Modifier une table de production à la main est risqué et non reproductible, alors qu'Alembic génère et applique des migrations de schéma versionnées, comme Git le fait pour le code.
Commandes & code
SQLAlchemy synchrone
# app/core/database.py
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker, DeclarativeBase
DATABASE_URL = "postgresql://user:password@localhost:5432/mydb"
engine = create_engine(
DATABASE_URL,
pool_size=10, # connexions maintenues ouvertes en permanence
max_overflow=20, # connexions supplémentaires temporaires autorisées sous charge
pool_pre_ping=True, # vérifie la connexion avant usage (évite les erreurs sur connexion morte)
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
class Base(DeclarativeBase):
pass# app/models/product.py — modèle ORM SQLAlchemy 2.0 (Mapped / mapped_column)
from sqlalchemy import String, ForeignKey
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.core.database import Base
class Product(Base):
__tablename__ = "products"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str] = mapped_column(String(200))
price: Mapped[float]
owner_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
owner: Mapped["User"] = relationship(back_populates="products")# app/dependencies.py — dépendance de session par requête
from app.core.database import SessionLocal
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()# Requêtes courantes avec l'API SQLAlchemy 2.0 (select explicite)
from sqlalchemy import select
from sqlalchemy.orm import Session
def get_product(db: Session, product_id: int) -> Product | None:
return db.get(Product, product_id) # lookup direct par clé primaire
def list_expensive_products(db: Session, min_price: float) -> list[Product]:
stmt = select(Product).where(Product.price >= min_price).order_by(Product.price.desc())
return list(db.scalars(stmt))
def get_products_with_owner(db: Session):
# eager loading pour éviter le problème N+1 (une requête au lieu de N)
from sqlalchemy.orm import joinedload
stmt = select(Product).options(joinedload(Product.owner))
return list(db.scalars(stmt))# Création, mise à jour, suppression
def create_product(db: Session, name: str, price: float, owner_id: int) -> Product:
product = Product(name=name, price=price, owner_id=owner_id)
db.add(product)
db.commit()
db.refresh(product) # recharge les champs générés par la DB (id, defaults)
return product
def update_product(db: Session, product: Product, **fields) -> Product:
for key, value in fields.items():
setattr(product, key, value)
db.commit()
db.refresh(product)
return product
def delete_product(db: Session, product: Product) -> None:
db.delete(product)
db.commit()# Alembic pour les migrations de schéma — jamais modifier la DB à la main en production
# alembic init alembic
# alembic revision --autogenerate -m "Ajout de la table products"
# alembic upgrade head# alembic.ini (extrait) — pointer vers la même base que l'application
sqlalchemy.url = postgresql://user:password@localhost:5432/mydbRésumé
pool_pre_ping=Trueévite les erreurs sur des connexions devenues invalides (timeout réseau, redémarrage DB).- La dépendance
get_dbavecyieldgarantit la fermeture de session même en cas d'exception dans l'endpoint. joinedload/selectinloadévitent le problème N+1 lors du chargement de relations.- Alembic gère les migrations de schéma de façon versionnée et reproductible entre environnements.
Exercices pratiques
Mission : le catalogue qui devient lent avec le succès
Objectif : Diagnostiquer et corriger un problème N+1 sur GET /products, puis sécuriser le pool de connexions.
Contexte
L'endpoint GET /products de app/routers/products.py répondait en 50ms avec 20 produits de test, mais met maintenant 4 secondes en production avec 300 produits. Les logs SQL activés montrent 301 requêtes pour une seule réponse HTTP. Le moteur SQLAlchemy de app/core/database.py n'a par ailleurs aucun paramètre de pool configuré.