RUBY ON RAILS · 15 MIN READ ·

Rails Multi-Tenancy: Schemas, Row-Level en Aparte Databases — De Juiste SaaS-aanpak Kiezen

Rails multi-tenancy patronen: row-level scoping, Postgres schemas, aparte databases. Wanneer elk werkt, wanneer het breekt, en hoe je ertussen migreert.

Rails Multi-Tenancy: Schemas, Row-Level en Aparte Databases — De Juiste SaaS-aanpak Kiezen

Een B2B SaaS-oprichter belde me twee zomers geleden, drie jaar bezig met een product dat twee miljoen ARR had overschreden en nu deals verloor aan enterprise-kopers. De reden op elke verloren-deal notitie was hetzelfde: “isolatie-eisen.” Zijn app had één Postgres-database, elke rij droeg een account_id, en elke controller deed current_account.invoices.find(params[:id]). Het werkte, totdat het procurement-team van een prospect hen een veiligheidsvragenlijst van 14 pagina’s overhandigde die op zeven verschillende manieren vroeg waar hun data fysiek leefde. We brachten de volgende vier maanden door met het migreren van de top 8% van klanten naar geïsoleerde databases, terwijl we de lange staart op het gedeelde cluster lieten staan, en sloten binnen negentig dagen na voltooiing drie enterprise-contracten.

Na negentien jaar Rails heb ik Rails multi-tenancy gebouwd op elk model dat ik hier beschrijf, en de les blijft zich herhalen: het juiste patroon is niet degene met het beste isolatieverhaal, maar degene die past bij je klantmix, je operationele volwassenheid en de manier waarop je data daadwerkelijk groeit. Deze post is het raamwerk dat ik doorloop met elke fractional CTO-klant die probeert te kiezen tussen row-level tenancy, Postgres schemas en aparte databases — inclusief de migratiepaden ertussen, want de meeste succesvolle SaaS-apps zullen er uiteindelijk meer dan één nodig hebben.

Wat Rails Multi-Tenancy Eigenlijk Betekent

Een multi-tenant SaaS-applicatie bedient veel klanten (tenants) vanuit dezelfde codebase. De vraag is waar je de grens trekt tussen tenants in de database. Er zijn drie eerlijke antwoorden:

Row-level tenancy zet de data van elke tenant in dezelfde tabellen, met een tenant_id (of account_id, workspace_id, organization_id) kolom op elke tenant-eigen rij. Elke query filtert op die kolom. Dit is waar de meeste Rails-apps mee beginnen en waar de meeste voor altijd op blijven.

Schema-level tenancy gebruikt Postgres schemas om elke tenant zijn eigen namespace binnen één database te geven. Tabellen worden per schema gedupliceerd; je wisselt schemas per request. Dit is wat de inmiddels gearchiveerde apartment gem in 2014 beroemd maakte.

Database-level tenancy geeft elke tenant een volledig aparte database — zelfde schema, aparte fysieke opslag. Connecties worden per request gewisseld, meestal op basis van subdomein of JWT-claim.

Geen van deze is universeel correct. De beste architecturen die ik heb gebouwd, gebruiken er twee tegelijk: row-level voor de lange staart, aparte databases voor de topklanten, nergens schema-level. Ik leg uit waarom.

Row-Level Rails Multi-Tenancy: De Standaard

Row-level Rails multi-tenancy is de juiste keuze voor bijna elke SaaS die nog niet aan een Fortune 500 heeft verkocht. Het is operationeel eenvoudig, schaalt netjes totdat je grootste tenant een aanzienlijk deel van de totale data wordt, en het is het enige model waarbij je één Rails console kunt draaien om een bug bij alle klanten te onderzoeken.

Het patroon is rechttoe rechtaan en is sinds 2010 niet veel veranderd. Elk tenant-eigen model behoort tot een Account. Elke query is automatisch scoped. Elke controller stelt een tenant-context in.

class Account < ApplicationRecord
  has_many :users
  has_many :invoices
  has_many :projects
end

class Invoice < ApplicationRecord
  belongs_to :account
  validates :account_id, presence: true
end

class ApplicationController < ActionController::Base
  before_action :set_current_account

  private

  def set_current_account
    Current.account = current_user.account
  end
end

class Current < ActiveSupport::CurrentAttributes
  attribute :account
end

De fout die de meeste teams maken is daar stoppen en erop vertrouwen dat elke controller current_account.invoices aanroept. Dat werkt voor de eerste 50 controllers en breekt op het moment dat een van je engineers Invoice.find(params[:id]) schrijft en uitrolt. We hebben deze exacte faalwijze behandeld in Rails Pundit-autorisatie voor multi-tenant SaaS — IDOR-kwetsbaarheden zijn de nummer één bugklasse in row-level tenancy.

De oplossing is verdediging in lagen. Gebruik Pundit-scopes voor autorisatie, en voeg een veiligheidsnet op Postgres-niveau toe met Row Level Security (RLS) voor de echt gevoelige tabellen.

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON invoices
  USING (account_id = current_setting('app.current_account_id')::bigint);
class ApplicationController < ActionController::Base
  around_action :set_tenant_context

  private

  def set_tenant_context
    ActiveRecord::Base.connection.execute(
      "SET LOCAL app.current_account_id = #{current_user.account_id.to_i}"
    )
    yield
  ensure
    ActiveRecord::Base.connection.execute("RESET app.current_account_id")
  end
end

Zelfs als een ontwikkelaar nu Invoice.find(params[:id]) schrijft en een vijandige gebruiker de ID van een andere tenant doorgeeft, retourneert Postgres “not found” in plaats van de rij te lekken. Deze gordel-en-bretels aanpak is wat ik inzet wanneer row-level tenancy het gekozen patroon is maar de data gevoelig is — zorg, financieel, HR.

Row-level Rails multi-tenancy breekt af wanneer de data van één klant je database domineert. Als één tenant 80% van je events tabelrijen heeft, optimaliseert elk queryplan voor hen en hongert het de rest uit. Als één tenant compliance-eisen heeft (data-residentie, encryptiesleutels die zij beheren, audit-logretentie anders dan de rest), kan row-level dat helemaal niet bevredigen. Dat is wanneer je naar de volgende twee opties begint te kijken.

Schema-Level Tenancy: Waarom Ik Het Niet Meer Gebruik

Schema-level Rails multi-tenancy klinkt op papier aantrekkelijk. Elke tenant krijgt een Postgres-schema. SET search_path TO tenant_42, public en elke query scoped zichzelf magisch. Geen account_id kolommen. Geen risico op cross-tenant queries. De apartment gem maakte dit patroon een paar jaar populair.

In de praktijk is schema-level tenancy de slechtste van de drie opties voor de meeste productie Rails-apps, en ik heb het sinds 2019 aan geen enkele klant meer aanbevolen. De redenen stapelen zich op:

Migraties worden een nachtmerrie. Een schemawijziging moet tegen elk schema draaien. Met duizend tenants draai je dezelfde migratie duizend keer. Langlopende schemawijzigingen blokkeren deploys. Een kolom toevoegen aan een tabel met 10 miljoen rijen is al moeilijk genoeg één keer; het duizend keer per deploy doen is operationeel vijandig.

De druk op de connectiepool is bruut. Elke request wisselt het search_path. PgBouncer in transaction mode houdt hier niet van, wat ik behandelde in Postgres connection pooling met PgBouncer voor Rails. Of je pint connecties (wat het delen van je pool vernietigt) of je riskeert dat een search_path van de ene request naar de andere lekt.

Rapportage over tenants heen wordt onmogelijk. Wil je weten hoeveel facturen er bestaan over alle klanten heen? Met row-level tenancy is dat een SELECT COUNT(*). Met schema-level moet je elk schema doorlopen, elk bevragen en in Ruby aggregeren. Elke cross-tenant analysequery wordt een lus van duizend queries.

Back-ups worden gigantisch. pg_dump van een database met duizend schemas is dramatisch langzamer dan pg_dump van een database met één schema en een tenant_id kolom. RDS snapshots maken het niet uit, maar logische replicatie wel.

Schema-level tenancy is precies in één situatie zinvol: je hebt een klein aantal grote tenants (zeg, tientallen, niet duizenden) en een regelgevende eis die zegt dat tenants geen tabellen mogen delen. Dat is zeldzaam. Voor alle anderen: sla het over.

Database-Level Tenancy: De Enterprise-Tier

Aparte databases per tenant — dezelfde Rails-app, verschillende fysieke Postgres-clusters — is het juiste antwoord wanneer een of meer van deze waar zijn:

Een specifieke klant heeft data-residentie-eisen (hun data moet in Frankfurt leven, niet in Virginia). Een specifieke klant heeft encryption-at-rest eisen met een sleutel die zij beheren. Een specifieke klant is zo groot dat ze queryplannen op het gedeelde cluster zouden domineren. Je verkoopt aan gereguleerde industrieën waar “logisch geïsoleerd” niet genoeg is.

