Comment j'ai réduit la latence p99 de 60 % sur un service Rust
Vrai playbook p99 : pooling, index, allocs JSON, coût du tracing, tuning Tokio, timeouts. De 220 ms à 85 ms de p99 sur Axum + Postgres.
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.7, Tokio, SQLx, Postgres 16, un seul VPS Hetzner à 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. - 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.
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.
Tu veux la checklist complète et le JSON du dashboard Grafana ? Gratuit dans la newsletter. Je fais aussi des sprints perf d’une semaine, benchmarks avant/après inclus.