Aller au contenu principal

Pourquoi ton code est rapide en dev et lent en prod

Une fonction testée sur 200 lignes qui met 15 secondes sur 80 000, sans qu'une seule ligne ait changé. Cet article explique ce que mesure la notation Big O, montre les trois formes de boucle imbriquée qu'on ne voit pas passer en revue de code, donne le geste qui les corrige, et le seuil mesuré à partir duquel ça devient rentable de s'en occuper.

Guides & tutoriels ·
Adel LATIBI
Adel LATIBI

Le Briefing Dev - les ressources et actus de la semaine, droit dans ta boîte chaque vendredi gratuitement.

En vous inscrivant, vous acceptez de recevoir notre newsletter. Désinscription possible à tout moment.

Un script tourne en local sur un extrait de données. Il répond en un clin d'oeil, les tests passent, la revue de code ne relève rien. Le même script part en production, branché cette fois sur la base complète, et la requête met quinze secondes à revenir.

Entre les deux exécutions, le code est identique au caractère près. Ce qui a changé, c'est le nombre de lignes en entrée.

La notation Big O sert exactement à prévoir ce moment. Elle est enseignée comme un chapitre de mathématiques discrètes, avec des limites et des théorèmes, ce qui la rend inutilisable pour la plupart des gens qui écrivent du code tous les jours. Elle se ramène pourtant à une seule question, qui se pose en français et qui se vérifie avec un chronomètre.

Cet article prend la question par ce bout. Les chiffres qui suivent viennent de mesures exécutées pendant la rédaction, sur un conteneur Linux à un seul coeur (Intel Xeon 2,10 GHz, 3,9 Go de RAM), en Python 3.12.3 et Node.js 22.22.2. Les valeurs absolues dépendent de la machine, les rapports entre elles beaucoup moins.

Le moment exact où ça casse

Voici une fonction de dédoublonnage, écrite comme elle sort naturellement quand on apprend à programmer. Elle parcourt une liste d'adresses e-mail et ne garde que celles qu'elle n'a pas encore vues.

# Python 3.12
def dedoublonner(lignes: list[str]) -> list[str]:
    resultat = []
    for ligne in lignes:
        if ligne not in resultat:
            resultat.append(ligne)
    return resultat

Elle est correcte. Elle rend le bon résultat sur n'importe quelle entrée. Et sur un fichier de test de 200 lignes, elle s'exécute en 0,07 milliseconde, ce qui ne déclenche aucun signal d'alarme nulle part.

Voici ce que donne la même fonction quand le fichier grossit.

Lignes en entrée Temps mesuré
2000,07 ms
1 0002,16 ms
5 00055 ms
20 0001,21 s
80 00015,6 s

400 fois plus de données, et plus de 200 000 fois plus de temps. La courbe ne monte pas, elle décolle. Un utilisateur qui déclenche cet import depuis une interface web voit sa requête partir en timeout, et l'équipe cherche du côté du serveur, du réseau ou de la base pendant que le coupable tient en six lignes.

Le fossé entre l'environnement de développement et la production ne se limite pas au volume de données, il touche aussi la configuration et les dépendances système. Ce sujet plus large est traité dans l'article sur la phrase "ça marche sur ma machine" et les projets portables.

Ce que Big O compte, et ce qu'il ne compte pas

Big O ne mesure pas des millisecondes. Il décrit la façon dont le nombre d'opérations évolue quand la taille de l'entrée augmente. Le n qui apparaît dans les formules, c'est cette taille : le nombre de lignes, d'utilisateurs, de fichiers, de produits.

La question à se poser devant une fonction tient en une phrase : si je double l'entrée, qu'est-ce qui arrive au temps d'exécution ?

  • Il ne bouge pas. C'est du O(1). Accéder à tableau[42], lire une clé de dictionnaire.
  • Il double aussi. C'est du O(n). Une boucle simple qui parcourt la liste une fois.
  • Il double, plus un petit supplément. C'est du O(n log n), le régime des fonctions de tri intégrées au langage.
  • Il quadruple. C'est du O(n²). Une boucle imbriquée, ou une recherche linéaire placée à l'intérieur d'une boucle.

