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 Read Replicas: Multiple Databases Setup met Automatische Connection Switching

Rails Read Replicas: Multiple Databases Setup met Automatische Connection Switching

Roger Heykoop
Ruby on Rails, DevOps
Rails read replicas met multiple databases: volledige database.yml setup, automatische connection switching, replica lag afhandeling en productiepatronen binnenin.

Een founder waar ik als fractional CTO mee werk belde mij op een dinsdagochtend omdat zijn Rails 8 SaaS al veertig minuten 502’s gooide. De Postgres primary zat op 96% CPU, de queue liep vol en Sidekiq workers timeden uit op connection checkout. Read traffic was de database aan het opvreten — één enkel dashboard endpoint dat vier zware aggregaties draaide bij elke pageload, aangeroepen vanuit een Slack-kanaal met tweehonderd mensen erin. We hadden geen grotere primary nodig. We hadden Rails read replicas nodig, en wel voor de lunch.

Na negentien jaar Rails heb ik dit dansje vaker gedaan dan ik wil tellen. Het goede nieuws is dat Rails read replicas geen third-party probleem meer zijn. Sinds Rails 6.1 en zeker sinds de multiple-databases verbeteringen in Rails 7 en 8 heeft het framework alles wat je nodig hebt: database.yml configuratie, automatische role-switching middleware, handmatige connected_to blokken, en blokkering van writes naar replicas. Dit is het productieboekje dat ik elke keer gebruik, inclusief de scherpe randjes die de Rails guides niet noemen.

Wat Rails Read Replicas Echt Oplossen

Rails read replicas zijn geen magisch schalingspoeder. Ze lossen exact één probleem op: de primary database doet te veel SELECTs. Als je bottleneck writes zijn, lock contention, of een ontbrekende index, dan helpen replicas niet — je krijgt hetzelfde probleem op een andere server, met de bonus van replicatielag. Voordat je naar read replicas grijpt, draai pg_stat_statements en bevestig dat read traffic de dominante kost is. Zo niet, fix dan eerst de echte bottleneck.

Wanneer read traffic wél het probleem is, kopen Rails read replicas je drie dingen. Ze halen de zware SELECTs van de primary, waardoor er CPU vrijkomt voor writes en het latency-gevoelige read pad. Ze geven je horizontale ruimte — je kunt een tweede of derde replica toevoegen zonder applicatiecode aan te raken. En ze laten je luidruchtige workloads isoleren, zoals analytics dashboards of admin reports, op een replica die mag achterlopen zonder dat iemand het merkt.

De kost is conceptueel, niet infrastructureel. Zodra je replicas hebt, moet elke read in je codebase een nieuwe vraag beantwoorden: mag ik een paar seconden stale zijn? De meeste reads mogen dat. Diegene die dat niet mogen — “is de bestelling die ik net plaatste echt opgeslagen” — zijn de reads die je verbranden als je ze verkeerd routeert. De rest van deze post gaat over die routing goed krijgen.

Multiple Databases Opzetten in database.yml

De Rails read replicas configuratie leeft volledig in config/database.yml. Je definieert twee connecties — een writing connectie naar de primary en een reading connectie naar de replica — en vertelt Active Record dat ze hetzelfde schema delen. Postgres regelt streaming replication buiten Rails om; managed services zoals RDS, Aurora en Crunchy geven je een replica endpoint als hostname, en dat is het enige wat Rails hoeft te weten.

# config/database.yml
default: &default
  adapter: postgresql
  encoding: unicode
  pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %>
  variables:
    statement_timeout: 5000

production:
  primary:
    <<: *default
    database: app_production
    username: app
    password: <%= ENV["DATABASE_PASSWORD"] %>
    host: <%= ENV["DATABASE_HOST"] %>
  primary_replica:
    <<: *default
    database: app_production
    username: app_readonly
    password: <%= ENV["REPLICA_PASSWORD"] %>
    host: <%= ENV["REPLICA_HOST"] %>
    replica: true

Twee dingen zijn belangrijk in het replica blok. De replica: true flag vertelt Rails om geen migraties tegen deze connectie te draaien — zonder die flag zal bin/rails db:migrate proberen DDL naar de replica te schrijven en op verwarrende manieren falen. De username moet een aparte Postgres rol zijn met alleen SELECT rechten. Hoewel Rails writes naar een replica connectie zal weigeren, is defensie in de diepte goedkoop: een read-only Postgres user betekent dat een buggy migratie of een rogue script niet per ongeluk via de replica connectie kan schrijven als iemand de rol verkeerd configureert.

