SQLite sur Rails : comment et pourquoi l’optimiser pour de meilleures performances
(fractaledmind.github.io)- SQLite n’est pas encore idéal en production sur Rails avec les réglages par défaut, mais quelques ajustements permettent de construire des applications performantes et résilientes
- Sous charge d’écriture concurrente, le verrou global d’écriture de SQLite provoque des
SQLITE_BUSY, et le flux habituel des transactions d’écriture de Rails s’accorde mal avec le mode deferred par défaut - Depuis
sqlite3-ruby1.6.9, on peut changer le mode de transaction par défaut en IMMEDIATE dansdatabase.yml, afin d’attendre et de retenter plus sûrement en cas d’échec d’acquisition du verrou d’écriture busy_timeoutconserve la GVL Ruby pendant l’attente et nuit donc au parallélisme de Puma ; utilisersleepdans unbusy_handlerpour libérer la GVL et retenter toutes les 1 ms réduit la latence de longue traîne- Le mode WAL, les transactions IMMEDIATE, un busy handler qui libère la GVL, la configuration SQLite par défaut de Rails 7.1, et la séparation optionnelle des pools de connexions lecture/écriture sont les principaux leviers pour améliorer les performances de SQLite sur Rails
Limites visibles avec la configuration Rails + SQLite par défaut
- Pour exploiter une application Rails sur SQLite avec de bonnes performances et de la résilience, la configuration actuelle par défaut ne suffit pas
- L’objectif est de faire en sorte que l’expérience par défaut avec SQLite dans Rails 8 soit prête pour la production
- L’application de démonstration est Lorem News
- Un clone basique de Hacker News avec utilisateurs, publications et commentaires
- Le contenu est composé de Lorem Ipsum
- Les tests de charge utilisent oha load testing CLI et des routes de benchmarking intégrées à l’application
- En envoyant des requêtes séquentielles vers l’endpoint
post#creatependant 5 secondes, le RPS reste stable et toutes les requêtes réussissent - En envoyant 4 requêtes concurrentes vers le même endpoint pendant 5 secondes, certaines requêtes renvoient des réponses 500
SQLITE_BUSY et les transactions IMMEDIATE
- Le premier problème visible dans les logs est l’exception
SQLITE_BUSY - SQLite utilise un verrou d’écriture sur la base de données afin de n’autoriser qu’une seule écriture à la fois
- Une seule connexion peut détenir le verrou d’écriture à un instant donné
- Si une nouvelle connexion tente d’obtenir le verrou pendant qu’une autre le détient, un
SQLITE_BUSYse produit
- Plus une application Rails subit de charge concurrente, plus la part des requêtes qui échouent avec
SQLITE_BUSYaugmente - Le mode de transaction par défaut de SQLite est deferred, et il n’essaie pas d’acquérir le verrou avant qu’une vraie écriture n’ait lieu
- C’est avantageux pour les performances dans un environnement à connexion unique ou avec beaucoup de transactions en lecture seule
- Une application Rails en production utilise plusieurs connexions sur plusieurs threads, et Rails encapsule les requêtes d’écriture dans des transactions, ce qui entre en conflit avec ce comportement par défaut
- Si l’acquisition du verrou d’écriture échoue au milieu d’une transaction, SQLite ne peut pas retenter proprement la requête sans casser l’isolation sérialisable et lève donc immédiatement une exception
- Les
transactions IMMEDIATEtentent d’acquérir le verrou d’écriture dès le début de la transaction- SQLite peut alors mettre la requête d’écriture en file d’attente et réessayer d’obtenir le verrou plus tard
- Ce mode convient mieux que deferred au schéma de transactions d’écriture de Rails
- Le gem
sqlite3-rubyprend en charge la configuration du mode de transaction par défaut depuis la version 1.6.9- Rails transmet les clés de premier niveau de
database.ymlà l’initialisation de la basesqlite3-ruby - On peut configurer dans
database.ymll’exécution des transactions SQLite de Rails en mode IMMEDIATE
- Rails transmet les clés de premier niveau de
- Après ce changement, de simples tests de charge passent presque sans erreurs 500 sous charge concurrente, mais à 16 requêtes simultanées quelques erreurs réapparaissent
busy_timeout, GVL et busy_handler personnalisé
- Le goulot d’étranglement suivant apparaît quand le nombre de requêtes concurrentes approche ou dépasse le nombre de workers Puma : la latence p99 grimpe brutalement
- Le temps réel de traitement des requêtes reste stable même sous une charge concurrente égale à trois fois le nombre de workers Puma, mais dès que certaines requêtes commencent à durer environ 5 secondes, des réponses 500 avec
SQLITE_BUSYapparaissent aussi - Ces 5 secondes correspondent au réglage
timeoutdansdatabase.yml, qui est mappé sur le paramètrebusy_timeoutde SQLite busy_timeoutessaie de réacquérir le verrou d’écriture pendant le nombre de millisecondes indiqué au lieu de lever immédiatement une exceptionBUSY- SQLite retente l’acquisition du verrou avec une sorte de backoff exponentiel
- L’exception
BUSYn’est levée que si le verrou n’est pas obtenu avant la fin du délai - Une application web peut ainsi ouvrir plusieurs connexions et laisser SQLite ordonnancer les écritures au lieu de le faire elle-même
- Le goulot d’étranglement vient du fait que SQLite est embarqué dans le processus Ruby et que
sqlite3-rubyne libère pas la GVL Ruby lorsqu’il appelle le code C de SQLite- Un worker Puma continue de détenir la GVL pendant qu’il attend le retour d’une requête base de données
- Il devient alors difficile pour d’autres workers Puma d’envoyer en même temps leurs requêtes d’écriture à SQLite
- Le caractère séquentiel des écritures SQLite rend aussi le traitement des requêtes Rails plus linéaire, ce qui réduit fortement le débit
- SQLite fournit un hook
busy_handlerde plus bas niveau quebusy_timeoutbusy_timeoutest une implémentation particulière debusy_handlerfournie par SQLitesqlite3-rubyexpose un binding vers la fonction Csqlite3_busy_handler, ce qui permet de créer un callback Ruby appelé quand une requête entre dans la file d’attente
- En utilisant
Kernel.sleepdans ce callback Ruby, on peut libérer la GVL pendant que la requête attend de retenter l’acquisition du verrou d’écriture - Cette approche améliore fortement la latence p99 sous charge concurrente, mais à p99.99 on observe encore que les requêtes les plus lentes deviennent plus lentes à mesure que la concurrence augmente
Longue traîne du backoff exponentiel et retries à 1 ms
- La logique
busy_timeoutréimplémentée en Ruby pénalise de plus en plus les requêtes qui attendent depuis longtemps - Les premières tentatives attendent peu, mais le temps d’attente augmente avec le nombre d’appels du callback
- À la première attente, on dort 1 ms
- Au dixième appel, on dort 50 ms
- À partir du 12e, on attend 100 ms à chaque fois
- Une exception est levée quand le temps d’attente cumulé dépasse le timeout de 5000 ms
- Si de nouvelles requêtes d’écriture continuent d’arriver régulièrement, les nouvelles requêtes peuvent retenter plus souvent l’acquisition du verrou grâce à leurs temps d’attente plus courts
- Pendant qu’une ancienne requête, déjà passée par trois attentes, dort 10 ms, une nouvelle requête peut retenter trois fois avec des attentes de 1 ms, 2 ms et 5 ms
- Ce backoff croissant augmente la probabilité que les requêtes plus anciennes n’obtiennent jamais le verrou d’écriture avant le timeout
- La solution consiste à faire en sorte que toutes les requêtes retentent à la même fréquence, quel que soit leur âge
- Ce changement est déjà présent sur la branche main de
sqlite3-ruby- Au moment de la rédaction, cette fonctionnalité n’est pas encore incluse dans une release taguée
- Le callback Ruby attend avec
sleep, ce qui libère la GVL pendant l’attente - Il dort toujours 1 ms
- Après ce changement, la courbe de latence p99.99 s’aplatit dans les benchmarks
- Un saut subsiste quand la concurrence dépasse la moitié du nombre de workers Puma
- Au-delà, la latence de longue traîne se stabilise autour de 0,5 seconde
Mode WAL et séparation des pools de connexions lecture/écriture
- Quatre conditions sont nécessaires pour les performances de SQLite sur Rails : les transactions IMMEDIATE, un busy handler qui libère la GVL, une configuration SQLite adaptée et le mode WAL
- Le write-ahead log permet à SQLite de gérer plusieurs lectures simultanées
- Le mode rollback journal par défaut n’autorise qu’une seule requête à la fois, en lecture comme en écriture
- Le mode WAL autorise plusieurs readers simultanés, mais un seul writer à la fois
- Depuis Rails 7.1, Rails applique de meilleurs réglages par défaut aux bases SQLite
- Ces réglages sont importants pour que SQLite fonctionne bien dans le contexte d’une application web
- Les détails de ces réglages et leurs raisons sont traités dans un autre article de blog
- Un cinquième levier de performance, optionnel, consiste à séparer un pool en lecture seule et un pool en écriture seule
- Le mode WAL de SQLite prend en charge plusieurs connexions de lecture et une seule connexion d’écriture
- Si le pool de connexions Active Record est saturé par des connexions d’écriture, les lectures peuvent être bloquées inutilement
- On peut exploiter la prise en charge des bases multiples dans Rails pour faire pointer les configurations reader et writer vers la même base SQLite
- Il ne s’agit pas de bases distinctes, mais de la même base unique
- On obtient malgré tout des pools de connexions et des réglages de connexion séparés
- Le pool reader est composé de connexions en lecture seule, tandis que le pool writer ne contient qu’une seule connexion
- Les modèles Active Record sont configurés pour se connecter au pool approprié selon leur rôle
- Grâce à l’automatic role switching de Rails, la connexion par défaut pour toutes les requêtes web peut être le pool reader, avec bascule vers le pool writer uniquement quand une écriture est nécessaire
- Comme tout se fait sur la même base, il n’y a pas besoin d’un
delaypour garantir leread your own writes - La méthode
transactionde l’adaptateur ActiveRecord est patchée pour que les transactions se connectent à la base writer
- Comme tout se fait sur la même base, il n’y a pas besoin d’un
- Cette combinaison de « deferred requests » et de pools de connexions isolés améliore les performances en RPS bruts lors du test de l’endpoint de création de commentaires
Des améliorations empaquetées dans un gem
- Il n’est pas nécessaire d’implémenter soi-même toutes ces améliorations dans son application Rails
- L’installation de
activerecord-enhancedsqlite3-adapterpermet d’appliquer les améliorations concernées - La fonctionnalité de pools de connexions isolés est plus récente et expérimentale, et demande donc une configuration supplémentaire en opt-in
- Cette approche regroupe outils, techniques et réglages par défaut pour utiliser SQLite en production sur Rails de façon rapide et flexible
- Rails est un bon framework d’application web pour travailler avec SQLite, et l’écosystème d’outils et de gems autour de ce sujet continue de grandir
1 commentaires
Avis sur Hacker News
Toute personne qui envisage d’utiliser SQLite + Rails devrait absolument jeter un œil au projet Litestack d’Oldmoe (X/GitHub)
Litestack est une gem Ruby qui exploite le caractère embarqué de SQLite dans les apps Ruby et Ruby on Rails pour fournir, dans un seul package, une base de données SQL, un cache rapide, une file de jobs, un broker de messages, un moteur de recherche plein texte et une plateforme de métriques
Je l’utilise dans un projet en ce moment et j’en suis très satisfait ; le lien est https://github.com/oldmoe/litestack
Je n’imagine même pas combien de jours il a fallu pour écrire un article aussi détaillé
C’est utile pour quiconque réfléchit à la montée en charge d’applications web avec SQLite, au-delà de Rails
Toute personne qui travaille avec SQLite devrait lire cet article, quels que soient le langage ou le framework utilisés
Ayant dû découvrir la plupart de ces choses par moi-même il y a quelques années, merci de les avoir rassemblées
Je construis un système d’analytics FOSS qui doit être facile à installer, et j’aimerais envoyer les données d’événements vers une base SQLite distincte, séparée des données principales de l’application
Même un site web modérément fréquenté peut générer plus de 1 000 événements par seconde, donc la scalabilité m’inquiète
Je me demande si accumuler les événements en mémoire côté serveur puis effectuer une écriture groupée une fois par seconde est une manière raisonnable de contourner la limite de SQLite sur les écritures multiples, ou s’il existe de meilleures idées
On peut aussi le démontrer facilement par benchmark, et mettre l’ensemble des éléments du lot dans une seule transaction
Avec le traitement par lots, on se retrouve en pratique avec un seul thread qui prend un lot et effectue l’écriture réelle, ce qui s’accorde bien avec la limite d’une seule écriture concurrente de SQLite
Cela dit, la complexité augmente un peu. Il faut décider quoi faire si une écriture groupée ne se termine pas en moins d’une seconde, si la taille de la file stockée en mémoire doit être illimitée, si, dans ce cas, on peut être sûr que le serveur ne mourra pas par OOM en cas de surcharge, et si l’on fixe une limite, s’il faut accepter de perdre des éléments, quels éléments jeter le cas échéant, et quelle limite donner à la file
Ces questions se posent inévitablement dans presque tous les systèmes qui ont besoin d’une file, et elles seront utiles aussi bien maintenant que plus tard
Si vous n’avez pas confiance dans les performances de concurrence des transactions de SQLite, vous pouvez sérialiser toutes les écritures dans un thread ou un processus dédié
Avec la fonctionnalité async-insert[0] de ClickHouse, vous n’avez pas à vous soucier du traitement par lots des événements côté application
Si vous cherchez une solution embarquée, vous pouvez utiliser chDB, basé sur ClickHouse
[0] https://clickhouse.com/blog/asynchronous-data-inserts-in-cli...
Vous pouvez avoir autant de lecteurs que vous voulez, et le rédacteur peut recevoir ses entrées depuis quelque chose comme une file
Rien qu’avec ça, les performances sont souvent vraiment surprenantes
Je ne comprends toujours pas très bien cette tendance à vouloir utiliser SQLite comme base de données backend en production
SQLite est excellent comme petite base de données applicative côté client, embarquable, par exemple pour le carnet d’adresses d’un téléphone, mais ses propres développeurs ont constamment refusé de l’étendre au-delà de ce périmètre
Par exemple, ils n’ajoutent pas de types natifs utiles comme date/heure ou UUID. Parce que cela ferait grossir le code et la taille des objets embarqués, avec pour résultat qu’on reste coincé dans un monde où « tout est une chaîne »
L’intégrité référentielle peut être activée, mais les options de contraintes restent très limitées
Je ne comprends pas pourquoi on continue à vouloir le faire entrer de force dans un rôle qui ne lui convient pas et qui n’est pas vraiment pris en charge
La raison est simple. Pour les schémas d’accès majoritairement en lecture, SQLite est extrêmement rapide, suffisamment pour simplifier le code d’accès à la base de données, et par exemple les requêtes N+1 ne sont souvent pas un vrai problème
SQLite supprime aussi une couche dans une architecture N-tiers, et donc autant d’éléments susceptibles de tomber en panne. Si vous avez déjà exploité vous-même Postgres ou MySQL, vous savez que beaucoup de choses peuvent réellement mal tourner
Ce n’est pas le choix parfait pour toutes les applis, ni même pour une majorité d’entre elles, mais la tendance actuelle ressemble plutôt à un rééquilibrage face à l’idée manifestement fausse selon laquelle SQLite ne conviendrait qu’aux « petites bases de données embarquées pour applis clientes »
Une fois qu’on le voit ainsi, avoir un autre SGBD à côté d’un SGBD qui manipule les mêmes données devient assez absurde
Du coup, soit on laisse le client se connecter directement à Postgres, soit on retire Postgres et on se concentre davantage sur son propre SGBD
Si l’on choisit la deuxième option, SQLite est un moteur pratique sur lequel construire. Il n’est pas parfait, mais c’est l’outil dont on dispose aujourd’hui
Cette prise de conscience ne s’est diffusée à grande échelle que relativement récemment, donc beaucoup d’expérimentations sont en cours pour voir ce qui fonctionne ou non
C’est le cycle naturel de l’informatique : l’ancien redevient nouveau
On peut remplacer Postgres par MySQL, MSSQL, Oracle ou tout autre SGBD dans cette lecture
Ce genre de simplification profite à plusieurs égards non seulement aux développeurs de SQLite et au matériel peu puissant, mais aussi aux développeurs d’applications
La documentation est plus simple, la courbe d’apprentissage plus courte, la surface de bugs et la taille du binaire diminuent aussi
Les logiciels actuels ont tendance à ajouter de l’embonpoint et de la complexité partout ; c’est très bien que quelques projets comme SQLite y résistent
Excellent article, et je me demande s’il existe quelque chose de similaire pour Django
ArchiveBox utilise SQLite via Django, et nous avons assez souvent rencontré exactement les mêmes problèmes que ceux décrits dans l’article pour Rails
Ce serait bien d’avoir une solution au niveau de SQLite qui n’oblige pas à sérialiser toutes les écritures par un autre chemin dans l’application
Le gem
sqlite3-ruby, par conception, ne libère pas le GVL pendant les appels SQLite ; d’après le commentaire de l’issue liée https://github.com/sparklemotion/sqlite3-ruby/issues/287#iss..., il semble qu’on soupçonnait un coût important pour reprendre le verrou, mais que cela n’avait pas été vérifiéAvec tous ces contournements, cela paraît un peu douteux
Dans la culture des extensions Python, j’ai l’impression que cela aurait été conçu à l’inverse, et je me demande comment ils font réellement de ce côté-là
En plus, dans l’issue liée, un commentaire indique que « le gem extralite est un client SQLite alternatif qui libère le GVL pendant les opérations bloquantes, et les détails liés à la concurrence sont ici : https://github.com/digital-fabric/extralite?tab=readme-ov-fi.... En général, il est beaucoup plus rapide que ce gem et n’a pas de problèmes de concurrence »
Il a été vérifié que libérer simplement le GVL à chaque
stepde la machine virtuelle SQLite dégrade fortement les performances monothreadTrouver un compromis entre performances monothread et multithread est délicat
Avec Rails, on sait que l’exécution est multithread à cause du pool de connexions, mais le gem de bas niveau est aussi largement utilisé par d’autres bibliothèques et outils dans des environnements monothread
Voici quelques réglages que je garde pour mon petit service web perso
PRAGMA journal_mode = WAL;PRAGMA busy_timeout = 5000;PRAGMA synchronous = NORMAL;PRAGMA cache_size = 1000000000;PRAGMA foreign_keys = true;PRAGMA temp_store = memory;Et j’utilise des transactions BEGIN IMMEDIATE
https://kerkour.com/sqlite-for-servers
cache_sizeetmmap_size?J’aime SQLite comme Rails, mais là, cela ressemble à utiliser MS Access en production
En plus, les ordinateurs et les disques sont aujourd’hui beaucoup plus rapides qu’il y a des décennies, quand Access était davantage utilisé
Pour des charges surtout en lecture, SQLite, et même Jet, peuvent probablement atteindre assez facilement des dizaines de milliers de requêtes par seconde
La plupart des applications n’ont pas des centaines de milliers d’utilisateurs simultanés, donc SQLite peut très bien convenir
Autre point fort de SQLite : il existe des clients pour presque toutes les plateformes et tous les langages
L’archivage, les sauvegardes et la portabilité sont aussi des cas d’usage qui conviennent bien à SQLite. Sur un projet avec des saisies de données limitées à une certaine période, j’ai fortement poussé l’usage d’un SQLite par boîte, et je pense encore aujourd’hui que cela aurait été mieux
Au lieu de créer un schéma complexe et des fonctions d’export/archivage personnalisées, on aurait pu simplement copier un seul fichier pour en faire une archive ou une sauvegarde, sans avoir à se soucier autant des évolutions de schéma au fil du temps
Cela dépend du contexte, mais c’est une solution plutôt correcte pour beaucoup de problèmes. C’est un peu comme le fait que, pour la plupart des applications, PostgreSQL ou un autre SGBDR est souvent un meilleur choix que des options NoSQL plus scalables
On avait tendance à surconcevoir, et les performances actuelles du calcul et des entrées/sorties approchent désormais un niveau où ces efforts se justifient de moins en moins
Article très instructif et bien écrit
Je me demande pourquoi la méthode
busy_timeoutpar défaut utilise un délai exponentiel qui pénalise les anciennes requêtesEn quoi est-ce un choix raisonnable par défaut ?