Le O(log n) mérite une mention à part, sans lui consacrer un chapitre : c'est le régime d'une recherche dans un index de base de données, où multiplier le volume par mille n'ajoute qu'une poignée d'étapes. C'est la raison pour laquelle un index change tout sur une grosse table.

Le tableau de mesures plus haut illustre le quadruplement de façon imparfaite, et c'est intéressant de le dire. En passant de 5 000 à 40 000 lignes, soit huit fois plus de données, le temps est passé de 54 ms à 4,1 s, soit 76 fois plus. Le modèle quadratique prédisait 64 fois. L'écart vient du bruit de mesure, des effets de cache processeur et du fait qu'une machine partagée ne donne jamais deux fois le même chiffre. Big O donne un ordre de grandeur, pas une prédiction à la milliseconde.

Les trois formes de boucle imbriquée qu'on ne voit pas

La boucle dans la boucle avec deux indentations visibles se repère en une seconde en revue de code. Les trois écritures ci-dessous produisent la même complexité quadratique, sans qu'aucune imbrication n'apparaisse à l'oeil.

1. Une recherche linéaire cachée dans une méthode

C'est le cas de la fonction de dédoublonnage du début. Le not in resultat ressemble à un test unique, mais Python parcourt la liste entière pour répondre. Une boucle de n tours qui contient un parcours de n éléments donne bien n × n opérations, même si le code tient sur une ligne.

Les équivalents dans les autres langages : Array.includes() et indexOf() en JavaScript, in_array() en PHP, List.contains() en Java.

2. Un filtre à l'intérieur d'un parcours

Le croisement de deux collections est l'endroit où ça arrive le plus souvent. Filtrer des commandes selon une liste de clients actifs, par exemple.

// Node.js 22, modules ES
const commandesActives = commandes.filter(
  (commande) => idsActifs.includes(commande.clientId)
);

Une seule ligne, aucune accolade imbriquée, et pourtant includes rebalaye idsActifs du début à chaque commande. Mesuré sur des collections de même taille, après chauffe du compilateur à la volée de V8 : 0,24 ms pour 500 éléments, 3,21 ms pour 2 000, 107 ms pour 10 000, et 2,60 s pour 50 000.

3. Une requête SQL dans une boucle

Récupérer une liste d'articles, puis boucler dessus pour aller chercher le nom de l'auteur article par article. Une requête pour la liste, puis n requêtes pour les détails : c'est le fameux N+1, et il se glisse tout seul dès qu'un ORM charge une relation à la demande.

Sur un banc de mesure avec SQLite en mémoire, 5 000 articles chargés en N+1 demandent 5 001 requêtes et 9,85 ms, contre 3,12 ms pour la jointure unique qui rend le même résultat. Le rapport de 3 semble presque acceptable, et c'est un piège : SQLite en mémoire n'a aucune latence réseau. Avec une base sur un autre serveur, à un aller-retour de 1 ms, les mêmes 5 001 requêtes ajoutent cinq secondes d'attente pure. Ce coût-là ne se voit ni dans le profileur CPU, ni sur un poste de développement où la base tourne en local. La question de savoir où poser ces accès aux données est traitée dans l'article sur le repository pattern et la séparation de l'accès aux données.

Le geste qui corrige : passer de "parcourir" à "demander"

Une liste répond à la question "est-ce que tu contiens cette valeur ?" en regardant ses éléments un par un. Un ensemble, ou un dictionnaire, répond en calculant directement l'emplacement où la valeur devrait se trouver. Ce calcul ne dépend pas du nombre d'éléments stockés, ce qui fait passer la recherche de O(n) à O(1).

La correction de la fonction du début tient en deux lignes de plus.

# Python 3.12
def dedoublonner(lignes: list[str]) -> list[str]:
    vus: set[str] = set()
    resultat: list[str] = []
    for ligne in lignes:
        if ligne not in vus:
            vus.add(ligne)
            resultat.append(ligne)
    return resultat

Sur les 80 000 lignes qui mettaient 15,6 secondes, cette version met 4,86 millisecondes. L'ordre est préservé, contrairement à un passage direct par set(), et le résultat est identique élément par élément.

Côté JavaScript, le même geste consiste à construire un Set avant la boucle plutôt que de rebalayer un tableau à l'intérieur.

