Architecture logicielle
Performance applicative : latence, percentiles, profilage et la JVM
Avancé12 min de lectureMis à jour
Cette page a été traduite de l'anglais avec l'aide de l'IA, puis vérifiée par rapport à l'original. Lire la version anglaise
Le travail de performance échoue quand il part d'opinions sur ce qui est lent. Ce guide pose les mesures qui décrivent honnêtement un système — latence et débit, percentiles, taux d'utilisation et files d'attente —, passe en revue les endroits où un service JVM perd réellement du temps, et se termine par une boucle d'optimisation qui produit des preuves plutôt que du folklore.
Ce que vous allez apprendre
- Distinguer latence, débit et concurrence, et les relier par la loi de Little
- Lire une distribution de percentiles et expliquer pourquoi les moyennes masquent les problèmes que vivent les utilisateurs
- Profiler un service en cours d'exécution pour savoir où partent réellement le temps et les allocations
- Diagnostiquer les causes habituelles de lenteur : requêtes N+1, pool épuisé, pression sur le GC, contention de verrous
- Mettre en place du cache et des limites de concurrence sans créer de problèmes d'exactitude ou de stabilité
Presque toutes les investigations de performance qui tournent mal commencent de la même façon : quelqu'un dit que l'application est lente, quelqu'un d'autre a une théorie, et une semaine s'envole à optimiser quelque chose qui n'a jamais été sur le chemin critique. La discipline qui évite cela n'a rien de glorieux. Mesurez ce que vivent les utilisateurs, trouvez, preuves à l'appui, où part le temps, changez une seule chose, mesurez à nouveau, puis gardez ou annulez en fonction du chiffre et non de la théorie.
Ce guide porte sur cette boucle et sur les endroits où un service JVM typique perd réellement du temps. Il traite du diagnostic et de l'optimisation d'un système que vous pouvez observer ; la construction de la charge qui révèle les problèmes avant la production est couverte dans la méthodologie des tests de charge.
Les trois grandeurs et leurs relations
La latence, c'est la durée d'une opération. Le débit, c'est le nombre d'opérations terminées par unité de temps. La concurrence, c'est le nombre d'opérations en cours au même instant. Ces grandeurs ne sont pas indépendantes, et la loi de Little les relie exactement :
L = λ × W
L = average number of requests in the system (concurrency)
λ = average arrival rate (throughput)
W = average time in the system (latency)
La loi vaut pour tout système stable, quel que soit son fonctionnement interne, ce qui en fait un contrôle de cohérence remarquablement efficace. Un service qui traite 500 requêtes par seconde avec une latence moyenne de 200 ms a, en moyenne, 100 requêtes en cours : un pool de 50 threads est donc un plafond infranchissable, et c'est dans la file d'attente devant lui que le temps se perd. Dans l'autre sens, elle dimensionne pour vous : pour servir 2 000 requêtes par seconde à 50 ms, il vous faut 100 emplacements concurrents, et si chacun tient une connexion à la base, vous venez d'apprendre quelque chose d'important sur le pool.
La seconde relation à bien intégrer, c'est ce qui se passe quand le taux d'utilisation monte. Pour un système simple, la théorie des files d'attente donne un temps d'attente proportionnel à ρ / (1 − ρ), où ρ est le taux d'utilisation. À 50 % d'utilisation, la file ajoute à peu près autant que le temps de service lui-même ; à 90 %, neuf fois plus ; à 95 %, dix-neuf fois. Voilà pourquoi une ressource à 80 % paraît saine sur un tableau de bord tout en se comportant très mal, et pourquoi les plans de capacité qui visent un fort taux d'utilisation produisent des systèmes qui s'effondrent au moindre pic de trafic.
La conséquence pratique : trouvez le goulot d'étranglement, la ressource la plus proche de la saturation, car rien d'autre ne compte tant qu'il n'a pas bougé. Optimiser autre chose que le goulot ne change rien de mesurable, et une fois qu'il est corrigé, le goulot se déplace ailleurs — c'est le résultat normal et attendu, pas un échec.
Mesurer des percentiles, jamais des moyennes
Les temps de réponse suivent une distribution asymétrique à longue traîne : des statistiques de synthèse choisies sans soin décrivent un système que personne n'utilise.
| Statistique | Ce qu'elle vous apprend |
|---|---|
| Moyenne | Presque rien ; masque totalement la traîne et la bimodalité |
| Médiane (p50) | L'expérience typique |
| p90 / p95 | Le seuil où les utilisateurs commencent à le remarquer |
| p99 | La traîne que vos utilisateurs les plus actifs subissent en permanence |
| p99.9 | L'endroit où apparaissent les pannes d'infrastructure et les pauses du GC |
| Max | Un seul point de mesure ; utile uniquement comme alarme |
Un utilisateur dont la page déclenche vingt appels backend tombe sur votre p95 avec une probabilité proche de deux tiers. La latence de traîne n'est donc pas un cas limite : pour les pages composites, c'est le cas courant, et c'est pourquoi « la latence de traîne domine » est la formule la plus utile du travail de performance.
Les percentiles doivent être calculés à partir d'un histogramme, et non d'un échantillon de « requêtes lentes », et sur une fenêtre explicite : un p99 sur 24 heures dissimule une heure de calvaire. Agrégez en additionnant les buckets d'histogramme entre instances ; ne faites jamais la moyenne de quantiles précalculés. Et méfiez-vous de la coordinated omission, ce biais de mesure où un client qui attend une réponse avant d'envoyer la suivante cesse d'échantillonner précisément quand le système est au plus lent, ce qui rend les percentiles rapportés bien meilleurs que la réalité.
Enfin, rattachez les chiffres à quelque chose qui a du sens : un objectif de niveau de service (SLO) formulé du point de vue de l'utilisateur, par exemple « 99 % des requêtes de checkout se terminent en moins de 800 ms, mesuré sur 28 jours ». La performance cesse alors d'être une opinion pour devenir un budget que vous dépensez délibérément.
Trouver où part le temps
Une fois la cible définie, la question devient : où sont les millisecondes ?
Partez de la périphérie et avancez vers l'intérieur. Une trace distribuée d'une requête lente montre quel saut a consommé le temps : l'application, la base de données, un service en aval, ou le réseau entre eux. Sans traçage, des heures disparaissent dans le mauvais service. L'instrumentation nécessaire est décrite dans Actuator et observabilité.
Ensuite, profilez le processus. Deux types de profileurs répondent à des questions différentes, et il vous faut les deux :
- Un profileur CPU échantillonne les piles d'appels pour montrer où est dépensé le temps de calcul. Les profileurs par échantillonnage modernes parcourent les piles de façon asynchrone, ce qui évite le biais de safepoint qui conduisait les anciens outils à désigner des méthodes totalement fausses. Lisez le résultat sous forme de flame graph : la largeur représente le temps total, donc cherchez les plateaux larges, pas les tours hautes.
- Un profileur d'allocations montre quels points d'appel créent des objets. Les allocations pilotent le garbage collection, et le temps de GC est souvent attribué à tort au code qui s'exécutait à ce moment-là.
Java Flight Recorder est le choix pragmatique par défaut sur la JVM : il est intégré, son surcoût tourne autour de 1 % avec le profil par défaut, et il peut être activé sur un processus de production pendant une fenêtre limitée.
# Attach to a running service and record for two minutes.
jcmd <pid> JFR.start name=diag settings=profile duration=120s filename=/tmp/diag.jfr
# Cheap first look while you wait: what are the threads doing?
jcmd <pid> Thread.print | grep -A 5 'java.lang.Thread.State: BLOCKED'
# GC behaviour at a glance.
jstat -gcutil <pid> 1000 30
Lisez les thread dumps pour ce que les profileurs ne voient pas. Trois dumps espacés de dix secondes en disent bien plus qu'un seul. Des threads toujours BLOCKED sur le même moniteur signalent une contention de verrou ; des threads WAITING sur le getConnection d'un pool signalent un pool épuisé ; des threads dans Socket.read signifient que vous attendez quelqu'un d'autre et que le problème n'est pas chez vous.
Là où se trouve généralement le temps
D'un service JVM à l'autre, on retrouve toujours la même poignée de causes.
Accès à la base de données
C'est presque toujours le premier endroit à examiner, et en général le plus gros gain à lui seul.
Requêtes N+1. Un ORM charge 200 commandes, puis émet une requête par commande pour en récupérer les lignes. Dans un test avec trois enregistrements, c'est invisible ; en production, cela fait 201 allers-retours. La correction passe par un join fetch, un entity graph ou une seconde requête groupée — et la façon de le détecter, c'est de compter les requêtes SQL par requête HTTP dans les tests, pas d'espérer. Les mécanismes côté JPA sont couverts dans Spring Data JPA.
Index manquants ou inutilisables. Lisez le plan d'exécution au lieu de deviner. Un parcours séquentiel sur une grosse table dans un chemin OLTP est un défaut ; tout comme un index inutilisable parce que le prédicat enveloppe la colonne dans une fonction.
EXPLAIN (ANALYZE, BUFFERS)
SELECT o.id, o.total
FROM orders o
WHERE o.customer_id = 4711
AND o.created_at >= now() - interval '90 days'
ORDER BY o.created_at DESC
LIMIT 50;
-- Look for: Seq Scan on a large table, rows removed by filter, an estimate
-- far from the actual count, and sorts spilling to disk.
Over-fetching. Sélectionner toutes les colonnes de toutes les lignes pour afficher quatre champs, ou paginer par offset très loin dans un grand jeu de résultats. Ne projetez que ce dont vous avez besoin.
Épuisement du pool. Les threads qui attendent une connexion se traduisent par de la latence dans l'application alors que la base semble inactive. Suivez le temps d'attente du pool comme une métrique de premier plan, et rappelez-vous qu'agrandir le pool aggrave généralement le problème au lieu de le résoudre.
Transactions ouvertes pendant des appels distants. Une méthode @Transactional qui appelle une API HTTP garde une connexion à la base et ses verrous pendant toute la durée de cet appel. Faites le travail distant en dehors de la transaction.
Mémoire et garbage collection
Le heap de la JVM est organisé en générations parce que la plupart des objets meurent jeunes. Les collectes de la jeune génération sont peu coûteuses et fréquentes ; c'est le travail sur l'ancienne génération qui provoque des pauses perceptibles.
Diagnostiquez avant de régler. Activez les logs du GC et regardez trois choses : la part du temps réel passée en GC (au-delà d'environ 5 %, cela mérite une enquête), la distribution des temps de pause par rapport à votre objectif de latence, et la tendance à la hausse ou non du heap vivant après chaque collecte de l'ancienne génération — la signature d'une fuite.
-Xlog:gc*,safepoint:file=/var/log/app/gc.log:time,uptime:filecount=5,filesize=20M
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/
La plupart des problèmes de GC sont des problèmes d'allocation. Un service qui alloue 2 Go par seconde pour construire des chaînes et des collections intermédiaires collectera sans arrêt, quel que soit le collecteur choisi, et réduire les allocations règle le problème pour de bon. Quand le choix du collecteur est vraiment en cause : G1 est un bon choix par défaut, équilibré entre débit et temps de pause ; ZGC et Shenandoah visent des pauses inférieures à la milliseconde au prix d'un peu de débit et d'une empreinte plus importante, et se justifient quand la latence p99.9 est dominée par les pauses. Un heap généreux réduit la fréquence des GC, mais allonge chaque collecte et accroît la concurrence avec le cache de pages — et dans un conteneur, fixez toujours explicitement la taille du heap ou vérifiez que la JVM tient compte du conteneur, car une limite que la JVM ne voit pas se transforme en OOM kill sans le moindre heap dump.
Concurrence et contention
La contention de verrous se manifeste par une latence qui augmente avec la concurrence alors que le CPU reste stable. Cherchez des blocs synchronized autour de caches ou de compteurs partagés, des instances uniques partagées d'objets coûteux, et une journalisation configurée en synchrone vers un appender lent. Les corrections, par ordre de préférence : supprimer le partage, réduire la section critique, remplacer le verrou par une structure de données concurrente ou une variable atomique, ou partitionner les données pour que les threads se disputent des segments différents.
L'échec inverse, c'est la concurrence non bornée : un executor doté d'une file illimitée accepte du travail indéfiniment, si bien qu'un pic devient un problème de mémoire et que chaque requête en file finit de toute façon par expirer. Bornez chaque file et rejetez tôt.
Sérialisation, réseau et travail inutile
Les appels distants trop bavards, la sérialisation JSON de payloads énormes, les réglages de compression et les handshakes TLS sur des connexions qui auraient dû être mutualisées apparaissent dans les profils plus souvent qu'on ne le croit. Le travail redondant aussi : calculer cinq fois la même valeur par requête, journaliser en debug dans une boucle critique, ou construire un graphe d'objets que l'appelant jette.
Le cache, avec prudence
Le cache est l'optimisation au meilleur effet de levier, et celle dont les modes de défaillance sont les pires, car un cache transforme un problème de performance en problème d'exactitude.
Couche par couche : le navigateur et le CDN gèrent les ressources statiques et les réponses GET cachables à l'aide des validateurs présentés dans la conception d'API REST ; un cache distribué comme Redis conserve les résultats calculés partagés ; un cache local en mémoire du processus conserve des données chaudes, petites et surtout lues, avec la latence la plus faible possible ; et le buffer pool de la base de données met les pages en cache sous tout le reste.
Les décisions qui déterminent si le cache aide vraiment :
- Quel degré d'obsolescence est acceptable ? Si la réponse est « aucun », ce n'est pas un cache qu'il vous faut, c'est une requête plus rapide.
- Stratégie d'expiration. L'expiration temporelle est simple et prévisible ; l'invalidation explicite à l'écriture est précise mais facile à rater ; un TTL court combiné à l'invalidation est un compromis robuste.
- Une taille bornée avec une politique d'éviction sensée, car un cache local non borné n'est qu'une fuite mémoire qui porte un nom sympathique.
- Protection contre le cache stampede. Quand une clé populaire expire, des centaines de requêtes la manquent en même temps et recalculent toutes. Utilisez un verrou par clé, ou servez le contenu périmé pendant qu'un seul appelant le rafraîchit.
- Mesurez le taux de hit. Un cache en dessous d'environ 80 % de hits ajoute souvent de la latence et de la complexité pour très peu, et vous devez le savoir plutôt que le supposer.
L'abstraction de cache de Spring et ses fournisseurs sont présentés dans le cache.
La boucle d'optimisation
Définir la cible
Choisissez une opération visible par l'utilisateur et un chiffre : « p95 de GET /orders sous 300 ms à 400 requêtes par seconde ». Sans cela, impossible de savoir quand s'arrêter.Reproduire sous une charge représentative
Une charge qui reflète la répartition des requêtes, le volume de données et la concurrence de la production. Optimiser contre une base vide ne vous apprend rien.Mesurer et localiser
Tracez pour trouver le saut lent, profilez pour trouver le code lent, et identifiez la ressource qui constitue le goulot d'étranglement.Formuler une hypothèse avec un effet prévu
« Regrouper ces 40 requêtes en une seule devrait faire gagner environ 120 ms. » Une prédiction qui se révèle fausse est une information ; un changement sans prédiction est un pari.Changer une seule chose
Un changement par mesure, sinon vous ne saurez pas lequel a compté — et l'un d'eux a peut-être empiré les choses.Mesurer à nouveau et décider
Comparez à la référence sur la même charge. Gardez le changement s'il a fait bouger le chiffre, annulez-le sinon, et consignez les deux résultats.Recommencer jusqu'à atteindre la cible, puis s'arrêter
Le goulot se sera déplacé. Ne continuez que tant que la cible n'est pas atteinte ; optimiser au-delà d'un objectif atteint, c'est payer sans rien gagner.
Deux habitudes rendent la démarche durable. Gardez le benchmark dans le pipeline, pour qu'une régression soit détectée par le build plutôt que par un client. Et notez ce que vous avez essayé et ce qui s'est passé, échecs compris — sinon, la prochaine personne qui se penchera sur ce système les répétera tous.
Points clés à retenir
- Latence, débit et concurrence sont liés par la loi de Little, et les effets de file d'attente font qu'une ressource utilisée à plus de 80 % est déjà en difficulté.
- Rapportez des percentiles issus d'histogrammes fusionnés sur une fenêtre explicite, jamais des moyennes ni des moyennes de quantiles ; pour les pages à appels multiples, la traîne est l'expérience courante.
- Tracez d'abord pour trouver le saut lent, puis profilez le CPU et les allocations, et lisez plusieurs thread dumps pour repérer contention et épuisement de pool ; l'accès à la base de données — requêtes N+1, index manquants, over-fetching, pools de connexions épuisés — est le goulot le plus fréquent et le moins cher à corriger.
- La plupart des problèmes de GC sont des problèmes d'allocation : diagnostiquez à partir des logs du GC et réduisez les déchets avant de changer de collecteur, et fixez toujours explicitement le heap dans un conteneur.
- N'ajoutez un cache qu'après avoir mesuré, avec une tolérance d'obsolescence explicite, une taille bornée, une protection contre le stampede et un taux de hit surveillé.
- Optimisez en boucle : définir la cible, reproduire, localiser, prédire, changer une seule chose, mesurer, et s'arrêter une fois l'objectif atteint.
Sources et lectures complémentaires
Questions fréquentes
- Pourquoi le temps de réponse moyen est-il trompeur ?
- Parce que les temps de réponse ne sont pas distribués de façon symétrique. Un service avec une moyenne de 40 ms peut très bien servir une requête sur vingt en plus d'une seconde, et ces requêtes lentes touchent en priorité les utilisateurs qui font les choses les plus précieuses : les plus gros paniers, les plus gros portefeuilles. La moyenne masque aussi complètement un comportement bimodal : des hits de cache à 5 ms et des miss à 900 ms donnent une moyenne qu'aucune requête ne vit jamais.
- Puis-je faire la moyenne des percentiles de plusieurs serveurs ?
- Non. La moyenne des p99 de chaque nœud n'est pas le p99 du service et peut être très fausse. Exportez les buckets d'histogramme de chaque instance, additionnez-les de façon centralisée et calculez le percentile à partir de l'histogramme fusionné. La plupart des systèmes de métriques le permettent ; si le vôtre publie des quantiles précalculés par instance, ils ne peuvent pas être agrégés de manière pertinente.
- Quelle taille donner à un pool de connexions à la base de données ?
- Bien plus petite que ce que l'on imagine en général. Les connexions au-delà de ce que la base peut exécuter en parallèle ne font que patienter, ce qui ajoute de la latence sans débit supplémentaire, et un pool trop grand permet à l'application de submerger la base. Partez de la capacité parallèle de la base — à peu près le nombre de cœurs plus le nombre effectif de disques —, dimensionnez le pool autour de cette valeur, puis vérifiez-le en l'augmentant : le débit ne progresse plus alors que la latence se dégrade.
- Quand faut-il ajouter un cache ?
- Une fois que vous avez mesuré que le même résultat coûteux est calculé encore et encore, et que vous savez dire quel degré de fraîcheur est acceptable. Un cache est un risque pour l'exactitude et une dépendance d'exploitation : il doit découler d'une mesure, pas d'une intuition. Corriger une requête N+1 ou ajouter un index supprime souvent tout besoin de cache, sans rien coûter en cohérence.