- Explique comment mettre en place une architecture utilisant une base de données distincte pour chaque locataire dans Rails, ainsi que les difficultés rencontrées
- ActiveRecord est conçu à l’origine en partant du principe d’une connexion à une base unique, ce qui rend le basculement de connexion par locataire complexe et délicat
- Propose une méthode pour basculer dynamiquement les connexions à l’exécution en exploitant la fonctionnalité connected_to de Rails 6 et versions ultérieures
- SQLite3 convient bien à la gestion d’un grand nombre de petites bases indépendantes, ce qui facilite les opérations de sauvegarde, débogage et suppression
- Souligne qu’à l’inverse de l’infrastructure Rails, surtout développée pour optimiser les très grands systèmes, une architecture centrée sur de petites bases de données indépendantes est tout à fait possible
Pourquoi utiliser une base de données distincte pour chaque locataire
- En séparant les données par locataire (
Site), qui fonctionne de manière autonome dans le modèle de données, l’isolation et l’administration deviennent plus simples
- Stocker les données de chaque locataire dans une base distincte est aussi avantageux pour la montée en charge des grands sites et pour les enjeux de sécurité
- Avec SQLite, une simple archive fichier suffit pour exploiter la base de données, sans configuration serveur, ce qui rend l’approche simple et flexible
Les difficultés dans Rails
- Les opérations
open/close de base de SQLite sont très simples, mais ActiveRecord repose en interne sur une gestion des connexions complexe
- ActiveRecord est conçu pour attacher les connexions aux modèles, ce qui complique le changement de locataire à l’exécution
- Le pool de connexions, le cache des requêtes et le cache de schéma dépendent tous de la connexion, ce qui rend chaque changement de connexion coûteux
Historique de la gestion multibase dans Rails
- Rails 1 : possibilité de définir la base au niveau de
ActiveRecord::Base
- Rails 3 : introduction du pool de connexions
- Rails 4 : ajout de
connection_handling
- Rails 6 : introduction de
connected_to
- Rails 7 : extension de
connected_to et prise en charge du sharding
- Mais des scénarios comme l’« ajout/suppression dynamique de locataires à l’exécution » ne sont toujours pas pris en charge nativement
Avantages d’une base de données par locataire
- On peut sauvegarder ou restaurer uniquement les fichiers d’un locataire, ce qui simplifie l’exploitation et le débogage
- Supprimer un locataire revient simplement à supprimer le fichier (
unlink)
- Les gros serveurs de bases de données optimisent des bases de plusieurs dizaines de téraoctets, alors que SQLite est optimisé pour des milliers de petites bases
- En pratique, iCloud adopte aussi une architecture stockant des millions de petites bases SQLite au-dessus de Cassandra
Démarche de résolution du problème
- L’approche existante (
establish_connection manuel) provoquait des erreurs ConnectionNotEstablished dans des environnements à connexions multiples
- En s’alignant sur l’approche de Rails 6 et versions suivantes, la structure a été modifiée pour laisser Rails gérer les connexions plutôt que de piloter manuellement le pool
- Un pool de connexions est créé dynamiquement pour chaque locataire, puis les opérations sont encapsulées dans un bloc
connected_to
- L’utilisation d’un middleware a permis d’améliorer le système en préparant et en libérant dynamiquement la connexion DB nécessaire au moment de la requête
Motif de code essentiel
- Vérifier le pool de connexions puis le créer s’il n’existe pas
MUX.synchronize do
if ActiveRecord::Base.connection_handler.connection_pool_list(role_name).none?
ActiveRecord::Base.connection_handler.establish_connection(database_config_hash, role: role_name)
end
end
- Après la connexion, exécuter les requêtes en toute sécurité dans le bloc
connected_to
ActiveRecord::Base.connected_to(role: role_name) do
pages = Page.order(created_at: :desc).limit(10)
end
Gestion du streaming Rack
- Lorsque la réponse Rack est diffusée en streaming, la connexion est fermée proprement à l’aide de
Rack::BodyProxy et de Fiber pour garantir une gestion sûre des connexions
connected_to_context_fiber = Fiber.new do
ActiveRecord::Base.connected_to(role: role_name) do
Fiber.yield
end
end
connected_to_context_fiber.resume
status, headers, body = @app.call(env)
body_with_close = Rack::BodyProxy.new(body) { connected_to_context_fiber.resume }
[status, headers, body_with_close]
Structure finale du middleware
- Un middleware
Shardine::Middleware a été écrit pour trouver la connexion DB appropriée à chaque requête, basculer via connected_to, puis nettoyer une fois la réponse terminée
- Il peut être appliqué dans le fichier
config.ru d’un projet Rails comme suit
use Shardine::Middleware do |env|
site_name = env["SERVER_NAME"]
{adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end
Travaux restant à faire
- Dans ActiveRecord 6, la fonctionnalité
shard n’a pas encore été exploitée, mais les versions suivantes permettent aussi la séparation lecture/écriture
- La fonction de nettoyage du pool de connexions lors de la suppression d’un locataire n’a pas encore été implémentée, car elle n’est pas encore nécessaire
- À l’avenir, les architectures gérant « un grand nombre de petites bases de données » pourraient attirer davantage l’attention
1 commentaires
Avis Hacker News
Utilise l’approche « database-per-tenant » avec environ 1 million d’utilisateurs
Aime SQLite, mais se demande si les bases OLTP existantes doivent décharger une partie des index de la mémoire
La plupart des gens n’ont pas besoin d’une base de données par tenant, et ce n’est pas l’approche habituelle
On peut envisager une approche intermédiaire
Travaille par hasard sur FeebDB pour Elixir
Forward Email fait quelque chose de similaire en utilisant une base sqlite chiffrée pour chaque boîte mail/utilisateur
Le nom est vraiment excellent. Cela fait penser à Sean Connery
Le workflow « database per tenant » n’en est qu’à ses débuts
A déjà utilisé quelque chose de similaire par le passé et en était très satisfait
rm username.sqlIl est difficile de faire un mauvais design quand les données sont isolées les unes des autres et qu’il n’y a pas de problème de montée en charge à l’intérieur d’un seul tenant