1. Objectif de l’audit : arrêter de traiter l’ORM « à tâtons »
L’audit d’un scénario de lecture — c’est lorsque vous cessez de réagir à l’ORM comme à un chat capricieux (« tantôt il mange, tantôt non, tantôt N+1 »), et que vous commencez à agir comme un ingénieur : vous fixez une méthode spécifique, reproduisez son comportement, mesurez le coût SQL et transformez les « chargements étranges » en un résultat clair et reproductible. Ici, l’important n’est pas « d’accélérer magiquement tout », mais de rendre le coût de la lecture prévisible.
Nous avons déjà une baseline : printOrdersWithCustomerEmail() sur le même OrderReadService a montré une croissance linéaire des requêtes. Et nous avons déjà une distinction importante : la liste et la fiche sont des contrats de lecture différents. Maintenant, pas besoin d’un autre service de démonstration. Il faut un audit appliqué du même scénario : le mesurer avant et après le cleanup.
Pour parler très concrètement, l’audit répond à trois questions :
- quelle spécifique méthode mesurons-nous ;
- où cette méthode touche accidentellement les associations et transforme la liste en parcours de graphe ;
- comment le proxy de requêtes change après que la liste et la fiche ont cessé de vivre dans le même code.
Un petit diagramme pour bien visualiser le processus :
flowchart TD
A[Prenons le même OrderReadService] --> B[Relevons le proxy de requêtes de base]
B --> C[Séparons la liste et la fiche dans ce même service]
C --> D[Répétons la mesure]
D --> E[Le coût de lecture devient prévisible]
2. Étape 1 : prenons la même baseline, pas un nouveau banc d’essai
La façon la plus courante de gâcher un audit est de créer un nouveau OrderReadScenarios, un nouveau runner et de changer accidentellement une demi-scène entière en une fois. Nous ne faisons pas cela. Nous conservons le même HibernateStats, le même OrderReadService et le même point de démarrage NPlusOneLabRunner. Ce qui change n’est pas le banc d’essai, mais la méthode que nous appelons à l’intérieur.
Ci-dessous le même runner après ce refactoring. Il mesure la baseline, puis la liste propre, puis la fiche :
package com.example.commerce.labsupport;
import com.example.commerce.orders.query.OrderReadService;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Profile;
import org.springframework.stereotype.Component;
@Profile("lab")
@Component
public class NPlusOneLabRunner implements CommandLineRunner {
private final OrderReadService service;
private final HibernateStats hStats;
public NPlusOneLabRunner(OrderReadService service, HibernateStats hStats) {
this.service = service;
this.hStats = hStats;
}
@Override
public void run(String... args) {
long sampleOrderId = 1001L; // prenez un id existant dans les données de seed
hStats.stats().clear();
service.printOrdersWithCustomerEmail();
System.out.println("baseline query proxy = " + hStats.stats().getPrepareStatementCount()); // par exemple, 31
hStats.stats().clear();
service.printOrderList();
System.out.println("list query proxy = " + hStats.stats().getPrepareStatementCount()); // par exemple, 1
hStats.stats().clear();
service.printOrderCard(sampleOrderId);
System.out.println("card query proxy = " + hStats.stats().getPrepareStatementCount()); // par exemple, 3
}
}
Ici, ce qui compte n’est pas le nombre dans le commentaire, mais la forme de la comparaison : le même runner, le même jeu de données, le même proxy pratique pour le comptage de requêtes via getPrepareStatementCount().
3. Étape 2 : noter ce qui a changé exactement dans le service
Maintenant, il est important de ne pas réexpliquer le fetching à zéro, mais de voir une chose : seul le pattern d’accès a changé. Nous ne modifions pas le domaine de la commande et nous n’introduisons pas une nouvelle magie Hibernate. Nous cessons de faire de la liste une demi-fiche.
Voici à quoi ressemble la différence au niveau des méthodes :
| Méthode | Ce qu’elle fait | Forme de coût attendue |
|---|---|---|
| printOrdersWithCustomerEmail() | Lit la liste des commandes et accède en boucle à customer.email | 1 + N ou à peu près |
| printOrderList() | Lit la liste et ne conserve que les champs de l’entité racine | coût constant, souvent une requête racine |
| printOrderCard(orderId) | Lit une seule commande et ses données de fiche | petite constante sans croissance linéaire liée à la liste |
Si dans printOrderList() se sont glissés de nouveau getCustomer(), getItems().size() ou un toString() d’entité, vous n’avez pas séparé les scénarios — vous avez simplement renommé la méthode.
4. Étape 3 : ce que répare exactement la séparation list/card
Un tel refactoring supprime le parcours accidentel. Il corrige précisément le parcours de graphe non intentionnel dans les listes. Mais il est important de ne pas faire de promesses excessives.
| Ce qu’apporte le split | Ce qu’il ne promet pas |
|---|---|
| La liste cesse de tirer automatiquement customer et items par habitude | ne garantit pas automatiquement exactement une requête avec EAGER forcé |
| La fiche a le droit d’être plus profonde | n’annule pas le coût du mapping lui-même |
| L’avant/après devient vraiment comparable | ne guérit pas tout chargement superflu par une simple réorganisation des méthodes |
Si l’association est déjà imposée en EAGER ou si la valeur par défaut to-one rend la lecture plus coûteuse, la liste après cleanup peut encore traîner du superflu. Cela ne signifie pas que l’audit est inutile. Il a déjà montré : l’accès accidentel a été supprimé, et le coût restant réside dans l’approche de fetching du mapping lui-même.
5. Étape 4 : répétons la mesure SQL
Maintenant, l’avant/après est vraiment comparable. Nous ne mesurons pas deux mondes différents, mais le même service avant et après le cleanup.
Ci-dessous la forme typique du résultat sur le même jeu de données de seed :
| Scénario | Ce que montre habituellement le proxy de requêtes | Comment l’interpréter |
|---|---|---|
| baseline printOrdersWithCustomerEmail() | par exemple, 31 sur 30 commandes | une requête racine + des SELECT répétés sur les clients |
| printOrderList() | souvent 1 | le parcours accidentel a disparu, la liste est redevenue compacte |
| printOrderCard(orderId) | petite constante, par exemple, 2–3 | les détails sont isolés dans une seule fiche, pas étalés sur la liste |
Le plus important ici n’est pas le chiffre lui-même, mais la forme de croissance. Une mauvaise liste vit selon la formule 1 + N. Une bonne liste devient généralement constante. La fiche aussi peut faire plusieurs requêtes, mais ce n’est pas N+1, car son coût ne croît pas avec la longueur de la liste des commandes.
Et encore une remarque importante : list query proxy = 1 n’est pas une promesse universelle pour n’importe quel projet. Si après le cleanup la liste est encore plus chère que prévu, regardez la trace SQL. On y voit généralement s’il reste un coût au niveau du mapping : EAGER, secondary select ou un autre fetch imposé.
6. Mini-checklist pour une lecture prévisible
En fin de compte, il est utile de fixer non pas un ensemble d’annotations, mais un ensemble de questions que vous posez à la méthode de lecture avant même qu’elle ne commence à se comporter étrangement. Cela aide non seulement à corriger N+1, mais aussi à le prévenir lors du prochain refactoring.
Voici un tableau compact à garder en tête lors de la revue de code de toute méthode de lecture :
| Question pour la méthode de lecture | Si la réponse est « oui », qu’est-ce que cela signifie généralement |
|---|---|
| La méthode lit une liste d’entités racines ? | Tout accès à une association dans une boucle est candidat à N+1 |
| Y a-t-il à l’intérieur getCustomer(), getItems(), getDetails() ? | Vous avez peut-être mélangé liste et détails dans un même scénario |
| Après nettoyage, la liste lit-elle encore du superflu ? | Regardez le mapping et l’approche de fetching : peut-être que le coût est imposé par EAGER ou la valeur par défaut to-one |
| La méthode imprime/logue l’entité entière ( log.info("{}", o) ) | Vous pouvez déclencher accidentellement un chargement lazy via toString() |
| Y a-t-il une baseline de mesures (proxy de requêtes avant les changements) ? | Sans baseline, vous ne pouvez pas distinguer une amélioration d’un aléa |
| Une mesure après les changements a-t-elle été effectuée ? | Sinon, vous n’avez pas encore la preuve que la situation s’est améliorée |
Remarquez, il n’y a ici aucun mot sur « tout mettre en EAGER » ou « écrire tout de suite une requête astucieuse ». C’est intentionnel. Au niveau de cette leçon, nous obtenons l’effet de la manière la moins coûteuse : par la discipline du scénario.
Et c’est là qu’apparaît la question d’ingénierie suivante. Si les données associées entrent réellement dans le contrat de lecture, il ne s’agit plus d’interdire les associations en général, mais d’adopter une approche de fetching précise — JOIN FETCH, EntityGraph, @BatchSize et outils similaires. Seulement maintenant, vous avez au moins une baseline avec laquelle les comparer.
7. Erreurs typiques lors de l’audit de lecture
Dans l’audit de N+1, on retombe presque toujours sur les mêmes râteaux. Et la bonne nouvelle, c’est que ce sont des râteaux d’ingénierie, pas karmiques : si vous voyez un motif, vous pouvez l’attraper à l’avance.
Erreur n°1 : essayer de corriger N+1 globalement, sans sélectionner un seul scénario.
Lorsque le développeur court dans le projet et modifie tantôt le fetch, tantôt le logging, tantôt des repositories individuels, il se prive d’une baseline. Au final, on ne sait pas ce qui a aidé, et une semaine plus tard, le problème revient sous une nouvelle forme. L’audit ne fonctionne que lorsque vous fixez un point d’entrée et modifiez un seul facteur à la fois.
Erreur n°2 : changer à la fois le service, le runner et le jeu de données, puis appeler cela « avant/après ».
Si vous avez créé un nouveau OrderReadScenarios, un nouveau QueryCountDemo et en plus modifié les données de seed, vous n’avez plus de comparaison honnête. Vous mesurez une autre expérience. Il est beaucoup plus fiable de conserver le même service et le même point de mesure, et de ne modifier que le pattern d’accès.
Erreur n°3 : croire que la séparation list/card a eu lieu alors que la liste contient encore des getters d’associations.
La méthode peut s’appeler printOrderList(), mais si à l’intérieur il y a getCustomer(), getItems().size() ou un malencontreux toString(), c’est encore une fiche dans une boucle. Hibernate ne lit pas le nom de la méthode — il lit l’accès réel aux données.
Erreur n°4 : percevoir N+1 comme simplement « beaucoup de requêtes ».
Une fiche d’une seule commande peut faire 2–3 requêtes, et ce n’est pas N+1. Le vrai problème commence là où le nombre de requêtes augmente avec la taille de la liste. Par conséquent, regardez non seulement le nombre, mais aussi la forme de croissance.
Erreur n°5 : penser que le nettoyage du pattern d’accès supprime automatiquement tout chargement superflu.
Il supprime très bien le parcours accidentel, mais n’annule pas EAGER forcé ni d’autres décisions au niveau du mapping. Si la liste est encore coûteuse après le nettoyage, cela ne signifie pas que l’audit a échoué. Cela signifie que vous êtes arrivé à la question suivante, plus précise, concernant l’approche de fetching.
GO TO FULL VERSION