Rails ondersteunt dit netjes sinds Rails 6 met meerdere databases, en Rails 7+ maakte het veel makkelijker met de multiple databases API. Hier is de setup die in productie werkt:

# config/database.yml
production:
  primary:
    database: saas_shared
    host: <%= ENV["DB_SHARED_HOST"] %>
    pool: 20
  primary_replica:
    database: saas_shared
    host: <%= ENV["DB_SHARED_REPLICA_HOST"] %>
    pool: 20
    replica: true

Configureer voor de per-tenant databases ze dynamisch in plaats van statisch. Je zult er geen plezier aan beleven om database.yml te bewerken elke keer dat je een nieuwe enterprise-klant onboardt.

class TenantDatabaseSwitcher
  def self.with_tenant(account)
    config = {
      adapter: "postgresql",
      database: account.database_name,
      host: account.database_host,
      username: account.database_username,
      password: Rails.application.credentials.dig(:tenant_databases, account.id, :password),
      pool: 5
    }

    ActiveRecord::Base.establish_connection(config)
    yield
  ensure
    ActiveRecord::Base.establish_connection(:primary)
  end
end

class ApplicationController < ActionController::Base
  around_action :switch_tenant_database, if: :isolated_tenant?

  private

  def switch_tenant_database
    TenantDatabaseSwitcher.with_tenant(current_account) { yield }
  end

  def isolated_tenant?
    current_account&.isolated_database?
  end
end

Dit is het hybride patroon dat ik blijf aanbevelen: de lange staart van klanten leeft op het gedeelde cluster met row-level tenancy, en een klein aantal grote of gereguleerde klanten krijgt een dedicated database. Het Account-model heeft een isolated_database? vlag en een database_host kolom; de controller vertakt erop.

De operationele kosten zijn reëel. Elke nieuwe tenant-database moet provisioned, gemigreerd, geback-upt, gemonitord en qua connecties beheerd worden. Je zult uiteindelijk een klein intern tool schrijven dat rails db:migrate draait tegen elke tenant-database bij elke deploy. Gebruik GoodJob of SolidQueue om die migraties parallel te draaien, met één job per tenant-database, en een statusbord zodat je kunt zien welke databases op welke schemaversie zitten.

Hoe Te Beslissen Welk Rails Multi-Tenancy Patroon Te Gebruiken

Na tientallen van deze gesprekken met oprichters is de beslisboom korter dan mensen verwachten:

Begin met row-level. Elke SaaS zou hier moeten beginnen, tenzij je een specifieke contractuele reden hebt om dat niet te doen. Row-level schaalt verder dan mensen vrezen — Basecamp, GitHub, Linear en Notion draaien allemaal enorme row-level multi-tenant systemen.

Voeg Row Level Security toe in Postgres als je data gevoelig is (PII, PHI, financieel). Het is een wijziging van één avond die een echt veiligheidsnet onder je application-level scoping toevoegt.

Wanneer je top 5% klanten compliancevragen begint te stellen die je niet kunt beantwoorden met row-level — data-residentie, customer-managed encryptiesleutels, dedicated back-ups — introduceer dan database-level tenancy als een premium-tier. Reken er een prijs voor. De operationele kosten zijn reëel, en klanten die het nodig hebben begrijpen dat ze voor isolatie betalen.

Sla schema-level tenancy over, tenzij je een specifieke regelgevende eis hebt die zegt dat tabellen niet gedeeld kunnen worden tussen tenants, en zelfs dan check of RLS op dezelfde tabellen de auditor zou tevredenstellen. Meestal doet het dat.

Migreren Van Row-Level Naar Database-Level

Dit is de migratie die ik het vaakst heb uitgevoerd, en het draaiboek is elke keer hetzelfde. Je herschrijft je app niet; je verplaatst de rijen van één tenant van de gedeelde database naar een verse geïsoleerde database, zonder downtime voor die tenant.

De netste tool hiervoor is Postgres logische replicatie, die ik uitgebreid behandelde in Postgres logische replicatie voor Rails: zero-downtime major version upgrades. Dezelfde machinerie werkt voor het uitsplitsen van één tenant uit een gedeelde database.

