Rails 8 Meerdere Databases: Read Replicas, Sharding en Automatische Connectie-switching
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:
- Als het request een
GETofHEADis, check ofdelayverstreken is sinds de laatste write - Zo ja → route naar reading replica
- Zo nee → route naar primary (writing role)
- 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.
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 TouchRelated Articles
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