Un service Rust à 220 ms de p99 qui aurait dû être rapide. Après une semaine : 85 ms de p99 (moins 60 %), même hardware, même Postgres. Voici exactement ce qui a compté, dans l’ordre.
Stack : Axum 0.8, Tokio, SQLx, Postgres 16, un seul VPS à 12 € + Postgres managé. Charge : ~2k rps mixtes lecture/écriture. Mesuré avec
ohaet des histogrammes Prometheus (pas des moyennes !).
0. Mesure la p99, pas la moyenne#
La moyenne cache les queues de distribution. J’ai ajouté :
// histogram, not counter
metrics::histogram!("http_request_duration_seconds", latency);
Et j’ai dashboardé p50, p95 et p99 séparément. La p50 était déjà à 12 ms. Correct. La p99 était le problème. Des correctifs différents ciblent des percentiles différents.
Correctif 1 : pool et index manquant (moins 35 % de p99)#
Deux classiques empilés :
- Pool à
max_connections(50)face à un petit Postgres : contention et file d’attente. Descendu à 12, avecacquire_timeout(3s). Le temps de file a disparu. (Le setup du pool est détaillé dans le guide Axum + Postgres.) - Un endpoint faisait
WHERE user_id = $1 ORDER BY created_at DESCsans index composite.EXPLAIN ANALYZEmontrait un seq scan sous concurrence.
CREATE INDEX CONCURRENTLY idx_tasks_user_created
ON tasks (user_id, created_at DESC);
Résultat : p99 de 220 à 145 ms. Ennuyeux et énorme. Vérifie toujours la BDD en premier. Rust est rarement le goulot.
Correctif 2 : requêtes N+1 (moins 15 % de p99)#
Une route chargeait les tâches puis bouclait un SELECT par assigné. 1 + 47 requêtes par appel à p99.
-- avant : N+1 dans une boucle Rust
-- après : un seul JOIN
SELECT t.id, t.title, u.name AS assignee
FROM tasks t LEFT JOIN users u ON u.id = t.assignee_id
WHERE t.project_id = $1;
Avec query_as! de SQLx, un seul aller-retour. p99 de 145 à 122 ms. pg_stat_statements et les spans de tracing par requête ont rendu ça évident.
Correctif 3 : allocs JSON sur le chemin chaud (moins 10 % de p99)#
Le profiling avec flamegraph montrait 18 % du temps dans le churn de serde_json::Value : parse, Value, resérialisation. Correctif : désérialiser direct dans des structs, utiliser bytes::Bytes pour le passthrough, activer CompressionLayer seulement pour les gros bodies.
// mal : Json<Value> puis bidouille
// bien : DTO typé, borrow quand possible
#[derive(Deserialize)]
pub struct Ingest<'a> { #[serde(borrow)] pub title: &'a str }
p99 de 122 à 108 ms. Petit mais gratuit.
Correctif 4 : coût du tracing et des logs (moins 8 % de p99)#
Niveau TRACE avec des jolis logs en prod et des bodies complets loggués. Passé à :
tracing_subscriber::fmt().json()
.with_max_level(tracing::Level::INFO)
.with_env_filter("myapp=info,tower_http=warn,sqlx=warn")
.init();
Spans de debug échantillonnés, info! par ligne supprimés. p99 de 108 à 95 ms. L’observabilité ne devrait pas coûter 10 %.
Correctif 5 : timeouts et dégradation gracieuse (le reste)#
Sans timeouts, un seul downstream lent empoisonne tous les workers. Ajouté :
use tower_http::timeout::TimeoutLayer;
use std::time::Duration;
// par route : appels externes 2s, acquisition BDD 3s
Plus tokio::time::timeout sur les appels reqwest sortants avec fallback sur le cache. p99 de 95 à 85 ms, et la p99.9 a arrêté de monter à plusieurs secondes. Les crates concernées (tower-http, metrics, reqwest) sont listées dans les meilleures crates Rust pour une API.
La méthode, pour que tu puisses refaire la mesure#
Un chiffre publié sur un blog ne vaut rien si tu ne peux pas le reproduire. Voici exactement comment j’ai mesuré, avec ce que ça implique.
Charger, puis attendre#
# 60 secondes minimum. Sur 5 secondes, tu mesures le cache et le JIT du noyau.
oha -c 100 -z 60s --latency-correction --disable-keepalive \
http://127.0.0.1:8080/tasks
--latency-correction compte le temps d’attente dans la file du client. Sans
lui, un serveur saturé affiche une latence flatteuse : les requêtes qui
attendent de partir ne sont pas comptées. C’est le piège classique, il porte un
nom, l’omission coordonnée.
Le --disable-keepalive est discutable. Il rend la mesure plus dure que la
réalité, puisque de vrais clients réutilisent leurs connexions. Je le garde pour
les comparaisons avant et après, parce qu’il rend les résultats plus stables.
Mesurer côté serveur aussi#
Le client mesure le temps total, réseau compris. Pour savoir où part le temps, il faut l’histogramme côté serveur.
// Un histogramme, pas un compteur : les percentiles se calculent dessus.
metrics::histogram!("http_request_duration_seconds").record(latency);
L’écart entre la p99 vue par le client et celle vue par le serveur dit quelque chose en soi. S’il est grand, le problème est dans la file d’attente, pas dans le traitement.
Changer une chose à la fois#
C’est la règle la plus ennuyeuse et la plus souvent violée. J’ai appliqué les cinq correctifs un par un, avec une mesure entre chaque. Sur les deux premiers essais, j’avais tout appliqué d’un coup : la p99 avait baissé, et j’étais incapable de dire ce qui avait servi. Deux correctifs sur les cinq n’apportaient presque rien sur cette charge précise.
Ce qui n’a rien donné#
Trois pistes explorées qui n’ont pas bougé l’aiguille ici, et qui reviennent pourtant dans toutes les discussions.
Passer l’allocateur à jemalloc : moins de 2 % d’écart, dans le bruit de mesure. Ça compte sur des charges très allocantes, pas sur celle-ci.
Augmenter le nombre de threads du runtime Tokio au-delà du nombre de cœurs : aucun gain, et une légère dégradation de la p99.9. Le service attendait sur Postgres, pas sur le CPU.
Remplacer serde_json par simd-json : un gain réel sur le parsing de gros
corps, invisible sur des réponses de quelques kilo-octets. Mesure la taille de
tes payloads avant d’aller là.
Checklist (à voler)#
- Dashboarde p50/p95/p99 séparément
- Pool à 10-20,
acquire_timeout, surveille la profondeur de file EXPLAIN ANALYZEsur les 5 top requêtes, ajoute des index composites- Tue les N+1 (join ou
WHERE id = ANY($1)) - JSON typé, évite
Valuesur les chemins chauds - Logs JSON en INFO, fais taire le bruit
sqlxettower_http - Timeouts partout et compression sélective
--release+lto="thin"+codegen-units=1pour les builds prod- Teste la charge avec
oha -c 100 -z 60s, pas 5s
Total : 220 à 85 ms de p99. Pas de réécriture, pas de nouvelle infra, juste de la mesure et des correctifs ennuyeux.
La version imprimable de cette liste est sur la checklist p99, avec l’ordre de rentabilité et les pièges de mesure.