Performance : Au-delà des moyennes (p50, p95, p99)

L'optimisation des performances est souvent résumée à un seul chiffre : la latence moyenne. Pourtant, se fier à la moyenne, c'est comme construire un pont qui ne supporte que le poids moyen d'une voiture : il s'effondrera au passage du premier camion.

En Go, comme dans tout système distribué, la performance réelle se mesure par la distribution de la latence, pas par son milieu.

Pourquoi la moyenne est un piège ?

Imaginez que vous ayez 100 000 utilisateurs quotidiens sur votre application.
Si votre latence "moyenne" est de 50ms, vous pourriez penser que tout va bien. Mais si votre p99 (le percentile 99) est de 5 secondes, cela signifie que :

  • 1% de vos requêtes sont extrêmement lentes.
  • Pour 100 000 utilisateurs, cela représente 1 000 utilisateurs qui subissent une expérience dégradée chaque jour.

Ignorer le p99, c'est accepter d'abandonner une partie de son audience à la frustration.

1. La sémantique des percentiles

Les percentiles classent vos mesures de la plus rapide (p0) à la plus lente (p100). C'est une carte de l'expérience utilisateur.

MétriqueNomCe qu'elle raconte
p50MédianeL'expérience de l'utilisateur "du milieu". 50% sont plus rapides.
p95Seuil de confortOn commence à voir les effets des micro-ralentissements système.
p99Tail LatencyLe 1% des requêtes les plus lentes. C'est ici que se cachent les vrais bugs d'infrastructure.

2. Pourquoi le considérer en Go ou l'ombre du Garbage Collector (GC)

En Go, la cause la plus fréquente d'un p99 catastrophique est le Garbage Collector (GC).

Pour libérer de la mémoire, le runtime de Go doit périodiquement arrêter l'exécution des Goroutines pour scanner les objets restants. C'est le fameux STW (Stop The World). Même si Go est optimisé pour des pauses de moins d'une milliseconde, une accumulation massive d'allocations éphémères peut forcer le GC à s'activer plus souvent ou plus longtemps.

C'est exactement ce type de comportement que nous allons simuler dans le benchmark suivant : un chemin nominal ultra-rapide pollué par une "grosse tâche" (cas 1%) qui fait exploser la latence de queue.


3. Le piège du Benchmark classique

L'outil standard go test -bench calcule une moyenne (ns/op). C'est un excellent outil pour comparer deux algorithmes, mais il est aveugle aux pics de latence sporadiques.

Pourquoi le benchmark "lisse" la réalité ?

Le package testing exécute votre code des milliers de fois (b.N) pour obtenir un chiffre stable. Ce processus de répétition a un effet pervers : il noie les anomalies dans la masse. Si 1 requête sur 100 prend 1 seconde alors que les autres prennent 1ms, la moyenne affichée par le benchmark restera trompeuse. Impressionnant sur le papier, mais catastrophique pour l'utilisateur qui subit la 1 seconde d'attente.

Exemple de comportement instable

 1package test
 2
 3import "time"
 4
 5func Work(i int) {
 6	if i%100 == 0 {
 7		// Simule un cas rare (1% du temps) :
 8		// Une requête complexe, un accès disque non-caché ou un cycle de GC.
 9		time.Sleep(time.Second * 1)
10	} else {
11		// Chemin nominal (99% du temps) :
12		time.Sleep(time.Millisecond)
13	}
14}
15
 1package test
 2
 3import (
 4	"testing"
 5)
 6
 7func BenchmarkWork(b *testing.B) {
 8	i := 0
 9	for b.Loop() {
10		i++
11		Work(i)
12	}
13}
14
1/usr/bin/go test -test.fullpath=true -benchmem -run=^$ -bench ^BenchmarkWork$ github.com/Chroq/christophe-lecroq.dev/cmd/test | benchstat -
2CWD: /home/chris/Projets/Personal/websites/christophe-lecroq.dev
3
4name      time/op        allocs/op
5Work-8   11.09m ± ∞ ¹    0.000 ± ∞ ¹
6¹ need >= 6 samples for confidence interval at level 0.95
1goos: linux
2goarch: amd64
3pkg: github.com/Chroq/christophe-lecroq.dev/cmd/test
4cpu: Intel(R) Core(TM) i7-8665U CPU @ 1.90GHz
5BenchmarkWork-8              100          11064645 ns/op               1 B/op          0 allocs/op
6PASS
7ok      github.com/Chroq/christophe-lecroq.dev/cmd/test 1.109s

Dans ce scénario :

  • Médiane (p50) : 1ms. La grande majorité de vos utilisateurs ne remarque rien.
  • Moyenne : ~11ms. Le benchmark (outil de moyenne) vous donne un chiffre qui semble "acceptable".
  • p99 : 1s. C'est la catastrophe invisible qui touche 1 000 personnes sur 100 000.

L'outil go test -bench verra une moyenne de 11ms et "lissera" le pic de 1s, rendant le bug indétectable sans une analyse de distribution.


4. Détecter le p99 en production : Observabilité et métriques

Le monitoring est la première ligne de défense. Si vous ne mesurez pas la distribution, vous ne pourrez pas isoler les pics de latence de vos utilisateurs.

L'outil roi : L'Histogramme

Contrairement aux moyennes, les histogrammes découpent vos mesures en "buckets" (seaux). Par exemple : 0-10ms, 10-50ms, 50-100ms, etc.

Avec Prometheus, on calcule le p99 via une fonction d'agrégation :

1histogram_quantile(0.99, sum by (le) (rate(http_request_duration_seconds_bucket[5m])))

Cela permet de reconstruire mathématiquement la distribution de latence à partir de vos buckets.

Implémentation via un Middleware Go

En Go, la détection commence souvent par un middleware qui enregistre le temps passé :

 1func LatencyMiddleware(next http.Handler) http.Handler {
 2    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 3        start := time.Now()
 4        next.ServeHTTP(w, r)
 5        duration := time.Since(start)
 6        
 7        // Enregistre la durée dans un histogramme (ex: Prometheus)
 8        metrics.HttpRequestDuration.Observe(duration.Seconds())
 9    })
10}

5. Isoler le "coupable" avec go tool trace

Quand votre p99 s'envole de manière inexpliquée, ce n'est souvent pas la faute de votre code métier, mais d'un événement système invisible :

  • STW (Stop The World) : Le Garbage Collector met votre programme en pause pour nettoyer la mémoire.
  • Contention de Mutex : Trop de Goroutines attendent le même verrou au même moment.
  • Starvation : Le scheduler Go n'arrive pas à donner du temps CPU à une routine critique.

Comment enquêter ?

Pour voir ce qui se passe durant ces micro-secondes de latence "fantôme", utilisez le package runtime/trace.

Étape 1 : Enregistrer la trace

1f, _ := os.Create("trace.out")
2trace.Start(f)
3defer trace.Stop()
4
5// Votre code à tester ici

Étape 2 : Analyser via l'interface web
Lancez la commande :

1go tool trace trace.out

Cela ouvre une interface web. Allez dans "View trace".

Ce qu'il faut chercher en priorité :

  • Barres bleues (Proc) : Si vous voyez des "trous" blancs, votre CPU est au repos forcé (attente réseau ou verrou).
  • Lignes de GC (Rouge) : Des barres verticales rouges indiquent une pause du Garbage Collector. Si elles sont fréquentes, vous allouez trop d'objets sur le tas (Heap).
  • Scheduler Wait Time : Si ce chiffre est élevé dans l'analyse des Goroutines, c'est que vos routines attendent trop longtemps avant d'être lancées par le processeur.

6. Petit lexique de la stabilité

  • Throughput (Débit) : Le nombre total de requêtes que vous pouvez traiter chaque seconde.
  • Jitter (Gigue) : La variation entre vos requêtes. Un système stable a un Jitter faible (le p50 est proche du p99).
  • Saturation : Le point de bascule où augmenter le trafic fait exploser le p99 sans augmenter le débit global. C'est le moment où le système commence à "ramer".