Bedraad nu de modellaag. De meeste apps hebben één Active Record base class, en dat is waar de connects-to leeft:

# app/models/application_record.rb
class ApplicationRecord < ActiveRecord::Base
  primary_abstract_class

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

primary_abstract_class vertelt Rails dat dit de ouder is voor elk model, dus de connectieconfiguratie wordt overal automatisch geërfd. Hierna heeft elk model in de app zowel een writing als een reading rol beschikbaar — maar er gaat nog geen read traffic naar de replica. Daarvoor hebben we de switcher nodig.

Automatische Connection Switching met de Ingebouwde Middleware

Rails ships een middleware die schakelt tussen writing en reading rollen op basis van de HTTP-methode. GET en HEAD requests gebruiken de reading rol. POST, PATCH, PUT, DELETE gebruiken de writing rol. Als een request net naar de database heeft geschreven, wordt elke daaropvolgende read voor die sessie ook gerouteerd naar writing voor een korte periode — de resolver houdt de timestamp van de laatste write bij om te voorkomen dat een net-gemuteerde gebruiker terug naar een stale replica wordt gestuurd.

# 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

Dat is het. Drie regels, restart, en je GETs gaan nu naar de replica. De delay: 2.seconds is het post-write venster — zet het op wat je replicatielag comfortabel boven ligt, en instrumenteer het (meer daarover verderop). De session context slaat de last-write timestamp op in de sessie van de gebruiker, dus een gebruiker die net een formulier heeft ingediend blijft twee seconden de writer krijgen en drift dan naar de reader.

Deze default werkt voor de meeste CRUD apps. Hij werkt niet voor alles, en de gotchas tellen. De middleware schakelt alleen op basis van HTTP-methode, niet op basis van wat de controller daadwerkelijk doet. Als je een GET endpoint hebt dat een write triggert — een webhook handler, een OAuth callback die een user aanmaakt, een tracking pixel die een counter ophoogt — schrijf je naar de replica rol en raised Rails ActiveRecord::ReadOnlyError. Converteer die endpoints naar POST of gebruik een handmatig switching blok.

De middleware doet ook niets voor background jobs, runners of rake tasks. Alles buiten de request cycle valt terug op de writing rol. Als je een Sidekiq job wilt die een gigantische analytische query op de replica draait, moet je daar expliciet om vragen.

Handmatige Connection Switching voor Background Jobs

ActiveRecord::Base.connected_to is de handmatige schakelaar. Wikkel een blok erin en de queries binnenin worden gerouteerd naar de rol die je vraagt. Zo verplaats ik analytics, reporting en trage background work naar replicas zonder het request pad aan te raken:

class WeeklyReportJob < ApplicationJob
  queue_as :reports

  def perform(account_id)
    ActiveRecord::Base.connected_to(role: :reading) do
      account = Account.find(account_id)
      stats = account.orders
        .joins(:line_items)
        .where(created_at: 1.week.ago..)
        .group("date_trunc('day', orders.created_at)")
        .sum("line_items.amount_cents")

      ReportMailer.weekly(account, stats).deliver_later
    end
  end
end

Twee patronen waar ik op leun. Ten eerste, ik wikkel nooit de hele job in connected_to(:reading)deliver_later enqueued een andere job, en die enqueue is een write die naar de primary moet. Houd het read blok strak rond de daadwerkelijke SELECTs en laat de writes erbuiten vallen. Ten tweede, voor jobs die legitiem zeer verse data moeten lezen (een betalingsbevestiging, een permissiecheck), niet schakelen. Default naar de writer. De replica is voor dingen die lag kunnen verdragen.

Voor Sidekiq middleware-stijl handhaving registreer ik soms een per-queue rol:

# config/initializers/sidekiq.rb
Sidekiq.configure_server do |config|
  config.server_middleware do |chain|
    chain.add(ReplicaRouter)
  end
end

class ReplicaRouter
  REPLICA_QUEUES = %w[reports analytics exports].freeze

  def call(_worker, job, queue)
    if REPLICA_QUEUES.include?(queue)
      ActiveRecord::Base.connected_to(role: :reading) { yield }
    else
      yield
    end
  end
