data / sqlalchemy
Performance : problème N+1 et opérations en masse
Explication
Ce que vous allez apprendre
- Comprendre pourquoi réduire le NOMBRE de requêtes compte souvent plus que leur complexité individuelle
- Repérer le coût caché du dirty tracking objet par objet sur de gros volumes
- Utiliser les opérations bulk (
insert()de Core,bulk_insert_mappings) pour aller plus vite - Mesurer objectivement une optimisation plutôt que de l'appliquer à l'aveugle
- Traiter de grands volumes de résultats sans tout charger en mémoire avec
yield_per/.partitions()
Dans quel contexte ?
Un script d'import doit insérer 500 000 lignes de produits dans la base à partir d'un fichier CSV. La première version, écrite avec session.add(produit) dans une boucle puis un seul commit() final, met plus de 10 minutes à s'exécuter et consomme des gigaoctets de mémoire à cause du suivi individuel de chaque objet par la session. Cette leçon montre comment réduire ce temps à quelques secondes avec des opérations bulk.
D'abord, revenir sur un problème déjà croisé
La leçon 7 a présenté le problème N+1 sous l'angle du chargement des relations. Cette leçon le reprend sous un angle plus large : chaque aller-retour réseau vers la base de données a un coût fixe non négligeable, indépendamment de la quantité de données transférées.
L'idée centrale à retenir
Réduire le NOMBRE de requêtes compte souvent plus que réduire leur complexité individuelle. C'est ce principe qui explique pourquoi l'eager loading (leçon 7) est si efficace : il transforme N+1 requêtes en seulement 2.
| Approche | ~500 000 lignes | Fonctionnalités ORM |
|---|---|---|
session.add() en boucle | très lent, forte consommation mémoire | complètes (événements, relations) |
bulk_insert_mappings | rapide | limitées (pas d'événements) |
insert() de Core | le plus rapide | aucune (SQL pur) |
Un autre coût caché : le suivi objet par objet
Créer 10 000 objets Python avec session.add() un par un semble naturel, mais chaque objet est individuellement suivi par la session (dirty tracking, identity map), ce qui a un coût mémoire et CPU qui s'accumule à grande échelle.
La solution : sortir du tracking objet par objet
Les opérations "bulk" (insert() de Core, bulk_insert_mappings) contournent délibérément ce suivi pour aller beaucoup plus vite, en échange de fonctionnalités ORM en moins : pas d'événements, pas de relations automatiques.
Piège fréquent
Les opérations bulk (bulk_insert_mappings, insert() de Core) ne déclenchent PAS les événements ORM (before_insert, validations Python, cascade). Si votre logique métier dépend de ces mécanismes, une insertion bulk peut silencieusement les contourner — à réserver aux imports massifs où cette perte est acceptée en connaissance de cause.
Un réflexe à adopter avant d'optimiser
Un principe essentiel de performance : ne jamais optimiser à l'aveugle. Comparer objectivement les approches, ORM naïf contre bulk, permet de savoir où l'effort d'optimisation est réellement rentable, plutôt que de le faire par intuition.
Un dernier problème, différent : la mémoire
Même une requête bien écrite peut poser problème si elle ramène des millions de lignes d'un coup en mémoire. yield_per/.partitions() répond à ce problème en traitant les résultats par petits lots successifs, sans jamais tout garder en mémoire simultanément.
Vers la suite
Après avoir optimisé la vitesse d'écriture et de lecture, la prochaine leçon s'attaque à un autre problème structurant : où et comment valider que les données restent toujours cohérentes.
Commandes & code
Performance : N+1 et bulk operations
from sqlalchemy import select, insert, update, delete
from sqlalchemy.orm import selectinload
import time
# --- Le problème N+1 ---
# 1 requête pour charger N auteurs + N requêtes pour charger leurs articles = N+1 requêtes
with SessionLocal() as session:
auteurs = session.scalars(select(Auteur)).all() # 1 requête
for auteur in auteurs:
print(len(auteur.articles)) # +1 requête PAR auteur -> lent !
# Solution : eager loading (voir aussi la leçon dédiée)
with SessionLocal() as session:
stmt = select(Auteur).options(selectinload(Auteur.articles))
auteurs = session.scalars(stmt).all() # 2 requêtes au total, quel que soit N
for auteur in auteurs:
print(len(auteur.articles)) # zéro requête supplémentaire
# Détecter les N+1 en développement : activer echo ou un compteur de requêtes
from sqlalchemy import event
compteur_requetes = {"n": 0}
@event.listens_for(engine, "before_cursor_execute")
def compter(conn, cursor, statement, parameters, context, executemany):
compteur_requetes["n"] += 1
# --- Bulk insert : éviter un objet Python + un INSERT par ligne ---
with SessionLocal() as session:
# LENT : 10 000 objets Python, 10 000 aller-retours de tracking ORM
for i in range(10_000):
session.add(Produit(nom=f"Produit {i}", prix=9.99))
session.commit()
# RAPIDE : bulk_insert_mappings / insert() Core, contourne le tracking objet par objet
with SessionLocal() as session:
session.execute(
insert(Produit),
[{"nom": f"Produit {i}", "prix": 9.99} for i in range(10_000)],
)
session.commit()
# Bulk update : un seul UPDATE au lieu de charger puis modifier chaque objet
with SessionLocal() as session:
session.execute(
update(Produit).where(Produit.categorie == "obsolete").values(actif=False)
)
session.commit()
# Bulk delete
with SessionLocal() as session:
session.execute(delete(Produit).where(Produit.stock == 0))
session.commit()
# yield_per : streamer de très gros résultats sans tout charger en mémoire
with SessionLocal() as session:
stmt = select(Produit).execution_options(yield_per=1000)
for lot in session.scalars(stmt).partitions(): # traite par lots de 1000
for produit in lot:
traiter(produit)
# Mesurer l'impact : bulk vs ORM classique
def benchmark_insert(n=5000):
debut = time.perf_counter()
with SessionLocal() as session:
session.execute(insert(Produit), [{"nom": f"P{i}", "prix": 1.0} for i in range(n)])
session.commit()
return time.perf_counter() - debut # généralement 5-20x plus rapide que l'ORM classique
# Éviter expire_on_commit=True par défaut si on relit beaucoup après commit (force un SELECT)
SessionRapide = sessionmaker(bind=engine, expire_on_commit=False)Résumé
- Le problème N+1 se détecte en comptant les requêtes SQL générées pour une boucle sur une relation : la solution est l'eager loading.
session.execute(insert(Modele), [...])(bulk) est bien plus rapide que Nsession.add()individuels pour de gros volumes.yield_per/.partitions()permet de traiter de très gros résultats sans charger toute la table en mémoire.expire_on_commit=Falseévite un SELECT de rechargement automatique après chaquecommit().
Exercices pratiques
Mission : accélérer un import de 500 000 produits qui dure 10 minutes
Objectif : Diagnostiquer le coût du tracking objet par objet et migrer un import vers une opération bulk.
Contexte
Un script d'import fait session.add(produit) dans une boucle sur 500 000 lignes d'un fichier CSV, puis un seul commit() final. Il met plus de 10 minutes à s'exécuter et consomme plusieurs gigaoctets de mémoire, alors que la fenêtre de maintenance disponible est bien plus courte.