- Es wird erklärt, wie sich in Rails eine Architektur mit einer separaten Datenbank pro Mandant aufbauen lässt und welche Herausforderungen dabei auftreten
- ActiveRecord ist standardmäßig auf eine einzelne DB-Verbindung ausgelegt, wodurch das Umschalten zwischen mandantenbezogenen Verbindungen komplex und heikel wird
- Es wird vorgeschlagen, die Funktion connected_to ab Rails 6 zu nutzen, um Verbindungen zur Laufzeit dynamisch umzuschalten
- SQLite3 eignet sich für viele kleine, unabhängige Datenbanken und erleichtert die Verwaltung bei Backups, Debugging und Löschung
- Im Gegensatz zur Rails-Infrastruktur, die sich vor allem auf die Optimierung großer Systeme entwickelt hat, wird betont, dass auch eine Architektur mit kleinen, unabhängigen Datenbanken möglich ist
Warum für jeden Mandanten eine separate Datenbank verwenden?
- Wenn nach der Mandanten-Einheit
Site getrennt wird, die im Datenmodell unabhängig arbeitet, werden Datenisolierung und Verwaltung einfacher
- Werden die Daten pro Mandant in einer eigenen DB gespeichert, ist das auch für die Skalierung großer Sites oder bei Sicherheitsfragen vorteilhaft
- Mit SQLite lässt sich eine Datenbank ohne Server-Konfiguration allein als Datei betreiben, was einfach und flexibel ist
Was in Rails schwierig ist
- Das grundlegende
open/close von SQLite ist sehr einfach, aber ActiveRecord besitzt intern eine komplexe Struktur zur Verbindungsverwaltung
- ActiveRecord ist so entworfen, dass Verbindungen an Modelle gebunden sind, weshalb ein Mandantenwechsel zur Laufzeit schwierig ist
- Connection Pool, Query Cache und Schema Cache hängen alle von der Verbindung ab, sodass ein ständiger Verbindungswechsel aufwendig ist
Die Geschichte der Verwaltung mehrerer Datenbanken in Rails
- Rails 1: DB-Angabe auf Ebene von
ActiveRecord::Base möglich
- Rails 3: Einführung des Connection Pool
- Rails 4:
connection_handling hinzugefügt
- Rails 6:
connected_to eingeführt
- Rails 7:
connected_to erweitert und Unterstützung für Sharding ergänzt
- Dennoch werden Szenarien wie das „dynamische Hinzufügen oder Entfernen von Mandanten zur Laufzeit“ weiterhin nicht standardmäßig unterstützt
Vorteile mandantenbezogener Datenbanken
- Da sich Dateien pro Mandant einzeln sichern oder wiederherstellen lassen, werden Betrieb und Debugging einfacher
- Das Entfernen eines Mandanten ist durch einfaches Löschen der Datei (
unlink) möglich
- Große Datenbankserver optimieren Datenbanken im Umfang von vielen Dutzend Terabyte, während SQLite für Tausende kleiner Datenbanken optimiert ist
- Tatsächlich setzt auch iCloud auf eine Struktur, bei der Millionen kleiner SQLite-Datenbanken auf Cassandra gespeichert werden
Der Weg zur Problemlösung
- Der bisherige Ansatz (manuelles
establish_connection) führte in Umgebungen mit mehreren gleichzeitigen Verbindungen zu Fehlern vom Typ ConnectionNotEstablished
- Passend zum Ansatz ab Rails 6 wurde die Struktur geändert: Statt den Connection Pool manuell zu verwalten, wird dies Rails überlassen
- Für jeden Mandanten wird dynamisch ein Connection Pool erzeugt, und die Arbeit wird in einen
connected_to-Block eingeschlossen
- Mithilfe von Middleware wurde der Ansatz so verbessert, dass die benötigte DB-Verbindung zum Zeitpunkt der Anfrage dynamisch vorbereitet und wieder freigegeben wird
Zentrales Code-Muster
- Den Connection Pool prüfen und nur bei Bedarf erzeugen
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
- Nach dem Verbindungsaufbau Abfragen sicher innerhalb eines
connected_to-Blocks ausführen
ActiveRecord::Base.connected_to(role: role_name) do
pages = Page.order(created_at: :desc).limit(10)
end
Rack-Streaming-Verarbeitung
- Wenn eine Rack-Antwort gestreamt wird, werden zur Verbindungsverwaltung
Rack::BodyProxy und Fiber genutzt, um die Verbindung sicher zu schließen
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]
Endgültige Middleware-Struktur
- Es wurde eine Middleware
Shardine::Middleware geschrieben, die pro Anfrage die passende DB-Verbindung findet, mit connected_to umschaltet und nach Ende der Antwort aufräumt
- Sie kann in der
config.ru-Datei eines Rails-Projekts wie folgt eingebunden werden
use Shardine::Middleware do |env|
site_name = env["SERVER_NAME"]
{adapter: "sqlite3", database: "sites/#{site_name}.sqlite3"}
end
Verbleibende Aufgaben
- In ActiveRecord 6 wurde die
shard-Funktion noch nicht genutzt, in späteren Versionen ist jedoch auch eine Trennung von Lesen und Schreiben möglich
- Eine Funktion zum Aufräumen des Connection Pool beim Löschen eines Mandanten wurde noch nicht implementiert, da sie bisher nicht benötigt wurde
- Künftig könnte eine Architektur, die „viele kleine Datenbanken“ verwaltet, noch mehr Aufmerksamkeit erhalten
1 Kommentare
Hacker-News-Kommentare
Wir verwenden den Ansatz „database-per-tenant“ mit rund 1 Million Nutzern
Ich mag SQLite, frage mich aber, ob bestehende OLTP-Datenbanken Teile ihrer Indizes aus dem Speicher auslagern müssen
Die meisten Menschen brauchen keine Datenbank pro Tenant; das ist kein gängiger Ansatz
Als Mittelweg könnte man Folgendes in Betracht ziehen
Zufällig arbeite ich gerade an FeebDB für Elixir
Forward Email macht etwas Ähnliches und verwendet eine verschlüsselte sqlite-DB pro Mailbox/Benutzer
Der Name ist wirklich großartig. Er erinnert an Sean Connery
Der Workflow „database per tenant“ steht jetzt erst am Anfang
Ich habe in der Vergangenheit etwas Ähnliches verwendet und war sehr zufrieden damit
rm username.sqlerledigenEs ist schwer, eine falsche Architektur zu bauen, wenn die Daten voneinander isoliert sind und es innerhalb eines einzelnen Tenants keine Skalierungsprobleme gibt