35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails 8 Meerdere Databases: Read Replicas, Sharding en Automatische Connectie-switching

Rails 8 Meerdere Databases: Read Replicas, Sharding en Automatische Connectie-switching

Roger Heykoop
Ruby on Rails
Hoe je meerdere databases configureert in Rails 8 met read replicas, horizontale sharding en automatische role switching. Inclusief productie-geteste database.yml configuraties, migratie-strategieën en performance benchmarks.

Rails ondersteunt meerdere databases sinds versie 6.0, maar Rails 8 heeft de implementatie zodanig verbeterd dat het daadwerkelijk werkt zonder tegen het framework te vechten. Hier lees je hoe je read replicas, horizontale sharding en automatische connectie-switching instelt — met de configuratiedetails die de Rails-handleidingen overslaan.

De database.yml-configuratie die echt werkt

De meeste tutorials tonen de minimale configuratie. Zo ziet een productie database.yml eruit met een primaire database en een read replica:

# config/database.yml
production:
  primary:
    database: myapp_production
    host: primary-db.internal
    username: <%= ENV["DB_PRIMARY_USER"] %>
    password: <%= ENV["DB_PRIMARY_PASSWORD"] %>
    adapter: postgresql
    pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
    prepared_statements: true

  primary_replica:
    database: myapp_production
    host: replica-db.internal
    username: <%= ENV["DB_REPLICA_USER"] %>
    password: <%= ENV["DB_REPLICA_PASSWORD"] %>
    adapter: postgresql
    pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
    replica: true
    prepared_statements: true

De regel replica: true vertelt Rails dat deze connectie read-only is. Vergeet je die, dan probeert Rails naar je replica te schrijven — met een PG::ReadOnlySqlTransaction-fout in productie op het slechtst denkbare moment.

Models koppelen aan specifieke databases

Je ApplicationRecord moet de replica kennen:

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: {
    writing: :primary,
    reading: :primary_replica
  }
end

Elk model dat van ApplicationRecord erft heeft nu twee connection pools: één voor writes, één voor reads. Rails regelt de juiste connectie op basis van de huidige role.

Automatische role switching

Rails 8 bevat ingebouwde middleware voor automatische read/write-switching:

# config/application.rb
config.active_record.database_selector = { delay: 2.seconds }
config.active_record.database_resolver =
  ActiveRecord::Middleware::DatabaseSelector::Resolver
config.active_record.database_resolver_context =
  ActiveRecord::Middleware::DatabaseSelector::Resolver::Session

De parameter delay: 2.seconds is bescherming tegen replicatie-vertraging. Na een write stuurt Rails alle reads gedurende 2 seconden naar de primary, zodat de replica tijd heeft om bij te werken. In de praktijk is 2 seconden ruim voor de meeste PostgreSQL streaming replicatie-setups — we maten p99 replicatie-lag van 50ms op RDS, maar hielden de buffer van 2 seconden aan omdat verouderde data na een write kostbaarder is dan een paar extra primary reads.

Hoe de switching-logica werkt

De middleware controleert de sessie-timestamp van de laatste write. Per request:

  1. Als het request een GET of HEAD is, check of delay verstreken is sinds de laatste write
  2. Zo ja → route naar reading replica
  3. Zo nee → route naar primary (writing role)
  4. Elk POST/PUT/PATCH/DELETE → altijd naar primary

Je kunt ook handmatig switchen wanneer je expliciete controle nodig hebt:

# Forceer een read van primary (bijv. na een write in een API-flow)
ActiveRecord::Base.connected_to(role: :writing) do
  @user = User.find(params[:id])
end

# Forceer een read van replica (bijv. analytics queries)
ActiveRecord::Base.connected_to(role: :reading) do
  @stats = Order.where(created_at: 30.days.ago..).group(:status).count
end

Horizontale sharding

Read replicas schalen reads op. Horizontale sharding pakt datavolume aan. Rails 8’s sharding-ondersteuning laat je data verdelen over meerdere databases op basis van een shard key:

# config/database.yml
production:
  primary_shard_one:
    database: myapp_shard_1
    host: shard1-db.internal
    adapter: postgresql

  primary_shard_two:
    database: myapp_shard_2
    host: shard2-db.internal
    adapter: postgresql
# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to shards: {
    shard_one: { writing: :primary_shard_one },
    shard_two: { writing: :primary_shard_two }
  }
end

Wisselen tussen shards:

ActiveRecord::Base.connected_to(shard: :shard_one) do
  User.find(123) # query op shard_one
end

Het lastige deel is routing. Rails bepaalt niet welke shard bevraagd moet worden — dat is jouw verantwoordelijkheid. Een veelgebruikt patroon is middleware die de shard bepaalt op basis van het request:

# app/middleware/shard_selector.rb
class ShardSelector
  def initialize(app)
    @app = app
  end

  def call(env)
    request = ActionDispatch::Request.new(env)
    shard = determine_shard(request)

    ActiveRecord::Base.connected_to(shard: shard) do
      @app.call(env)
    end
  end

  private

  def determine_shard(request)
    # Route op basis van tenant-subdomein, user ID hash, regio, etc.
    tenant = request.subdomain
    tenant_shard_mapping[tenant] || :shard_one
  end
end

Migraties met meerdere databases

Elke database heeft zijn eigen migratie-directory nodig. Genereer migraties voor een specifieke database:

bin/rails generate migration CreateAnalyticsEvents --database=analytics

Dit maakt de migratie aan in db/analytics_migrate/ in plaats van de standaard db/migrate/. Voer migraties per database uit:

bin/rails db:migrate              # migreert alleen primary
bin/rails db:migrate:analytics    # migreert analytics database
bin/rails db:migrate:all          # migreert alles

Een valkuil: db:migrate:all werd pas toegevoegd in Rails 7.1. Als je upgradet van een oudere versie, heb je mogelijk een deploy-script dat alleen db:migrate uitvoert en je secundaire databases stilletjes overslaat. Ik heb dit een incident van 2 uur zien veroorzaken waarbij de analytics-database een kolom miste die de applicatie verwachtte — de migratie was weken eerder gecommit maar nooit in productie uitgevoerd.

Aparte databases voor verschillende workloads

Naast replicas vermindert het splitsen op domein de contentie. Een veelgebruikt patroon:

production:
  primary:
    database: myapp_production
    host: primary-db.internal
    adapter: postgresql

  analytics:
    database: myapp_analytics
    host: analytics-db.internal
    adapter: postgresql
    migrations_paths: db/analytics_migrate
# app/models/analytics_record.rb
class AnalyticsRecord < ActiveRecord::Base
  self.abstract_class = true

  connects_to database: {
    writing: :analytics,
    reading: :analytics
  }
end

# app/models/page_view.rb
class PageView < AnalyticsRecord
  # Dit model leest en schrijft naar de analytics-database
end

Dit voorkomt dat zware analytics queries concurreren met je transactionele workload. Op een project waar we analytics naar een aparte database verplaatsten, daalde de gemiddelde API-responstijd met 15% doordat de primaire connection pool niet langer verzadigd werd door langlopende aggregate queries.

Connection pool sizing

Met meerdere databases krijgt elke database zijn eigen connection pool. Een Rails-proces met 5 Puma-threads dat verbindt met een primary en een replica onderhoudt 10 connecties — 5 per pool. Met 4 Puma workers zijn dat 40 databaseconnecties vanaf één server.

Bereken je totale connecties:

totaal_connecties = puma_workers × threads_per_worker × aantal_databases

Voor een setup met 4 workers, 5 threads, primary + replica + analytics:

4 × 5 × 3 = 60 connecties per applicatieserver

PostgreSQL’s standaard max_connections is 100. Met twee applicatieservers zit je al op 120 — over de limiet. Verhoog max_connections (let op je geheugen), gebruik PgBouncer voor connection pooling, of verlaag je pool-grootte per database.

De pool-instelling in database.yml moet overeenkomen met je thread-aantal, niet hoger. pool: 20 instellen met 5 threads verspilt connectie-slots die andere processen kunnen gebruiken.

Testen met meerdere databases

Je testconfiguratie moet de productiestructuur spiegelen:

