Une p99 qui dérape ne se règle pas en réécrivant du code au hasard. Elle se règle dans cet ordre, parce que c’est l’ordre du rapport gain sur effort.

Avant de toucher au code#

  • Tu mesures des percentiles, pas des moyennes. Une moyenne de 12 ms peut cacher 1 % de requêtes à 2 secondes.
  • p50, p95 et p99 sont sur trois courbes séparées. Elles ne bougent pas pour les mêmes raisons et n’ont pas les mêmes correctifs.
  • Les mesures sont prises sous charge réaliste, pas sur une requête isolée depuis ton poste.
  • Le test de charge dure au moins 60 secondes. Sur 5 secondes, tu mesures le cache, pas le service.
  • Tu as noté le chiffre de départ. Sans point de comparaison, impossible de dire si un correctif a servi.

Base de données, d’abord#

C’est là que se cachent la plupart des queues de distribution. Dans neuf cas sur dix, Rust n’est pas le goulot.

  • Pool dimensionné et temps d’attente d’acquisition mesuré. Un pool saturé ajoute une attente pure, invisible dans les logs applicatifs.
  • EXPLAIN ANALYZE sur les requêtes du chemin chaud, avec un volume réaliste.
  • Index composites alignés sur les requêtes réelles.
  • N+1 éliminés.
  • Les requêtes lentes sont tracées avec leur durée, pour les retrouver après coup.

Appels réseau sortants#

  • Timeout sur chaque appel externe. Par défaut, la plupart des clients HTTP attendent indéfiniment. Un service aval lent devient ta panne.
  • Un seul client HTTP réutilisé, pas un client construit à chaque requête : sinon c’est une poignée de main TLS complète à chaque fois.
  • Dégradation prévue quand l’appel échoue : cache, valeur par défaut, réponse partielle. Pas une 500.
  • Les retries sont bornés et réservés aux opérations idempotentes, avec du backoff exponentiel.

Sérialisation et allocations#

  • Les structures typées remplacent les serde_json::Value sur le chemin chaud.
  • Les grosses réponses sont paginées plutôt que compressées. Le moyen le plus rapide de sérialiser 4 Mo de JSON, c’est de ne pas le faire.
  • La compression est activée, mais pas sur les toutes petites réponses où elle coûte plus qu’elle ne rapporte.

Runtime et observabilité#

  • Aucun appel bloquant sur le runtime async. Hash de mot de passe, lecture de fichier, calcul lourd : tout part sur spawn_blocking.
  • Le niveau de log en production est info, avec sqlx et tower_http réduits au silence. Le tracing verbeux coûte facilement 10 % de latence.
  • Les spans de debug sont échantillonnés, pas émis à chaque requête.
  • Le binaire tourne en --release. Ça paraît évident, et pourtant.

Timeouts et protection#

  • Timeout global côté serveur, pour qu’une requête pathologique n’occupe pas un worker indéfiniment.
  • Taille de corps de requête limitée.
  • Rate limiting en place sur les routes coûteuses.
  • Arrêt gracieux testé : pendant un déploiement, les requêtes en vol se terminent au lieu d’être coupées. Un déploiement raté est la cause la plus banale d’un pic de p99.

Build#

  • lto = "thin" et codegen-units = 1 dans le profil release. Compilation plus lente, binaire plus rapide.
  • La mesure a été refaite après chaque correctif, un par un. Trois correctifs en même temps, et tu ne sais pas lequel a marché.

Le détail chiffré de chaque étape est dans comment j’ai réduit la p99 de 60 %.