Pourquoi l'optimisation des performances de SQLite a rendu Bencher 1200x plus rapide
Everett Pompeii
La semaine dernière, j’ai reçu un retour d’un utilisateur disant que leur page de performance Bencher mettait du temps à charger. Alors j’ai décidé de vérifier, et oh, ils étaient vraiment indulgents. Ça a pris un temps foooou à charger ! D’une longueur embarrassante. Surtout pour l’outil phare de Benchmarking Continu.
Par le passé, j’ai utilisé la page de performance Rustls comme test de référence. Ils ont 112 benchmarks et l’une des mises en place de Benchmarking Continu les plus impressionnantes qui existent. Cela prenait environ 5 secondes à charger. Cette fois, ça a pris… ⏳👀 … 38.8 secondes ! Avec un tel délai, je devais creuser. Les bugs de performance sont des bugs, après tout !
Contexte
Dès le début, je savais que l’API de performance Bencher allait être l’un des points de terminaison les plus exigeants en termes de performances. Je pense que la principale raison pour laquelle tant de personnes ont dû réinventer la roue du suivi des benchmarks est que les outils existants ne gèrent pas la haute dimensionalité requise. Par “haute dimensionalité”, je veux dire être capable de suivre la performance dans le temps et à travers de multiples dimensions : Branches, Bancs d’essai, Benchmarks et Mesures. Cette capacité à trancher et à dés en cinq dimensions différentes conduit à un modèle très complexe.
En raison de cette complexité inhérente et de la nature des données, j’ai envisagé d’utiliser une base de données de séries temporelles pour Bencher. Finalement, j’ai opté pour l’utilisation de SQLite à la place. J’ai estimé qu’il valait mieux faire des choses qui ne sont pas évolutives que de passer du temps supplémentaire à apprendre une toute nouvelle architecture de base de données qui pourrait ne pas vraiment aider.
Au fil du temps, les exigences sur l’API de performance Bencher ont également augmenté. Au départ, vous deviez sélectionner manuellement toutes les dimensions que vous vouliez tracer. Cela créait beaucoup de friction pour les utilisateurs afin d’obtenir un tracé utile. Pour résoudre cela, j’ai ajouté une liste des Rapports les plus récents aux pages de Perf, et par défaut, le Rapport le plus récent était sélectionné et tracé. Cela signifie que si le dernier Rapport contenait 112 benchmarks, alors tous les 112 seraient tracés. Le modèle est également devenu encore plus compliqué avec la capacité de suivre et de visualiser les limites de seuil.
Avec cela à l’esprit, j’ai apporté quelques améliorations liées à la performance. Puisque le tracé Perf a besoin du Rapport le plus récent pour commencer à tracer, j’ai refactorisé l’API des Rapports pour obtenir les données de résultat d’un Rapport en un seul appel à la base de données au lieu d’itérer. La fenêtre temporelle pour la requête de Rapport par défaut a été fixée à quatre semaines, au lieu d’être illimitée. J’ai également limité considérablement la portée de toutes les manipulations de base de données, réduisant la contention des verrous. Pour aider à communiquer avec les utilisateurs, j’ai ajouté un indicateur de progression pour le tracé Perf ainsi que pour les onglets de dimensions.
J’ai également eu une tentative échouée l’automne dernier d’utiliser une requête composite pour obtenir tous les résultats de Perf dans une seule requête, au lieu d’utiliser une boucle imbriquée quadruple. Cela m’a conduit à atteindre la limite de récursion du système de types de Rust, à déborder de la pile à répétition, à souffrir de temps de compilation insensés (bien plus longs que 38 secondes), et finalement à une impasse à la limite maximale de SQLite du nombre de termes dans une déclaration de sélection composée.
Avec tout cela à mon actif, je savais que je devais vraiment m’impliquer ici et enfiler mon pantalon d’ingénieur de performance. Je n’avais jamais profilé une base de données SQLite avant, et honnêtement, je n’avais jamais vraiment profilé aucune base de données avant. Attendez une minute, vous pourriez penser. Mon profil LinkedIn dit que j’étais “Administrateur de Base de Données” pendant presque deux ans. Et je n’ai jamais profilé une base de données‽ Oui. C’est une histoire pour une autre fois, je suppose.
De l’ORM à la Requête SQL
La première difficulté à laquelle je me suis heurté était d’extraire la requête SQL de mon code Rust. J’utilise Diesel comme mappeur objet-relationnel (ORM) pour Bencher.
🐰 Le Saviez-vous ? Diesel utilise Bencher pour leur Benchmarking Continu Relatif. Jetez un œil à la page de performances de Diesel !
Diesel crée des requêtes paramétrées.
Il envoie la requête SQL et ses paramètres liés séparément à la base de données.
C’est-à-dire que la substitution est réalisée par la base de données.
Par conséquent, Diesel ne peut pas fournir une requête complète à l’utilisateur.
La meilleure méthode que j’ai trouvée était d’utiliser la fonction diesel::debug_query
pour afficher la requête paramétrée :
Et ensuite, nettoyer manuellement et paramétrer la requête en SQL valide :
Si vous connaissez une meilleure façon, veuillez me le faire savoir ! C’est ainsi que le mainteneur du projet l’a suggéré toutefois, alors j’ai simplement suivi cette voie. Maintenant que j’avais une requête SQL, j’étais enfin prêt à… lire énormément de documentation.
Planificateur de requêtes SQLite
Le site web de SQLite propose une documentation excellente pour son planificateur de requêtes. Elle explique précisément comment SQLite exécute votre requête SQL, et elle vous enseigne quels indices sont utiles et quels opérations surveiller, comme les balayages complets de table.
Afin de voir comment le planificateur de requêtes exécuterait ma requête Perf,
j’ai dû ajouter un nouvel outil à ma boîte à outils : EXPLAIN QUERY PLAN
Vous pouvez soit préfixer votre requête SQL avec EXPLAIN QUERY PLAN
ou exécuter la commande .eqp on
avant votre requête.
Dans les deux cas, j’ai obtenu un résultat qui ressemble à ceci :
Oh, là là ! Il y a beaucoup à digérer ici. Mais les trois grandes choses qui m’ont sauté aux yeux sont :
- SQLite crée une vue matérialisée à la volée qui scanne la table
boundary
en entier - Ensuite, SQLite scanne la table
metric
en entier - SQLite crée deux index à la volée
Et à quel point les tables metric
et boundary
sont-elles volumineuses ?
Eh bien, il se trouve qu’elles sont justement les deux tables les plus volumineuses,
car c’est là que sont stockées toutes les Métriques et les Seuils.
Comme c’était mon premier rodéo de tuning de performance SQLite, je voulais consulter un expert avant de faire des changements.
Expert SQLite
SQLite dispose d’un mode “expert” expérimental qui peut être activé avec la commande .expert
.
Il suggère des index pour les requêtes, donc j’ai décidé de l’essayer.
Voici ce qu’il a suggéré :
C’est définitivement une amélioration !
Il a supprimé le scan sur la table metric
et les deux index créés à la volée.
Honnêtement, je n’aurais pas trouvé les deux premiers index par moi-même.
Merci, SQLite Expert !
Maintenant, la seule chose qu’il reste à éliminer, c’est cette fichue vue matérialisée créée à la volée.
Vue Matérialisée
Lorsque j’ai ajouté la capacité de suivre et de visualiser les Limites de Seuil l’année dernière,
j’avais une décision à prendre concernant le modèle de base de données.
Il existe une relation de 1 à 0/1 entre une Métrique et sa Limite correspondante.
C’est-à-dire qu’une Métrique peut être liée à zéro ou une Limite, et une Limite ne peut être liée qu’à une seule Métrique.
J’aurais donc pu simplement étendre la table métrique
pour inclure toutes les données de limite
avec chaque champ lié à limite
étant nullable.
Ou je pourrais créer une table limite
séparée avec une clé étrangère UNIQUE
pour la table métrique
.
Pour moi, la dernière option semblait beaucoup plus propre, et je me disais que je pourrais toujours gérer les implications de performance plus tard.
Voici les requêtes effectives utilisées pour créer les tables métrique
et limite
:
Et il s’avère que “plus tard” est arrivé.
J’ai tenté d’ajouter simplement un index pour limite(metric_id)
mais cela n’a pas aidé.
Je crois que la raison a à voir avec le fait que la requête Perf provient de la table métrique
et parce que cette relation est de 0/1 ou pour le dire autrement, nullable elle doit être balayée (O(n))
et ne peut pas être cherchée (O(log(n))).
Cela m’a laissé une option claire.
Je devais créer une vue matérialisée qui aplatit la relation entre métrique
et limite
pour empêcher SQLite de devoir créer une vue matérialisée à la volée.
Voici la requête que j’ai utilisée pour créer la nouvelle vue matérialisée metric_boundary
:
Avec cette solution, je troque de l’espace contre des performances d’exécution. Combien d’espace ? Étonnamment, seulement environ une augmentation de 4 %, même si cette vue concerne les deux plus grandes tables de la base de données. Le mieux dans tout ça, c’est que ça me permet d’avoir le beurre et l’argent du beurre dans mon code source.
Créer une vue matérialisée avec Diesel a été étonnamment facile. Il suffisait d’utiliser exactement les mêmes macros que Diesel utilise lors de la génération de mon schéma normal. Cela dit, j’ai appris à apprécier beaucoup plus Diesel tout au long de cette expérience. Voir Bug Bonus pour tous les détails croustillants.
Conclusion
Avec l’ajout des trois nouveaux index et d’une vue matérialisée, voici ce que montre désormais le Planificateur de requêtes :
Regardez toutes ces magnifiques recherches SEARCH
réalisées avec des index existants ! 🥲
Et après avoir déployé mes modifications en production :
Il était alors temps pour le test final. À quelle vitesse la page de performance Rustls se charge-t-elle ?
Voici même un lien ancre. Cliquez dessus puis rafraîchissez la page.
La performance compte
Bencher: Benchmarking Continu
Bencher est une suite d’outils de benchmarking continu. Avez-vous déjà eu une régression de performance qui a impacté vos utilisateurs ? Bencher aurait pu empêcher cela de se produire. Bencher vous permet de détecter et de prévenir les régressions de performance avant qu’elles n’arrivent en production.
- Exécuter: Exécutez vos benchmarks localement ou en CI en utilisant vos outils de benchmarking préférés. La CLI
bencher
enveloppe simplement votre harnais de benchmarking existant et stocke ses résultats. - Suivre: Suivez les résultats de vos benchmarks au fil du temps. Surveillez, interrogez et graphiquez les résultats à l’aide de la console web Bencher en fonction de la branche source, du banc d’essai et de la mesure.
- Détecter: Détectez les régressions de performances en CI. Bencher utilise des analyses de pointe et personnalisables pour détecter les régressions de performances avant qu’elles n’arrivent en production.
Pour les mêmes raisons que les tests unitaires sont exécutés en CI pour prévenir les régressions de fonctionnalités, les benchmarks devraient être exécutés en CI avec Bencher pour prévenir les régressions de performance. Les bugs de performance sont des bugs !
Commencez à détecter les régressions de performances en CI — essayez Bencher Cloud gratuitement.
Addendum sur l’Auto-utilisation
Je suis déjà en train d’auto-utiliser Bencher avec Bencher, mais tous les adaptateurs de harnais de benchmark existants sont pour des harnais de micro-benchmarking. La plupart des harnais HTTP sont réellement des harnais de test de charge, et le test de charge est différent du benchmarking. De plus, je ne cherche pas à étendre Bencher au test de charge de si tôt. C’est un cas d’utilisation très différent qui nécessiterait des considérations de conception très différentes, comme cette base de données en série temporelle par exemple. Même si j’avais mis en place des tests de charge, j’aurais vraiment besoin de travailler contre une nouvelle extraction de données de production pour que cela soit détecté. Les différences de performance pour ces changements étaient négligeables avec ma base de données de test.
Cliquez pour voir les résultats des benchmarks de la base de données de test
Avant :
Après les index et la vue matérialisée :
Tout cela me conduit à croire que je devrais créer un micro-benchmark qui s’exécute contre le point de terminaison de l’API Perf et auto-utiliser les résultats avec Bencher. Cela nécessitera une base de données de test de taille considérable pour s’assurer que ce type de régressions de performance soit détecté dans l’IC. J’ai créé un problème de suivi pour ce travail, si vous souhaitez suivre l’évolution.
Tout cela m’a toutefois fait réfléchir : Et si vous pouviez faire des tests de captures instantanées de votre plan de requête de base de données SQL ? C’est-à-dire, vous pourriez comparer vos plans de requête de base de données actuels par rapport à ceux candidats. Les tests de plan de requête SQL seraient une sorte de benchmarking basé sur le nombre d’instructions pour les bases de données. Le plan de requête aide à indiquer qu’il peut y avoir un problème avec la performance d’exécution, sans avoir à réellement benchmark la requête de la base de données. J’ai créé un problème de suivi pour cela également. N’hésitez pas à ajouter un commentaire avec vos pensées ou toute œuvre antérieure que vous connaissez !
Bonus Bug
J’avais initialement un bug dans mon code de vue matérialisée. Voici à quoi ressemblait la requête SQL :
Avez-vous vu le problème ? Non. Moi non plus !
Le problème est ici :
Cela aurait dû être :
J’essayais d’être trop astucieux, et dans mon schéma de vue matérialisée Diesel, j’avais permis cette jointure :
Je supposais que cette macro était d’une certaine manière assez intelligente
pour relier alert.boundary_id
à metric_boundary.boundary_id
.
Mais hélas, ce n’était pas le cas.
Il semble qu’elle ait juste choisi la première colonne de metric_boundary
(metric_id
) pour se rapporter à alert
.
Une fois que j’ai découvert le bug, il était facile à corriger. Il suffisait simplement d’utiliser une jointure explicite dans la requête Perf :
🐰 C’est tout, folks !