1 Punkte von GN⁺ 2025-04-29 | 1 Kommentare | Auf WhatsApp teilen
  • 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

 
GN⁺ 2025-04-29
Hacker-News-Kommentare
  • Wir verwenden den Ansatz „database-per-tenant“ mit rund 1 Million Nutzern

    • Dieser Ansatz eignet sich gut für lesezentrierte Apps, und die meisten Tenants sind klein und haben nicht viele Datensätze pro Tabelle, sodass selbst komplexe Joins sehr schnell sind
    • Das Hauptproblem ist, dass einzelne Datenbanken nacheinander migriert werden müssen, was die Release-Zeit erheblich verlängern kann
    • Wenn Schema- oder Daten-Drift auftritt, wird das Release blockiert, und man muss herausfinden, warum Funktionen bei einigen Tenants nicht funktionieren
  • Ich mag SQLite, frage mich aber, ob bestehende OLTP-Datenbanken Teile ihrer Indizes aus dem Speicher auslagern müssen

    • Bei Datenbanken pro Benutzer wird für inaktive Benutzer oder Benutzer, die nur auf anderen Instanzen aktiv sind, nichts im Speicher gehalten
    • Das ist ähnlich wie die JSON-Situation bei Mongo, wobei Postgres doppelt so schnell ist wie Mongo
  • Die meisten Menschen brauchen keine Datenbank pro Tenant; das ist kein gängiger Ansatz

    • Es gibt bestimmte Fälle, in denen das die Nachteile wie Migrationen und Schema-Drift aufwiegt
    • Nur weil man es nutzen kann, heißt das nicht, dass man es auch nutzen sollte
    • Man sollte vorsichtig vorgehen und wissen, dass man wirklich eine Datenbank pro Tenant braucht
  • Als Mittelweg könnte man Folgendes in Betracht ziehen

    • Die Top-N-Tenants identifizieren
    • Die DBs für diese Tenants auslagern
    • Die Top N werden anhand von IOPS, Wichtigkeit (in Bezug auf Umsatz) usw. bestimmt
    • Das Datenmodell sollte so entworfen sein, dass die Zeilen für jeden Tenant extrahiert werden können
  • Zufällig arbeite ich gerade an FeebDB für Elixir

    • Man kann es als Alternative zu Ecto sehen, das nicht gut funktioniert, wenn es Tausende von Datenbanken gibt
    • Es begann hauptsächlich als interessantes Experiment, aber an jedem Ort, an dem ich früher gearbeitet habe, wäre diese Architektur eine große Hilfe gewesen
    • Ziel ist es, die üblichen Probleme des Datenbank-pro-Tenant-Ansatzes zu beseitigen oder zu verringern
    • Garantiert einen einzelnen Writer pro Datenbank
    • Verbesserte Verbindungsverwaltung für alle Tenants
    • Unterstützung für Migrationen und Backups bei Bedarf
    • Unterstützung für Map/Reduce/Filter-Operationen über mehrere DBs
    • Unterstützung für Cluster-Deployment
  • Forward Email macht etwas Ähnliches und verwendet eine verschlüsselte sqlite-DB pro Mailbox/Benutzer

    • Das ist eine großartige Möglichkeit, den Schutz pro Benutzer zu differenzieren
  • Der Name ist wirklich großartig. Er erinnert an Sean Connery

  • Der Workflow „database per tenant“ steht jetzt erst am Anfang

    • James Edward Gray sprach 2012 auf der RailsConf darüber
  • Ich habe in der Vergangenheit etwas Ähnliches verwendet und war sehr zufrieden damit

    • Wenn Benutzer ihre Daten wollten, konnte man ihnen die komplette Datenbank geben
    • Wenn Benutzer ihr Konto löschten, konnte man das einfach mit rm username.sql erledigen
    • Compliance wird dadurch sehr einfach
  • Es ist schwer, eine falsche Architektur zu bauen, wenn die Daten voneinander isoliert sind und es innerhalb eines einzelnen Tenants keine Skalierungsprobleme gibt

    • Fast alles wird funktionieren