Optimisation des Applications Rails avec SQLite
Introduction à l’Optimisation des Performances
Au cours de l’année écoulée, j’ai entrepris un voyage pour comprendre comment exécuter des applications Rails avec SQLite de manière performante et résiliente. J’ai acquis plusieurs enseignements que je souhaite partager. Dans cet article, nous allons explorer les problèmes rencontrés, leurs causes et les solutions possibles.
La Réalité Actuelle de SQLite sur Rails
Il est important de reconnaître que l’utilisation de SQLite avec Rails, sans ajustements, n’est pas une option viable aujourd’hui. Cependant, avec quelques modifications et optimisations, il est possible de déployer une application Rails performante et robuste utilisant SQLite. Mon objectif personnel pour Rails 8 est de rendre l’expérience prête pour la production dès la sortie de la boîte.
Identifier les Problèmes et Trouver des Solutions
Au cours de l’année passée, j’ai analysé en profondeur les défis liés à l’utilisation de SQLite dans les applications Rails et les solutions à ces problèmes. Voici ce qu’il faut savoir pour construire une application Rails prête pour la production avec SQLite.
Commandes Essentielles pour la Production
Pour préparer votre application à la production, trois commandes clés peuvent faire toute la différence. Elles permettent d’améliorer considérablement les performances, d’ajouter des fonctionnalités SQL et de mettre en place des sauvegardes à un instant donné. Voici comment vous pouvez établir une application SQLite sur Rails prête pour la production.
Étude de Cas : Application Démonstration « Lorem News »
Pour rendre cette exploration concrète, nous allons travailler sur une application de démonstration nommée « Lorem News ». Il s’agit d’un clone simple de Hacker News, où les utilisateurs peuvent publier des articles et des commentaires, mais tout le contenu est en Lorem Ipsum. Ce code servira de base pour nos exemples et nos tests de performance.
Évaluation des Performances de l’Application
Pour évaluer les performances de notre application de démonstration, nous utiliserons l’outil de test de charge oha
et les routes de benchmarking intégrées à l’application. Commençons par un test simple où nous envoyons une requête après l’autre pendant 5 secondes à notre point de terminaison post#create
.
Les résultats initiaux montrent des performances solides, avec un bon nombre de requêtes par seconde (RPS) et un taux de succès élevé. Cependant, la requête la plus lente est significativement plus longue que la moyenne, ce qui n’est pas idéal. Cela remet en question l’idée que l’expérience par défaut avec Rails et SQLite est prête pour la production.
Tests de Charge Concurrente
En effectuant le même test de charge, mais en envoyant 4 requêtes simultanées par vagues pendant 5 secondes, les résultats changent. Nous commençons à voir des erreurs 500 dans nos réponses, indiquant des problèmes sous-jacents.
Analyse des Logs et Identification des Problèmes
En examinant les logs, nous découvrons le premier problème majeur auquel les applications Rails utilisant SQLite doivent faire face : l’exception SQLITE_BUSY
.
SQLite utilise un verrou d’écriture pour s’assurer qu’une seule opération d’écriture se produit à la fois. Si plusieurs connexions tentent d’écrire simultanément, l’exception SQLITE_BUSY
est levée. Sans configuration appropriée, une application web avec un pool de connexions vers une base de données SQLite rencontrera de nombreuses erreurs lors de la réponse aux requêtes.
Solutions : Gérer les Transactions
À mesure que la charge concurrente augmente sur votre application Rails, le pourcentage de requêtes échouant avec l’exception SQLITE_BUSY
augmente également. Il est donc crucial de trouver un moyen de gérer les requêtes d’écriture de manière à ce qu’elles s’enchaînent sans générer d’erreurs.
Transactions Immédiates
Pour résoudre ce problème, nous devons utiliser des transactions immédiates. En raison du verrou d’écriture global, SQLite nécessite différents modes de transaction pour différents comportements.
Mode de Transaction par Défaut
Par défaut, SQLite utilise un mode de transaction différé, ce qui signifie qu’il n’acquiert le verrou qu’au moment d’une opération d’écriture. Cela peut être efficace dans des contextes où il n’y a qu’une seule connexion ou lorsque les transactions ne contiennent que des opérations de lecture. Cependant, ce n’est pas le cas des applications Rails en production.
Dans une application Rails, plusieurs connexions sont souvent actives, et chaque transaction inclut généralement une opération d’écriture. Cela entraîne des conflits et des erreurs, rendant nécessaire l’adoption d’une approche différente pour gérer les transactions.
Conclusion
bien que l’utilisation de SQLite avec Rails présente des défis, il est possible de surmonter ces obstacles grâce à des ajustements appropriés. En comprenant les problèmes liés à l’exception SQLITE_BUSY
et en adoptant des stratégies de gestion des transactions, vous pouvez créer une application Rails robuste et performante utilisant SQLite.
Optimisation des Transactions dans SQLite pour les Applications Rails
Comprendre les Exceptions SQLITE_BUSY
Les exceptions SQLITE_BUSY
se produisent lorsque SQLite tente d’acquérir un verrou d’écriture pendant qu’une autre connexion le détient. Dans ce cas, SQLite ne peut pas relancer la requête liée à la transaction en toute sécurité, car cela pourrait compromettre l’isolation sérielle que garantit SQLite. Ainsi, lorsqu’une exception de type busy
se produit lors de la tentative d’élévation d’une transaction, SQLite ne peut pas mettre en file d’attente cette requête pour une tentative ultérieure ; elle renvoie immédiatement une erreur et interrompt la transaction.
Amélioration des Transactions avec le Mode IMMEDIATE
Pour contourner ce problème, il est possible de commencer la transaction en déclarant explicitement qu’il s’agit d’une transaction immédiate. Cela permet à SQLite de mettre en file d’attente la requête pour réessayer d’acquérir le verrou d’écriture plus tard. En utilisant un système de mise en file d’attente, SQLite peut gérer les requêtes concurrentes, même si certaines d’entre elles sont encapsulées dans des transactions.
Configuration des Transactions dans Rails
Depuis la version 1.6.9, la gem sqlite3-ruby
permet de configurer le mode de transaction par défaut. Étant donné que Rails transmet les clés de configuration de niveau supérieur dans votre fichier database.yml
directement à l’initialiseur de base de données sqlite3-ruby
, il est facile de s’assurer que toutes les transactions SQLite de Rails s’exécutent en mode IMMEDIATE.
Résultats des Tests de Charge
Après avoir effectué cette modification dans notre application de démonstration et relancé notre test de charge, nous avons constaté que notre application Rails gère désormais la charge concurrente sans générer presque aucune erreur 500. Cependant, des erreurs commencent à apparaître à partir de 16 requêtes concurrentes, ce qui indique qu’il reste des problèmes à résoudre.
En examinant les résultats de latence de nos tests de charge, un nouveau problème se manifeste rapidement. À mesure que le nombre de requêtes concurrentes atteint et dépasse le nombre de travailleurs Puma, la latence p99 augmente considérablement. Étonnamment, le temps de requête réel reste stable, même avec une charge trois fois supérieure au nombre de travailleurs Puma. Cependant, dès que certaines requêtes prennent environ 5 secondes, nous commençons également à recevoir des réponses SQLITE_BUSY
.
Gestion des Délais d’Attente
Ce délai de 5 secondes correspond exactement à notre paramètre de délai d’attente. Il semble qu’à mesure que notre application subit une charge concurrente supérieure au nombre de travailleurs Puma, de plus en plus de requêtes de base de données dépassent le délai d’attente. C’est un nouveau défi à relever.
Le paramètre de délai d’attente dans notre fichier database.yml
est lié à l’une des options de configuration de SQLite, à savoir busy_timeout
. Au lieu de renvoyer immédiatement l’exception BUSY
, vous pouvez indiquer à SQLite d’attendre jusqu’à un certain nombre de millisecondes. SQLite tentera de réacquérir le verrou d’écriture en utilisant une sorte de stratégie de retour exponentiel. Si le verrou ne peut pas être acquis dans la fenêtre de délai d’attente, alors seulement l’exception BUSY
sera lancée.
Mécanisme de Mise en File d’Attente
Imaginons que notre application envoie quatre requêtes d’écriture à la base de données simultanément. L’une d’elles obtiendra le verrou d’écriture en premier et s’exécutera, tandis que les trois autres seront mises en file d’attente, exécutant la logique de réacquisition. Une fois la première requête d’écriture terminée, l’une des requêtes en file d’attente tentera de réacquérir le verrou et réussira, tandis que les deux autres continueront à rester en file d’attente.
Ce mécanisme de mise en file d’attente est essentiel pour éviter les exceptions SQLITE_BUSY
. Cependant, un goulot d’étranglement de performance majeur se cache dans les détails de cette fonctionnalité pour les applications Rails.
Problèmes de Performance avec le GVL
Étant donné que SQLite est intégré dans votre processus Ruby, il est crucial de libérer le verrou global de la machine virtuelle Ruby (GVL) lorsque les liaisons Ruby-SQLite exécutent le code C de SQLite. Par défaut, la gem sqlite3-ruby
ne libère pas le GVL lors de l’appel à SQLite. Bien que cela soit généralement une décision raisonnable, cela nuit considérablement au débit lors de l’utilisation de busy_timeout
.
En ne permettant pas à un autre travailleur Puma d’acquérir le GVL pendant qu’un travailleur attend le retour d’une requête de base de données, le premier travailleur continue de détenir le GVL même lorsque les opérations Ruby sont complètement inactives. Cela signifie que les travailleurs Puma concurrents ne peuvent même pas envoyer de requêtes d’écriture simultanées à la base de données SQLite, ce qui force notre application Rails à traiter les requêtes web de manière quelque peu linéaire. Cela ralentit considérablement le débit de notre application Rails.
Conclusion
Pour optimiser les performances de votre application Rails utilisant SQLite, il est essentiel de configurer correctement les transactions et de gérer les délais d’attente. En appliquant ces ajustements, vous pouvez améliorer la gestion des requêtes concurrentes et réduire les erreurs, tout en maintenant une performance optimale.
Optimisation de la gestion des requêtes dans les applications Ruby avec SQLite
Introduction à la gestion des requêtes concurrentes
Dans le cadre des applications Ruby utilisant SQLite, il est essentiel de permettre à nos processus de traitement de requêtes de fonctionner simultanément. Cela implique de gérer le verrou global (GVL) de manière efficace, surtout lorsque des requêtes d’écriture sont en attente d’acquérir le verrou d’écriture de SQLite.
Utilisation des gestionnaires de délai
SQLite offre une fonctionnalité intéressante appelée busy_timeout
, qui est en réalité une implémentation spécifique d’un gestionnaire de délai (busy_handler
). Ce dernier permet aux applications d’implémenter leur propre logique de gestion des délais. Le gem sqlite3-ruby
sert de pilote SQLite, fournissant des liaisons Ruby pour l’API C de SQLite. Grâce à cela, nous pouvons créer un rappel Ruby qui sera exécuté chaque fois qu’une requête est mise en attente.
Implémentation de la logique de délai
Voici un exemple d’implémentation en Ruby qui reflète la logique de busy_timeout
de SQLite. À chaque appel de ce rappel, un compteur est transmis, indiquant combien de fois la requête a été mise en attente. Ce compteur est crucial pour déterminer la durée d’attente avant de réessayer d’acquérir le verrou d’écriture. En utilisant la méthode sleep
de Ruby, nous garantissons que le GVL est libéré pendant que la requête attend.
Amélioration de la latence
En libérant le GVL pendant que les requêtes attendent, nous avons considérablement amélioré notre latence p99, même sous une charge concurrente. Cependant, des cas extrêmes persistent. En examinant la latence p99.99, nous constatons une tendance à la hausse.
Analyse des requêtes lentes
Nos requêtes les plus lentes deviennent progressivement plus lentes à mesure que la charge concurrente augmente. Ce phénomène est une courbe de croissance que nous souhaitons aplanir. Pour y parvenir, il est crucial de comprendre les raisons sous-jacentes de cette situation.
Problèmes liés aux requêtes anciennes
Le problème réside dans notre ré-implémentation Ruby de la logique busy_timeout
, qui pénalise les requêtes plus anciennes. Cela nuit à notre performance sur le long terme, car les réponses se segmentent naturellement entre les requêtes « jeunes » et « anciennes ». Pour illustrer ce point, examinons le fonctionnement de notre logique busy_timeout
en Ruby.
Premier appel du rappel
Lors de la première mise en attente d’une requête, le compteur est à zéro. Comme ce chiffre est inférieur à 12, nous entrons dans le bloc conditionnel. Nous récupérons le premier élément du tableau des délais, qui est de 1 milliseconde. À ce stade, la requête n’a pas encore été retardée.
Appels successifs
Au dixième appel, le compteur est de 10. Nous récupérons alors le dixième élément du tableau des délais, qui est de 50 millisecondes. En additionnant les retards précédents, nous constatons que la requête a déjà été retardée de 178 millisecondes. À ce moment-là, nous dormons pendant 50 millisecondes avant de rappeler le gestionnaire.
Appels ultérieurs
Lors du 58ème appel, le compteur dépasse 12, ce qui nous fait entrer dans le bloc else
. À partir de ce moment, chaque appel entraînera un délai fixe de 100 millisecondes. Cela signifie que les requêtes plus anciennes subiront des retards de manière systématique, ce qui peut nuire à la performance globale.
Conclusion
Pour optimiser la gestion des requêtes dans les applications Ruby utilisant SQLite, il est crucial de réévaluer notre approche de la logique de délai. En comprenant les impacts des requêtes anciennes et en ajustant notre stratégie, nous pouvons améliorer la performance et réduire la latence, même sous des charges concurrentes élevées.
La somme de l’ensemble du tableau des délais est de 328, et en soustrayant 12, nous obtenons 46. En multipliant 46 par 100, nous avons 4600. En ajoutant cela à 328, nous arrivons à un total de 4928. À ce stade, notre requête a été retardée de 4928 millisecondes.
100 + 4928 donne 5028, ce qui dépasse effectivement 5000, nous entrons donc dans le bloc conditionnel.
Enfin, nous levons l’exception.
Il est vrai que passer en revue ce code peut sembler un peu fastidieux, mais il est essentiel que nous comprenions tous comment le mécanisme de busy_timeout
de SQLite gère les requêtes en attente. Lorsque je dis qu’il pénalise les anciennes requêtes, je veux dire qu’il les rend beaucoup plus susceptibles de devenir des requêtes expirées sous une charge constante. Pour comprendre pourquoi, revenons à nos requêtes en attente…
Suivons combien de fois chaque requête a été réessayée dans notre exemple simple ci-dessus.
Nos trois requêtes restantes ont été réessayées une fois…
Et maintenant, les deux requêtes restantes sont, au mieux, à leur deuxième réessai.
Notre troisième requête est, encore une fois au mieux, à son troisième réessai. Lors de ce troisième réessai, le délai est déjà de 10 millisecondes. Imaginons qu’à ce moment-là, une nouvelle requête d’écriture soit envoyée à la base de données.
Cette nouvelle requête tente immédiatement d’acquérir le verrou d’écriture, se voit refuser l’accès et fait son premier appel à la fonction de rappel busy_timeout
. On lui demandera d’attendre 1 milliseconde. Notre requête originale attend 10 millisecondes, donc cette nouvelle requête aura la possibilité de réessayer avant notre requête plus ancienne.
Tant que le verrou d’écriture est maintenu, notre nouvelle requête se voit demander d’attendre 2 millisecondes ensuite.
Même lorsque le compteur est à 2, elle n’est que sollicitée d’attendre 5 millisecondes. Cette nouvelle requête sera autorisée à réessayer d’acquérir le verrou d’écriture trois fois avant que la requête originale ne puisse réessayer une seule fois.
Ces délais d’attente croissants pénalisent considérablement les requêtes plus anciennes, de sorte que toute requête qui doit attendre même juste 3 réessais est maintenant beaucoup plus susceptible de ne jamais acquérir le verrou d’écriture s’il y a un flux constant de requêtes d’écriture.
Alors, que se passerait-il si, au lieu de retarder progressivement nos réessais, nous faisions simplement en sorte que chaque requête réessaie à la même fréquence, indépendamment de son ancienneté ? Cela signifierait également que nous pourrions nous passer de notre tableau de delays
et repenser notre fonction busy_handler
dans son ensemble.
C’est précisément ce que nous avons réalisé dans la branche principale de la gemme sqlite3-ruby
. Malheureusement, à ce jour, cette fonctionnalité n’est pas encore dans une version taguée de la gemme, mais elle devrait être publiée relativement bientôt. Ce rappel Ruby libère le GVL tout en attendant une connexion en utilisant l’opération sleep
et dort toujours 1 milliseconde. Ces 10 lignes de code font une énorme différence dans les performances de votre application SQLite sur Rails.
Rerunons nos scripts de benchmarking et voyons comment se présente notre latence p99.99 maintenant…
Voilà ! Nous avons aplati la courbe. Il y a encore un pic avec une charge supérieure à la moitié du nombre de travailleurs Puma que nous avons, mais après ce pic, notre latence de longue traîne se stabilise autour de la moitié de seconde.
En ce qui concerne les performances, il y a quatre éléments clés que vous devez vous assurer d’appliquer à votre prochaine application SQLite sur Rails…
Nous avons couvert les trois premiers, mais pas le dernier. Le journal d’écriture anticipée (WAL) permet à SQLite de prendre en charge plusieurs lecteurs concurrents. Le mode de journalisation par défaut ne permet qu’une seule requête à la fois, qu’il s’agisse d’une lecture ou d’une écriture. Le mode WAL permet des lecteurs concurrents, mais seulement un écrivain à la fois.
Heureusement, à partir de Rails 7.1, Rails applique une meilleure configuration par défaut pour votre base de données SQLite. Ces changements sont essentiels pour faire fonctionner SQLite de manière optimale dans le contexte d’une application web. Si vous souhaitez en savoir plus sur chacune de ces options de configuration, pourquoi nous utilisons les valeurs que nous faisons, et comment ce groupe spécifique de détails de configuration améliore les choses, j’ai un article de blog qui explore ces détails.
Bien que cela ne soit pas une exigence, il existe un cinquième levier que nous pouvons actionner pour améliorer les performances de notre application. Étant donné que nous savons que SQLite en mode WAL prend en charge plusieurs connexions de lecture simultanées mais seulement une connexion d’écriture à la fois, nous pouvons reconnaître qu’il est possible que le pool de connexions Active Record soit saturé de connexions d’écriture, bloquant ainsi les opérations de lecture concurrentes.
Optimisation des Performances de SQLite avec Rails
Gestion des Connexions dans un Environnement Concurrent
Lorsque vous disposez d’un pool de connexions limité à trois et que vous recevez cinq requêtes simultanées, que se passe-t-il si les trois connexions sont attribuées à des requêtes d’écriture ? Les requêtes de lecture restantes devront patienter jusqu’à ce qu’une des requêtes d’écriture libère une connexion. En théorie, grâce à l’utilisation de SQLite en mode WAL (Write-Ahead Logging), les requêtes de lecture ne devraient jamais être bloquées par des requêtes d’écriture. Pour garantir cela, il est essentiel de créer deux pools de connexions distincts : un pour les opérations de lecture et un autre pour les opérations d’écriture.
Mise en Place de Pools de Connexions Distincts
Rails offre la possibilité de gérer plusieurs bases de données, ce qui nous permet d’atteindre cet objectif. Plutôt que de diriger les configurations de base de données pour la lecture et l’écriture vers des bases de données séparées, nous les orientons vers une seule base de données tout en créant deux pools de connexions isolés avec des configurations distinctes.
Le pool de connexions pour la lecture sera exclusivement composé de connexions en lecture seule, tandis que le pool pour l’écriture ne contiendra qu’une seule connexion.
Configuration des Modèles Active Record
Nous pouvons configurer nos modèles Active Record pour qu’ils se connectent au pool de connexions approprié en fonction de leur rôle. Conceptuellement, nous souhaitons que nos requêtes fonctionnent comme des transactions différées dans SQLite. Chaque requête devrait par défaut utiliser le pool de connexions de lecture, mais lorsqu’il est nécessaire d’écrire dans la base de données, nous basculons vers le pool d’écriture uniquement pour cette opération. Pour cela, nous utiliserons la fonctionnalité de changement de rôle automatique de Rails.
Initialisation et Configuration des Connexions
En intégrant ce code dans un initialiseur, nous forçons Rails à définir la connexion par défaut pour toutes les requêtes web sur le pool de lecture. Nous ajustons également la configuration de délai, car nous n’utilisons pas de bases de données distinctes, mais seulement des connexions séparées. Ainsi, il n’est pas nécessaire de garantir que les requêtes « lisent leurs propres écritures » avec un délai.
Nous pouvons ensuite modifier la méthode transaction
de l’adaptateur ActiveRecord pour qu’elle se connecte à la base de données d’écriture.
Amélioration des Performances
Ces ajustements permettent à nos « requêtes différées » d’utiliser des pools de connexions isolés. Lors des tests sur le point de terminaison de création de commentaires, nous observons une amélioration des performances en termes de requêtes simples par seconde.
Voici cinq niveaux d’amélioration des performances que vous devriez envisager pour votre application SQLite sur Rails.
Simplification de l’Intégration des Améliorations
Cependant, il n’est pas nécessaire de passer par toutes ces améliorations dans votre application Rails. Comme mentionné précédemment, vous pouvez simplement installer le gem d’adaptateur amélioré. Si vous souhaitez utiliser les pools de connexions isolés, il vous suffit d’ajouter cette configuration à votre application. Il s’agit d’une fonctionnalité expérimentale plus récente, c’est pourquoi vous devez choisir de l’activer.
Conclusion sur l’Optimisation de SQLite avec Rails
j’espère que cette exploration des outils, techniques et configurations par défaut pour les applications SQLite sur Rails vous a démontré la puissance, la performance et la flexibilité de cette approche. Rails est véritablement le meilleur framework d’application web pour travailler avec SQLite aujourd’hui. L’écosystème croissant de la communauté, avec ses outils et gems, est sans égal. C’est le moment idéal pour commencer une application SQLite sur Rails et explorer ces possibilités par vous-même.
J’espère que vous vous sentez désormais confiant quant aux méthodes et raisons d’une performance optimale lors de l’exécution de SQLite en production avec Rails.