// Node.js 22, modules ES
const actifs = new Set(idsActifs);
const commandesActives = commandes.filter(
  (commande) => actifs.has(commande.clientId)
);

Sur 50 000 éléments, la version avec Set passe de 2,60 s à 7,34 ms, soit un facteur 355. Une ligne ajoutée, un mot changé.

0,01 ms 0,1 ms 1 ms 10 ms 100 ms 1 s 10 s 200 1 000 5 000 20 000 80 000 lignes en entrée recherche dans une liste recherche dans un ensemble temps (échelle log)
Temps mesurés pour la même fonction de dédoublonnage en Python 3.12, échelle verticale logarithmique. Sur une échelle linéaire, la courbe verte serait plaquée sur l'axe.

Les pièges

Construire un ensemble pour une seule recherche

Voici le chiffre que je n'attendais pas en préparant cet article. Sur une liste de 1 000 éléments, une seule recherche coûte 6,63 microsecondes en parcours linéaire, contre 9,38 microsecondes si on construit un ensemble avant d'interroger. La construction est elle-même en O(n), donc pour une question unique elle ne rentabilise jamais son coût.

Le basculement arrive à la deuxième recherche : 11,88 microsecondes en liste contre 8,28 avec l'ensemble. À partir de deux interrogations sur la même collection, la structure paie. En dessous, elle coûte.

Optimiser ce qui ne grossira jamais

Une double boucle sur les 12 mois de l'année fait 144 opérations, aujourd'hui et dans dix ans. Une comparaison croisée sur les 5 statuts d'une commande en fait 25. Réécrire ce code au nom de la complexité algorithmique rend le programme moins lisible sans rien gagner de mesurable.

Le critère de décision est simple : est-ce que la taille de l'entrée est fixée par le métier, ou est-ce qu'elle suit le nombre d'utilisateurs, de commandes ou de fichiers importés ? Dans le premier cas, la question ne se pose pas. Dans le second, elle se posera un jour, et le jour en question sera un vendredi soir.

Confondre nombre d'opérations et temps d'attente

Le cas N+1 le montre bien : côté processeur, 5 001 petites requêtes ne coûtent presque rien de plus qu'une grosse. Le coût se trouve entièrement dans l'aller-retour réseau répété. Big O compte des opérations, sans distinguer celles qui prennent une nanoseconde de celles qui prennent une milliseconde. Sur du code qui appelle une base de données, une API externe ou un système de fichiers, le nombre d'appels compte plus que le nombre d'itérations.

Mesurer une seule fois en JavaScript

Pendant la préparation des mesures de cet article, la première série sur 2 000 éléments donnait la version avec Set plus lente que la version avec includes, à 4,59 ms contre 4,04 ms. Résultat absurde au regard de la théorie, et pourtant reproductible : le compilateur à la volée de V8 n'avait pas encore optimisé le code au premier passage. Après une phase de chauffe, l'écart repassait à 26 fois en faveur du Set. Toute mesure en JavaScript qui ne fait tourner la fonction qu'une fois mesure la compilation autant que l'algorithme.

Attendre que la base de données rattrape le coup

Un moteur SQL sait faire des jointures efficaces, à condition qu'on lui donne le travail. Une jointure faite à la main en Python ou en JavaScript après avoir chargé deux tables entières en mémoire ne bénéficie d'aucun index ni d'aucune statistique de table. Le code applicatif refait alors, en moins bien, ce que le moteur aurait fait en quelques millisecondes.

Mesurer, plutôt que deviner

Le raisonnement Big O sert à savoir où regarder. Il ne remplace pas le chronomètre, parce que les constantes cachées et les optimisations du moteur d'exécution décident du résultat réel sur les petites entrées.

Les outils tiennent en trois commandes, disponibles sans rien installer :

  • En Python, le module timeit de la bibliothèque standard, qui répète la mesure et retient le minimum. C'est lui qui a produit tous les chiffres Python de cet article.
  • En JavaScript, performance.now() encadrant l'appel, avec une phase de chauffe et plusieurs tours.
  • Côté base de données, EXPLAIN ANALYZE sur PostgreSQL, qui donne le plan choisi et le temps réel de chaque étape.

