Granit, ma plateforme de surveillance, a une partie visible : ses pages de monitoring. Des tableaux de bord qui affichent l’état des sondes, les incidents en cours, les budgets d’erreur des SLO. Et ces pages-là étaient lentes. L’aperçu d’un projet pouvait mettre seize secondes à se dessiner.
Vous avez déjà fixé une page blanche pendant seize secondes, à vous demander si elle est cassée ou juste lente ? Moi oui, sur ma propre application, tous les matins.
J’avais l’impression d’être beaucoup plus vieux entre la demande d’affichage de la page et son premier pixel…
La cause se voyait dans l’onglet réseau : chaque page allait chercher ses données en plusieurs fois. La liste des checks ici, leurs dernières exécutions là, les incidents dans un troisième appel, les SLO dans un quatrième. Trois à six allers-retours par page, parfois plus.
Ces seize secondes n’étaient pas un accident, plutôt une trace. Pendant des semaines, j’avais itéré vite sur ces pages, à la recherche du bon fonctionnel : quelles infos montrer, dans quel ordre, sous quelle forme. Tant que tout bougeait, chaque page se servait au plus simple, en réclamant ce dont elle avait besoin au coup par coup.
Optimiser à ce stade aurait été prématuré : on ne fait pas sécher une forme qu’on est encore en train de modeler.
Un peu de mauvaise foi, je l’avoue : même en bâclant, on ne tombe pas à seize secondes par accident.
En même temps, en local, je n’ai pas presque deux mille checks qui tournent toutes les trente secondes depuis plus de trois mois. La base commence à être à l’étroit dans sa chemise.
Un seul interlocuteur
Puis la forme a séché. Les pages ont arrêté de bouger, le fonctionnel s’est posé. Là, optimiser a un sens, pas avant. Et le premier geste, c’est de verrouiller ce que chaque page a vraiment besoin d’afficher, pour cesser d’aller chercher plus de données qu’elle n’en montre.
L’idée du BFF (Backend For Frontend) tient en une phrase : au lieu de laisser la page mendier ses données service par service, je lui donne un seul interlocuteur qui rassemble tout ce qu’elle réclame et le rend d’un bloc.
Faire cinquante requêtes par page, c’est un effet de bord des frameworks front multi-composants : chacun fait sa compote dans son coin et va chercher ses pommes au garde-manger tout seul. Sauf qu’avec une seule porte pour cinquante compotes, ça se bouscule au portillon.
Plutôt que d’intercaler un serveur entre le backend et le frontend (l’approche BFF classique, orientée multi-API), j’ai simplement ajouté des endpoints spécialisés pour le front. Mon backend Rust gère déjà l’authentification, les permissions, le cloisonnement par organisation. Ajouter une couche par-dessus, c’était une latence de plus pour rien.
Le BFF, chez moi, c’est un endpoint de plus dans le même serveur : GET /projects/:id/overview, qui retourne en une fois ce que la page allait chercher en cinq.
(les puristes me feront des gnagnagna, mais ma couche intermédiaire entre les data layers et le front, c’est cette API backend spécialisée).
La page
- le tableau de bordchecks, incidents, SLO et heartbeats, tout en même temps
Le BFF
- GET /projects/:id/overviewce que la page cherchait en trois à six appels, rendu en une réponse
La base
- des requêtes bornéestop-N par sonde en LATERAL, fenêtre de trente jours, vieilles partitions ignorées
La lenteur a juste changé de pièce
Première mesure, l’agrégation en place : seize secondes tombées à quatre et demie. Une vraie victoire. Sauf que quatre secondes et demie pour afficher une page, c’est encore une éternité.
Et là, le piège du BFF m’a sauté à la figure. Regrouper les appels n’avait pas supprimé le travail, il l’avait déplacé. Là où le navigateur faisait cinq allers-retours sur le réseau, mon endpoint en faisait maintenant cinq sur la base, à la suite. J’avais débarrassé le salon en entassant tout dans la cave.
On ne range pas en changeant de pièce.
(et si vous vous arrêtez à l’agrégation d’API, vous héritez du même problème).
Descendre jusqu’à la requête
Il a donc fallu ouvrir la cave. Pour chaque morceau de l’endpoint, la même méthode, têtue : suivre le chemin depuis la page jusqu’à la requête SQL, et poser un EXPLAIN ANALYZE dessus. (C’est l’outil de Postgres qui rejoue une requête et dit, étape par étape, où le temps s’en va.)
Pas sur trois lignes de test : sur du vrai volume, des années de données (du moins, sur Granit c’est plutôt des mois).
Le coupable principal, une requête au doux nom de fetch_recent_executions_bulk, allait chercher les dernières exécutions de chaque sonde. Sa méthode : trier l’historique entier par date, puis ne garder que les toutes premières lignes de chaque sonde. Tout remuer pour ne garder presque rien.
Le vrai problème tient à la façon dont ces exécutions sont rangées. La table est découpée en tranches, une par mois (le terme technique, c’est partitionnée). C’est voulu : ça permet à la base d’ignorer les vieux mois quand on ne les demande pas. Sauf que ma requête ne donnait aucune date. Alors la base ouvrait toutes les tranches, depuis la toute première, et entassait des années d’exécutions sur le disque pour les trier. Des mois de données brassés à chaque affichage de page, pour n’en montrer que les dernières heures.
Le correctif tient en deux gestes simples. D’abord, aller cueillir directement les quelques dernières lignes de chaque sonde, au lieu de classer tout le tas. (C’est ce que fait un CROSS JOIN LATERAL : pour chaque sonde, va me chercher ses N dernières, puis passe à la suivante.) Ensuite, ajouter une condition « seulement les trente derniers jours ». Cette borne change tout : la base sait enfin qu’elle peut laisser fermées les tranches plus anciennes.
Cent quatre-vingt-dix millisecondes tombées à une. (Une milliseconde, c’est beaucoup plus court qu’une minute, mais surtout : c’est cent quatre-vingt-dix fois moins de base sollicitée pour le même résultat.)
Oui, il y a mieux qu’un CROSS JOIN LATERAL pour faciliter le monitoring, mais l’idée y est. On en discutera dans un prochain carnet.
La même lentille sur toute l’API
Une page réparée, une évidence : si une requête pouvait cacher ça, les autres aussi. J’ai passé toute l’API à la même lentille, endpoint par endpoint, jusqu’à la requête DB.
J’ai fini par ranger les pathologies par famille. Le scan non borné sur une table partitionnée. Le N+1, une requête par entité dans une boucle. Le top-N par groupe qui classe tout avant de filtrer.
Le COUNT(*) exact refait à chaque page pour la pagination. La liste sans LIMIT qui ramène la terre entière.
La page de statut publique, par exemple : pour chaque composant, quatre requêtes en série, plus une par incident. Sur une page à vingt composants, ça chiffrait vite. Regroupée en requêtes par lots, elle est passée à cinq requêtes, plus une.
La cible est désormais écrite noir sur blanc : moins de deux cents millisecondes par endpoint, mesurées avant et après avec un EXPLAIN ANALYZE joint au ticket, et un test d’intégration qui refuse que le budget de requêtes regonfle en douce.
Claude est bien mignon, mais…
En fait, ce premier jeu de requêtes, c’est l’affreux Jojo qui me les a rédigées à toute berzingue.
Sauf que la performance et le bon sens ne sont pas vraiment son fort.
Pour le moment, je garde une phase d’audit manuel, et j’écris des skills un peu mieux faites pour dialoguer avec une base. La prochaine étape, à mon sens, sera de systématiser les tests et le monitoring des requêtes pour repérer les dérives et les absurdités.
Ce que j’en retiens
Le BFF n’était pas une mauvaise idée. Il a fait son travail : la page demande une fois, au lieu de cinq. Je lui avais juste prêté un pouvoir qu’il n’a pas, celui d’aller vite à ma place.
La vitesse, je ne l’ai pas gagnée dans la couche qui parle au navigateur. Je l’ai gagnée tout en bas, le jour où j’ai laissé la base ignorer les partitions qu’elle n’avait aucune raison de lire.
Claude est bien gentil, mais il n’est pas toujours mon ami.