end

Nu draait elke job die op de reports queue staat standaard tegen de replica. De jobcode blijft schoon, de routing is configuratie. Dit gaat goed samen met Solid Queue’s queue prioritering — zware reads krijgen hun eigen pool, hun eigen queue en hun eigen connectie.

Replica Lag Afhandelen in Productie

Replica lag is de stille moordenaar van Rails read replicas. Postgres streaming replication is snel — meestal milliseconden — maar onder zware write-belasting, tijdens lang lopende transacties op de primary, of wanneer de replica zijn eigen vacuum werk doet, kan lag pieken tot seconden of minuten. Als je app stilletjes een kritieke read naar een gelagde replica routeert, krijg je bug reports die onmogelijk in development te reproduceren zijn.

Drie verdedigingen. Ten eerste, monitor lag expliciet. Postgres exposeert pg_last_wal_replay_lsn() en pg_stat_replication — krijg die in je dashboards en alert wanneer lag je delay venster overschrijdt. Ik alert meestal bij drie keer de geconfigureerde delay, dus een delay: 2.seconds config alert bij zes. Ten tweede, zet een hard plafond op delay in je database_selector config dat minstens het dubbele is van je p99 lag. Als je p99 lag 800ms is, zet delay: 2.seconds, niet 200ms. De default is te agressief voor de meeste productieworkloads.

Ten derde, schrijf reads die geen lag kunnen verdragen met expliciete role switches. Als een controller actie afhangt van data die de gebruiker net heeft geschreven — een bestelling bevestigen, het resultaat van een formulierinzending tonen, een net aangemaakte record retourneren — wikkel het:

class OrdersController < ApplicationController
  def show
    @order = ActiveRecord::Base.connected_to(role: :writing) do
      Order.find(params[:id])
    end
  end
end

Ja, dit is breedsprakig. Ja, het ondermijnt een deel van het punt van de middleware. Gebruik het chirurgisch — een of twee endpoints, diegene die daadwerkelijk breken onder lag. De post-write delay handelt het gewone geval af (een gebruiker die net een formulier heeft ingediend). De expliciete switch handelt het ongewone geval af (een bevestigingspagina die uit een andere sessie laadt, een admin die een net aangemaakte record bekijkt, een webhook die direct terugleest wat hij schreef).

Dit is ook waar Postgres advisory locks van pas komen voor cron-achtige jobs — als je job schrijft en dan leest, garandeert de lock serialisatie op de primary en vermijd je de lees-van-stale-replica val helemaal.

Veelvoorkomende Valkuilen met Rails Read Replicas

De eerste valkuil is N+1 queries. Als je een controller hebt die associaties lui laadt, is elke association load een aparte query, en de middleware zal ze allemaal naar de replica routeren. Dat is prima totdat een ervan eigenlijk een find_or_create_by is die zich vermomt als een read. Rails N+1 patronen en replica routing interageren slecht — fix de N+1s eerst, en gebruik agressief eager loading, voordat je replica routing aanzet.

De tweede valkuil is connection pool sizing. Elke rol krijgt zijn eigen pool. Als je pool: 5 hebt en je zet replicas aan, dan houdt je Puma worker nu tot tien Postgres connecties open — vijf naar de primary en vijf naar de replica. Met vier Puma workers per server en tien servers is dat 400 connecties naar elke Postgres host. Postgres default is 100. Je raakt connecties op. Verlaag de pool size, draai PgBouncer voor beide endpoints, of beide. Ik doe bijna altijd beide.

De derde valkuil is migraties. bin/rails db:migrate draait tegen elke niet-replica database in database.yml. Als je de replica: true flag bent vergeten, draait de migratie ook tegen de replica, wat een write is, wat de replica zal afwijzen, wat je achterlaat met half-gemigreerde state. Zet altijd replica: true. Test het door bewust een migratie in een staging-omgeving met een replica te draaien — als je een fout krijgt die je begrijpt, ben je correct bedraad.

De vierde valkuil is de test environment. De meeste teams configureren replicas alleen in productie. Dat is prima totdat een controller test het replica routing pad uitoefent en zich anders gedraagt in productie dan in CI. Ik configureer een single-database test environment dat de replica rol naar dezelfde database wijst als de writer. De middleware codepath draait in tests, maar er is geen daadwerkelijke replica, dus geen lag en geen verrassingen.