test:
  primary:
    database: myapp_test
    adapter: postgresql

  primary_replica:
    database: myapp_test  # zelfde DB in test — geen echte replica nodig
    adapter: postgresql
    replica: true

  analytics:
    database: myapp_analytics_test
    adapter: postgresql
    migrations_paths: db/analytics_migrate

Voor de replica in test verwijs je naar dezelfde database. Je test niet de replicatie — je test of je applicatie queries correct via de juiste connectie routeert.

Voer bin/rails db:test:prepare uit om alle testdatabases op te zetten. Als je database_cleaner gebruikt, configureer het voor elke connectie:

# spec/support/database_cleaner.rb
RSpec.configure do |config|
  config.before(:suite) do
    DatabaseCleaner[:active_record, { connection: :primary }]
      .strategy = :transaction
    DatabaseCleaner[:active_record, { connection: :analytics }]
      .strategy = :transaction
  end
end

Wanneer je daadwerkelijk meerdere databases nodig hebt

Niet elke applicatie profiteert van deze complexiteit. Dit is wanneer het loont:

Read replicas zijn zinvol wanneer je read-to-write ratio hoger is dan 10:1 en je primary CPU-gebonden is op queries. Als je bottleneck write-throughput is of je reads snel zijn, voegt een replica operationele complexiteit toe zonder noemenswaardige winst.

Horizontale sharding is zinvol wanneer één PostgreSQL-instantie je dataset niet meer kan bevatten of je write-throughput niet aankan. Voor de meeste Rails-applicaties is verticaal schalen (grotere instantie) goedkoper en simpeler totdat je in de tientallen-terabytes-range komt.

Aparte databases per workload zijn zinvol wanneer verschillende workloads om resources concurreren. Analytics queries naast transactionele queries is het klassieke geval, maar overweeg ook het scheiden van background job-opslag of audit logs.

Controleer voordat je databases toevoegt of betere indexering of caching het probleem eerst oplost. Meerdere databases zijn een schaaltool, geen performance-fix.

FAQ

Hoe gaat Rails om met transacties over meerdere databases?

Rails ondersteunt geen distributed transacties. Een transaction-blok geldt alleen voor de huidige connectie. Als je atomiciteit over twee databases nodig hebt, heb je compensatielogica op applicatieniveau nodig (saga pattern) of accepteer je eventual consistency. Het service object-patroon werkt goed voor het coördineren van multi-database writes.

Kan ik verschillende database-engines gebruiken voor verschillende connecties?

Ja. Je kunt PostgreSQL draaien voor je primary en MySQL of SQLite voor een andere database in dezelfde Rails-applicatie. Elke entry in database.yml specificeert zijn eigen adapter. Dit is handig bij integratie met legacy-systemen die op een andere engine draaien.

Wat gebeurt er als de read replica uitvalt?

Standaard geeft Rails een connectiefout. Er is geen automatische fallback naar de primary. Je hebt een custom resolver of een proxy zoals PgBouncer/HAProxy nodig voor failover. In productie draai ik replicas achter een load balancer die de replica health-checkt en naar de primary routeert als de replica onbereikbaar is.

Werkt connected_to binnen background jobs?

Ja, maar de connectie-context wordt niet overgedragen vanuit de enqueueing-code. Als je een job enqueued binnen een connected_to(shard: :shard_two)-blok, draait de job zelf met de standaardconnectie. Geef de shard key mee als job-argument en switch expliciet binnen perform.

Hoe monitor ik connection pool-gebruik over meerdere databases?

Rails biedt pool-statistieken via ActiveRecord::Base.connection_pool.stat. Voor meerdere databases bevraag je elke pool individueel. De OpenTelemetry-integratie kan pool-metrics (grootte, connecties in gebruik, wachtende threads) per database exporteren naar je monitoringsysteem.

#rails 8 #databases #postgresql #performance #scaling
R

About the Author

Roger Heykoop is een senior Ruby on Rails ontwikkelaar met 19+ jaar Rails ervaring en 35+ jaar ervaring in softwareontwikkeling. Hij is gespecialiseerd in Rails modernisering, performance optimalisatie, en AI-ondersteunde ontwikkeling.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation