Une API Node.js qui répondait en 200 ms au p95, ramenée à 25 ms. Ce qui compte dans cette histoire, ce n'est pas le chiffre final — c'est l'ordre dans lequel les choses ont été faites, parce que la moitié du gain était accessible avant même d'écrire la moindre ligne de code.
Le symptôme, et le piège tendu au départ
La demande initiale était formulée comme elles le sont presque toujours : « l'API est lente, on pense qu'il faut la réécrire ». C'est une phrase que j'entends souvent, et c'est presque toujours la bonne intuition sur le problème avec la mauvaise conclusion sur la solution.
Avant de toucher à quoi que ce soit, j'ai demandé une chose simple : sur quoi repose ce diagnostic ? La réponse tenait à des retours utilisateurs et à une impression générale de lenteur. Aucune mesure. C'est le point de départ le plus courant, et c'est aussi celui où l'on perd le plus d'argent, parce qu'une réécriture décidée sans mesure a toutes les chances d'optimiser quelque chose qui n'était pas le problème.
Étape 1 — Mesurer avant de décider
La première semaine n'a produit aucune amélioration visible, et c'était prévu. Elle a servi à installer de quoi observer :
- De la trace distribuée sur les routes principales, pour voir où le temps part réellement plutôt que là où on croit qu'il part.
- Des mesures en percentiles, pas en moyenne. La moyenne est une statistique confortable qui masque exactement ce que vos utilisateurs subissent : le p50 était à 45 ms, tout à fait honorable, pendant que le p95 tapait à 200 ms et le p99 dépassait la seconde.
- Le journal des requêtes lentes activé côté base de données, ce qui aurait dû l'être depuis le début.
Le verdict a été net et, disons-le, assez humiliant pour la théorie de la réécriture : sur les 200 ms du p95, environ 130 ms étaient passées à attendre la base de données. Le code applicatif n'était pas le problème principal. Il ne l'est presque jamais au premier tour.
Étape 2 — Les requêtes, là où se trouvait l'essentiel du gain
Trois problèmes classiques, qu'on retrouve dans une écrasante majorité des applications que j'audite :
Des requêtes N+1. Une liste chargeait sa collection principale, puis déclenchait une requête par élément pour aller chercher une relation. Sur vingt éléments affichés, vingt et un allers-retours réseau vers la base. Corrigé avec des jointures et un chargement groupé.
Des index manquants. Deux colonnes utilisées systématiquement en filtrage n'étaient pas indexées. Un EXPLAIN sur les requêtes lentes le montrait immédiatement — encore fallait-il le lancer. L'ajout des index a divisé le temps de ces requêtes par un facteur difficile à croire tant qu'on ne l'a pas mesuré soi-même.
Des colonnes récupérées pour rien. Plusieurs SELECT * ramenaient des champs volumineux dont l'API n'utilisait aucun. C'est de la bande passante et de la mémoire consommées sans contrepartie.
Résultat de cette seule étape : le p95 est passé de 200 ms à environ 80 ms. Aucun changement d'architecture, aucun nouveau composant à exploiter, aucune ligne de Rust. Juste du travail de base fait correctement.
Étape 3 — Le cache, avec discernement
Redis est arrivé ensuite, et j'insiste sur l'ordre : mettre un cache devant des requêtes mal écrites, c'est cacher la poussière sous le tapis. On masque le symptôme, on garde le problème, et on ajoute une source d'incohérence pour faire bonne mesure.
Ce qui a été mis en cache, ce sont les données qui changent peu et se lisent beaucoup — référentiels, configurations, agrégats coûteux à recalculer. Ce qui ne l'a pas été, ce sont les données propres à un utilisateur et tout ce dont l'obsolescence aurait eu des conséquences visibles.
Le vrai sujet du cache n'a jamais été la mise en cache : c'est l'invalidation. Nous avons choisi une invalidation explicite à l'écriture plutôt que des durées de vie courtes, parce qu'une donnée fausse pendant trente secondes reste une donnée fausse, et que le support finit toujours par en entendre parler.
Le p95 est descendu autour de 45 ms.
Étape 4 — Rust, en dernier et sur un périmètre restreint
Restait un point dur : un endpoint de calcul, réellement gourmand en CPU, qui tenait à lui seul le haut de la distribution. Là, et seulement là, le langage devenait le facteur limitant.
Ce service — un seul — a été réécrit en Rust derrière la même interface HTTP. Le reste du système n'a rien vu passer. C'est l'approche que je recommande systématiquement : on remplace un organe, pas le corps entier.
Le p95 global est arrivé à 25 ms, avec au passage une consommation mémoire nettement inférieure sur ce service et, ce qui compte souvent davantage, un p99 devenu prévisible.
Ce que ça a coûté, et ce que je n'ai pas fait
La partie qu'on oublie systématiquement dans ce genre de récit :
- La semaine d'instrumentation n'a produit aucun gain visible. C'est le moment le plus inconfortable d'une mission de ce type, celui où il faut tenir bon face à l'envie légitime de voir des résultats.
- Le cache a ajouté un composant à exploiter : une dépendance de plus, un mode dégradé à penser, une invalidation à maintenir. Ce n'est jamais gratuit.
- Le service en Rust a introduit une seconde technologie dans une équipe qui n'en maîtrisait qu'une. C'était assumé et cadré, mais c'est une dette d'équipe réelle.
Et surtout, ce que je n'ai pas fait : je n'ai pas réécrit l'API. Elle est toujours en Node.js aujourd'hui, à un service près. La demande initiale portait sur une réécriture complète, et la refuser était la partie la plus utile du travail.
La méthode, si vous devez n'en retenir qu'une chose
- Mesurez en percentiles, jamais en moyenne, et acceptez que cette étape ne produise rien de visible.
- Regardez la base de données en premier. C'est là que se trouve le gros du gain dans la grande majorité des cas, et c'est l'étape la moins coûteuse.
- Cachez ensuite, et seulement ce qui le mérite, avec une stratégie d'invalidation décidée à l'avance.
- Changez de langage en dernier, sur un périmètre restreint, et uniquement là où la mesure démontre que c'est le facteur limitant.
Chacune de ces étapes coûte plus cher que la précédente. Les faire dans l'ordre, c'est s'assurer d'obtenir la majorité du résultat pour une fraction du budget — et de garder l'option d'arrêter dès que c'est assez bon.
En résumé
Le facteur huit sur cette API n'a rien d'un exploit technique : c'est le résultat d'une méthode appliquée dans le bon ordre. La partie spectaculaire, la réécriture en Rust, a produit moins de la moitié du gain total et représentait l'essentiel du risque. La partie ennuyeuse — des index, des jointures, des colonnes en moins — a produit le reste pour un coût dérisoire.
C'est vrai à peu près partout, et c'est pour ça que je commence toujours par un audit plutôt que par une proposition de refonte : dans la plupart des cas, la refonte n'est pas nécessaire.
Une API qui traîne, des utilisateurs qui s'en plaignent, et l'impression qu'il faudrait tout reprendre ? Commençons par mesurer — c'est souvent moins grave, et beaucoup moins cher, qu'on ne le craint.