Retour au cours

data / sqlalchemy

Performance : problème N+1 et opérations en masse

Leçon 131 exercice

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 lignesFonctionnalités ORM
session.add() en boucletrès lent, forte consommation mémoirecomplètes (événements, relations)
bulk_insert_mappingsrapidelimitées (pas d'événements)
insert() de Corele plus rapideaucune (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

python
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 N session.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 chaque commit().

Exercices pratiques

1 disponible
1

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.

Résoudre l’exercice →