La méthode qui fonctionne consiste à mesurer sur trois volumes croissants plutôt que sur un seul. Un temps isolé ne dit rien, deux temps donnent une pente, trois révèlent la forme de la courbe. Cette façon de procéder par observation avant de toucher au code se rapproche de ce qui est décrit dans l'article sur l'apprentissage du débogage.

Ces réflexes de structure de données font partie des fondations qu'on installe avant d'apprendre un framework. Ils sont travaillés en pratique dans la formation Algorithmique et logique de programmation (référence FND-ALG, 2 jours, 14 heures), dont le module sur les boucles imbriquées et celui sur les tableaux traitent exactement les cas de cet article. Pour aller plus loin sur la qualité du code au sens large, le hub sur les 20 principes de code donne la carte d'ensemble.

Un dernier chiffre pour la route : la fonction de six lignes du début, corrigée en deux lignes de plus, est passée de 15,6 secondes à 4,86 millisecondes. Aucun serveur supplémentaire, aucune bibliothèque ajoutée. Un ensemble à la place d'une liste.

Questions fréquentes

Faut-il connaître les maths pour utiliser Big O au quotidien ?

Non pour l'usage courant. Repérer une recherche linéaire placée dans une boucle et savoir répondre à la question "si je double l'entrée, qu'est-ce qui se passe" couvre la quasi-totalité des cas qu'un développeur croise en application métier. Les démonstrations formelles servent en recherche et dans la conception d'algorithmes nouveaux, pas pour écrire un import de fichier CSV.

À partir de combien de données faut-il s'en préoccuper ?

Il n'y a pas de seuil universel, parce qu'il dépend du coût de chaque opération. Sur les mesures de cet article, une fonction quadratique restait sous la milliseconde jusqu'à 600 lignes environ, franchissait les deux millisecondes à 1 000, dépassait la seconde vers 20 000 et devenait inutilisable à 80 000. La bonne question porte sur la trajectoire de l'entrée plutôt que sur sa taille actuelle : si elle suit le nombre d'utilisateurs ou de commandes, elle finira par franchir le seuil.

Un dictionnaire consomme-t-il beaucoup plus de mémoire qu'une liste ?

Oui, un ensemble ou un dictionnaire occupe plus de place qu'une liste contenant les mêmes valeurs, parce qu'il réserve des emplacements vides pour limiter les collisions. C'est un échange classique entre mémoire et temps de calcul. Sur des volumes qui posent un problème de vitesse, le surcoût mémoire reste largement préférable à une attente de quinze secondes, mais sur des jeux de données de plusieurs millions d'éléments la question mérite d'être mesurée des deux côtés.

Comment repérer un N+1 dans un projet existant ?

Le signal le plus fiable est le compteur de requêtes affiché par la barre de débogage du framework : Symfony Profiler, Django Debug Toolbar, Laravel Telescope. Un nombre de requêtes qui augmente avec le nombre de lignes affichées à l'écran désigne le N+1 sans ambiguïté. La correction passe par un chargement anticipé de la relation, avec une jointure explicite ou une seconde requête unique sur l'ensemble des identifiants.

Est-ce que ces questions tombent en entretien technique ?

Sur des postes juniors en développement web, la question posée ressemble plus souvent à "pourquoi cette fonction est-elle lente" qu'à une demande d'implémenter un tri fusion au tableau. Savoir expliquer à voix haute qu'une recherche dans une liste placée dans une boucle donne un comportement quadratique vaut mieux que de réciter des complexités par coeur. Le sujet du format réel des entretiens est traité dans l'article sur l'entretien technique junior.

Et la complexité en mémoire, elle se traite comment ?

La même logique s'applique : la question devient "si je double l'entrée, combien de mémoire supplémentaire est réservée". Le cas le plus courant en développement web est le chargement d'un fichier entier en mémoire au lieu de le lire ligne par ligne, ce qui fonctionne sur un fichier de test et sature la machine sur un export de production. La lecture en flux, avec un générateur en Python ou un flux de lecture en Node.js, ramène la consommation à une valeur constante.

Vous êtes expert ?

Partagez votre expertise sur notre blog

Tutoriel, retour d'expérience, analyse - publiez un article invité et gagnez en visibilité.

Écrire pour nous