De volgorde is:

  1. Provision een nieuwe lege database voor de tenant. Draai schemamigraties zodat de structuur overeenkomt.
  2. Maak een publicatie op de gedeelde database aan die alleen op de rijen van deze tenant filtert. Postgres 15+ ondersteunt rij-filters in publicaties, wat precies is wat je nodig hebt.
CREATE PUBLICATION tenant_42_export
  FOR TABLE invoices WHERE (account_id = 42),
      TABLE projects WHERE (account_id = 42),
      TABLE users WHERE (account_id = 42);
  1. Maak een subscription op de nieuwe database. Initiële kopie plus streaming changes. Wacht tot pg_stat_subscription.received_lsn is bijgewerkt.
  2. Tijdens een korte onderhoudsperiode voor alleen die tenant, flip de Account.isolated_database? vlag en de database_host kolom. De volgende request van die tenant gaat naar de nieuwe database.
  3. Verifieer dat de nieuwe database writes ontvangt. Stop de subscription. Verwijder de publicatie. Verwijder de rijen van die tenant uit de gedeelde database.

Totale gebruikerszichtbare downtime per tenant: ongeveer dertig seconden, beperkt tot de requests van die tenant. De rest van de SaaS blijft draaien. We deden dit voor veertien enterprise-klanten over twee maanden zonder één enkele klacht en zonder één enkel productie-incident.

FAQ

Moet ik de apartment gem gebruiken voor Rails multi-tenancy?

De apartment gem heeft sinds 2020 geen betekenisvolle release meer gehad en is functioneel gearchiveerd. Ik raad het niet aan voor nieuwe projecten. Het schema-level patroon dat het implementeert heeft de operationele problemen die hierboven zijn beschreven, en de paar teams die ik ken die het in productie draaien, migreren er actief vanaf. Als je het row-level patroon wilt, schrijf het dan zelf met Current.account en Pundit-scopes — het zijn minder dan 100 regels code en je begrijpt elke regel. Als je isolatie wilt, gebruik dan aparte databases.

Hoe werkt Rails multi-tenancy met background jobs?

De job moet weten voor welke tenant hij draait. Geef account_id als job-argument door, en stel in een base job class Current.account in voordat perform draait en reset het daarna. Als je database-level tenancy gebruikt, moet je job ook de connectie wisselen. Wikkel de connectie-switch om perform. SolidQueue en GoodJob maken dit allebei netjes omdat de job in zijn eigen thread draait; stel tenant-context nooit in buiten de perform-grens, anders lek je state tussen jobs.

Is Postgres Row Level Security een vervanging voor application-level scoping in Rails?

Nee, het is een veiligheidsnet eronder. Application-level scoping (Pundit, current_account.invoices) is je primaire verdediging en is waar je de regels schrijft. RLS vangt het geval op waarin een ontwikkelaar vergeet een query te scopen. Draai allebei. RLS zonder application scoping maakt elke query iets langzamer en geeft je geen goede foutmeldingen; application scoping zonder RLS geeft je één bug verwijderd van een tenant-datalek.

Wanneer stopt Rails multi-tenancy met schalen?

Row-level tenancy op Postgres schaalt zonder problemen naar miljarden rijen en duizenden tenants als je tenant_id netjes indexeert en je grootste tabellen partitioneert. Het punt waarop row-level breekt is niet “we raakten een bepaalde rijaantal” — het is “een van onze tenants heeft nu compliance-eisen waaraan het gedeelde cluster niet kan voldoen.” Plan voor het hybride patroon als toekomstige staat, maar bouw het niet vooruit. Migreer wanneer een echte klant erom vraagt.

Hulp nodig bij het ontwerpen of migreren van een multi-tenant Rails-architectuur voor je SaaS? TTB Software is gespecialiseerd in Rails-architectuur, databaseontwerp en de operationele volwassenheid die nodig is om enterprise-klanten te ondersteunen. We doen dit al negentien jaar.

#rails-multi-tenancy #multi-tenant-saas-rails #postgres-schemas-multi-tenant #rails-row-level-tenancy #saas-database-architecture #apartment-gem-alternative #ruby-on-rails

Related Articles

Laatste sectie. Bel dan alsjeblieft.

Het is een telefoongesprek. Erger dan dat kan het niet worden.

Geen discovery-deck. Geen 45-minuten "kwalificatiegesprek." 30 minuten, jouw probleem, mijn mening. Als we een fit zijn weet je dat in minuut 12.

Directe lijn — Roger neemt zelf op
+31 6 5123 6132
Ma–vr, 09:00–18:00 CET · Nu beschikbaar

OF
info@ttb.software