Replica Routing Monitoren en Verifiëren

Configuratie zonder verificatie is geloofsgebaseerde engineering. Nadat je Rails read replicas aanzet, moet je bevestigen dat queries daadwerkelijk gaan waar je denkt. Het goedkoopste signaal is ActiveSupport::Notifications — elke query event bevat de connectie, en je kunt logregels taggen met de rol:

# config/initializers/log_db_role.rb
ActiveSupport::Notifications.subscribe("sql.active_record") do |*args|
  event = ActiveSupport::Notifications::Event.new(*args)
  role = event.payload[:connection]&.pool&.db_config&.name
  Rails.logger.tagged("db=#{role}") { Rails.logger.debug(event.payload[:sql]) }
end

Beter dan logs: tag je APM. New Relic, Datadog en Skylight ondersteunen allemaal custom attributes — push de rol op elke span en je krijgt een per-endpoint breakdown van replica vs primary traffic. Na een week data vind je minstens één endpoint dat de verkeerde rol raakt. Er is altijd minstens één.

De ultieme verificatie is een productiequery tegen pg_stat_activity op elke host. De primary moet een mix van SELECT, INSERT, UPDATE, DELETE laten zien. De replica moet alleen SELECT laten zien (en het replication apply process). Als de replica iets anders laat zien, heb je ergens een verkeerd geconfigureerde connectie — meestal een rogue background job of een Rails console sessie die vergat in welke rol hij zat.

FAQ

Werken Rails read replicas met PgBouncer?

Ja, en je wilt het bijna zeker in productie. Draai een aparte PgBouncer pool voor elk endpoint — een voor de primary, een voor elke replica — in transaction pooling mode. De Rails connection pool zit voor PgBouncer, en PgBouncer multiplext duizenden Rails connecties op een klein aantal Postgres backends. Zonder PgBouncer raken zelfs bescheiden Rails fleets snel max_connections op wanneer je de per-worker pool verdubbelt door een replica toe te voegen.

Hoe route ik specifieke Active Record modellen naar een andere replica?

Override connects_to op een per-model abstracte class. Maak een AnalyticsRecord die overerft van ActiveRecord::Base, geef hem zijn eigen connects_to mapping naar een specifieke analytics replica, en laat je reportingmodellen overerven van AnalyticsRecord in plaats van ApplicationRecord. Elke abstracte class krijgt zijn eigen connection pool, en je kunt queries per model routeren in plaats van per HTTP-methode. Handig wanneer de analytics workload van één team nooit de primary of de user-facing replica mag raken.

Wat gebeurt er met transacties bij het gebruik van read replicas?

Transacties draaien altijd op de writing rol. ActiveRecord::Base.transaction wikkelt impliciet in connected_to(role: :writing), dus alles binnen een transactie blok raakt de primary, zelfs als het omringende request GET is. Dit is correct — je kunt geen multi-statement transactie hebben over twee fysieke databases — maar het verrast mensen. Als je writes naar de primary ziet op een request waarvan je verwachtte dat hij de replica zou raken, zoek dan een impliciete transactie (vaak van een callback of een with_lock aanroep).

Kan ik Rails read replicas gebruiken met Aurora, Crunchy of self-managed Postgres?

Alle drie. De Rails configuratie is identiek — je wijst de replica rol naar welke hostname je provider je geeft. Aurora’s reader endpoint load-balanced automatisch over alle replicas. Crunchy en self-managed setups geven je meestal per-replica hostnames, dus je lijst één replica per Rails connectie en laat DNS of een load balancer ervoor de failover regelen. De Rails kant geeft niet om welk model je gebruikt, zolang de host read-only Postgres connecties accepteert met logical of streaming replication erachter.


Een Rails app aan het schalen en niet zeker of read replicas, sharding of een grotere primary de juiste volgende stap is? TTB Software helpt founders de juiste schalingshendel kiezen en zonder downtime in productie te zetten. Negentien jaar Rails, talloze replica setups en de mening dat de meeste teams hun replicas een jaar te laat aanzetten.

#rails-read-replicas #rails-multiple-databases #activerecord-replica-switching #postgres-read-replicas-rails #rails-database-yml #rails-scaling-patterns #ruby-on-rails
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