RUBY ON RAILS · 18 MIN READ ·

Rails Materialized Views met Scenic: Snelle Postgres Read Models Zonder Writes Om Zeep Te Helpen

Rails materialized views met de Scenic gem: bouw snelle read models, versioneer SQL in migraties en refresh concurrent zonder productieschrijfacties te blokkeren.

Rails Materialized Views met Scenic: Snelle Postgres Read Models Zonder Writes Om Zeep Te Helpen

Op een goede dag laadde het dashboard in elf seconden. Op een slechte dag lag de p95 dichter bij dertig. Het was een SaaS analytics-scherm voor een marketplace, en het aggregeerde acht tabellen — orders, order items, verkopers, kopers, disputes, refunds, uitbetalingen en een categorieboom — tot een enkel “seller health” grid. Het team had al elke index toegevoegd die Postgres wilde accepteren, twee counters gedenormaliseerd naar de sellers-tabel en Redis erbij gehaald. Op dat punt belde de CTO en stelde de vraag die ik minstens één keer per kwartaal hoor: “Moeten we sharden of moeten we dit naar een read replica verplaatsen?” Het antwoord die middag was geen van beide. Het antwoord was Rails materialized views met de Scenic gem, een refresh iedere paar minuten en ongeveer negentig minuten werk.

Na negentien jaar Rails heb ik teams zien grijpen naar Elasticsearch, ClickHouse of een data warehouse terwijl het echte probleem is dat een handvol dure read queries bij elke pageload draait tegen tabellen die geoptimaliseerd zijn voor writes. Rails materialized views zijn het saaie antwoord dat blijft werken. Postgres berekent het aggregaat één keer, slaat het op schijf op als een tabel, en laat je er een index op zetten alsof het er ook echt één is. De Scenic gem geeft Rails een eersteklas manier om die views te versioneren in migraties. Deze post is de speeldraaiboek dat ik teams meegeef zodra een reporting-query de outage wordt die ze niet gepland hadden.

Wat een Materialized View Eigenlijk Is

Een gewone Postgres view is een opgeslagen query. Elke keer dat je er een SELECT op doet, draait Postgres de onderliggende SQL. Prima voor simpele joins en verschrikkelijk voor alles wat een miljoen rijen aggregeert.

Een materialized view is een opgeslagen query waarvan het resultaat naar disk wordt geschreven als een fysieke tabel. Reads gaan direct naar die tabel — geen joins, geen aggregaties, geen sortering tijdens de query. De prijs is dat de data verouderd is totdat je expliciet REFRESH MATERIALIZED VIEW draait. In de praktijk zijn de meeste reporting workloads prima tevreden met data die dertig seconden, vijf minuten of een uur oud is. Je leadership team dat kijkt naar “verkopers met de meeste disputes deze week” heeft geen real-time waarheid nodig. Het heeft een pagina nodig die in 80 milliseconden laadt.

Het juiste mentale model: een materialized view is een read model dat is opgebouwd uit je write-side tabellen. Je houdt de bron van waarheid genormaliseerd, je houdt de reporting-vorm gedenormaliseerd, en je bepaalt de refresh-frequentie per view.

Waarom Niet Gewoon De Query Cachen In Rails

Die vraag krijg ik elke keer. “We kunnen het toch inpakken in Rails.cache.fetch?” Dat kan, en voor veel losse endpoints is dat de juiste keuze. Maar een Rails cache heeft drie failure modes die een materialized view niet heeft.

Ten eerste invalidatie. Zodra twee schrijvers dezelfde sleutel kunnen invalideren, zit je weer met een “waarom is dit stale voor de ene gebruiker en fresh voor de andere” bug ticket. Ten tweede draait de query nog steeds bij een koude cache — direct na een deploy, om 03:00 als Redis evict, op Black Friday als een klant twee keer refresht. Ten derde kun je geen index op een cache zetten. Je kunt niet zeggen “sorteer seller health op dispute rate, filter op categorie, pagineer.” Een materialized view is een echte tabel. Je kunt CREATE INDEX seller_health_dispute_rate_idx ON seller_health (dispute_rate DESC) toevoegen en elke filter/sort combinatie is snel.

Postgres geeft je bovendien REFRESH MATERIALIZED VIEW CONCURRENTLY, waarmee lezers de oude kopie kunnen blijven raadplegen terwijl de nieuwe wordt opgebouwd. Redis heeft daar geen equivalent van. Meer over Rails-native cache-opties staat in Rails caching beyond basics.

Scenic Installeren en Je Eerste View Schrijven

Scenic is de saaie, goed onderhouden gem om Postgres views te beheren binnen Rails migraties. Voeg hem toe en genereer je eerste view:

# Gemfile
gem "scenic", "~> 1.8"
bundle install
bin/rails generate scenic:model seller_health

Scenic maakt drie dingen: een migratie die CREATE VIEW seller_health gaat draaien, een model file (app/models/seller_health.rb) die een read-only ActiveRecord class is, en een SQL-file op db/views/seller_health_v01.sql waar je de echte query schrijft.

Hier is de SQL voor het dashboard dat we vervangen hebben:

-- db/views/seller_health_v01.sql
SELECT
  s.id                                                AS seller_id,
  s.tenant_id                                         AS tenant_id,
  s.name                                              AS seller_name,
  COUNT(DISTINCT o.id)                                AS orders_30d,
  COALESCE(SUM(oi.total_cents), 0)                    AS gmv_cents_30d,
  COUNT(DISTINCT d.id)                                AS disputes_30d,
  CASE
    WHEN COUNT(DISTINCT o.id) = 0 THEN 0
    ELSE (COUNT(DISTINCT d.id)::float / COUNT(DISTINCT o.id)::float)
  END                                                 AS dispute_rate_30d,
  MAX(o.created_at)                                   AS last_order_at
FROM sellers s
LEFT JOIN orders o
  ON o.seller_id = s.id
 AND o.created_at >= NOW() - INTERVAL '30 days'
LEFT JOIN order_items oi
  ON oi.order_id = o.id
LEFT JOIN disputes d
  ON d.order_id = o.id
 AND d.created_at >= NOW() - INTERVAL '30 days'
GROUP BY s.id, s.tenant_id, s.name;

Maak er nu een materialized view van. Scenic ondersteunt dat met één optie:

# db/migrate/20260705120000_create_seller_health.rb
class CreateSellerHealth < ActiveRecord::Migration[8.0]
  def change
    create_view :seller_health, materialized: true
  end
end

Draai de migratie en Postgres bouwt de view één keer op vanuit de SQL-file. Nu geeft SellerHealth.count direct antwoord, want het bevraagt een fysieke tabel.

De Modellaag Die Dit Als Native Rails Laat Aanvoelen

De generator van Scenic geeft je een read-only ActiveRecord model. Koppel het aan je write-side model zodat de code natuurlijk leest:

# app/models/seller_health.rb
class SellerHealth < ApplicationRecord
  self.primary_key = :seller_id

  belongs_to :seller
  belongs_to :tenant

  scope :top_gmv,       -> { order(gmv_cents_30d: :desc) }
  scope :high_disputes, -> { where("dispute_rate_30d > ?", 0.05) }

  def readonly?
    true
  end
end

# app/models/seller.rb
class Seller < ApplicationRecord
  has_one :health, class_name: "SellerHealth", foreign_key: :seller_id
end

Twee dingen om te markeren. Ten eerste self.primary_key = :seller_id. Postgres materialized views hebben geen automatisch gegenereerde id-kolom tenzij je die zelf schrijft, en Rails wordt daar gek van. Kies een stabiele identifier uit je brondata als primaire sleutel. Ten tweede: overschrijf readonly?. Scenic waarschuwt als je het vergeet, maar ik heb een junior developer twintig minuten naar de errormelding zien staren nadat hij SellerHealth.first.update!(...) in de console had aangeroepen. Wees expliciet.

Nu leest de controller zoals je altijd al wilde:

# app/controllers/admin/sellers_controller.rb
class Admin::SellersController < AdminController
  def index
    @sellers = SellerHealth
      .where(tenant_id: Current.tenant.id)
      .high_disputes
      .top_gmv
      .page(params[:page])
  end
end

Het dashboard van elf seconden is nu tachtig milliseconden. Niet omdat ik de query optimaliseerde, maar omdat ik ermee stopte de query bij elke request te draaien.

Refreshen Zonder Writes Te Blokkeren

Dit is het stukje dat een werkend prototype scheidt van iets wat je echt in productie zet. REFRESH MATERIALIZED VIEW seller_health neemt een exclusive lock. Reads en writes op de view blokkeren totdat hij klaar is. Op een bron van 40 miljoen rijen zijn dat minuten downtime.

REFRESH MATERIALIZED VIEW CONCURRENTLY seller_health bouwt de nieuwe versie in een tijdelijke ruimte op en swapt hem atomisch in. Reads gaan de hele tijd door tegen de oude versie. Er is één randvoorwaarde: de view moet minimaal één unique index hebben. Voeg die toe via Scenic’s add_index in een vervolgmigratie:

# db/migrate/20260705120100_add_indexes_to_seller_health.rb
class AddIndexesToSellerHealth < ActiveRecord::Migration[8.0]
  disable_ddl_transaction!

  def change
    add_index :seller_health, :seller_id,
              unique: true, algorithm: :concurrently
    add_index :seller_health, [:tenant_id, :dispute_rate_30d],
              algorithm: :concurrently
    add_index :seller_health, [:tenant_id, :gmv_cents_30d],
              algorithm: :concurrently
  end
end

De refresh-job is nu saai:

# app/jobs/refresh_seller_health_job.rb
class RefreshSellerHealthJob < ApplicationJob
  queue_as :low

  def perform
    Scenic.database.refresh_materialized_view(
      :seller_health,
      concurrently: true,
      cascade: false
    )
  end
end

Plan hem in met Solid Queue’s recurring jobs (zie Solid Queue recurring jobs) of gewoon cron:

# config/recurring.yml
production:
  refresh_seller_health:
    class: RefreshSellerHealthJob
    schedule: every 5 minutes

Vijf minuten is een redelijke default voor de meeste dashboards. Voor nachtelijke reporting views is één keer per nacht op een rustig uur prima. Voor een “leaderboard” waarvan men verwacht dat het live aanvoelt, is dertig seconden haalbaar — maar dan zou je jezelf moeten afvragen of een event-gedreven gedenormaliseerde counter beter past dan een view.

Als Strong Migrations op je project actief is, flagt hij elke non-concurrent refresh of ontbrekende unique index. Het loont om dit te hebben — ik schreef erover in Rails Strong Migrations.

Views Versioneren Zoals Migraties

De reden om Scenic te gebruiken in plaats van rauwe SQL te schrijven is dat views veranderen, en verandering is waar teams productie breken. Stel dat het productteam een refunds_30d kolom wil toevoegen. Bewerk db/views/seller_health_v01.sql niet. Genereer een nieuwe versie:

bin/rails generate scenic:view seller_health

Scenic kopieert de vorige SQL naar db/views/seller_health_v02.sql en genereert een migratie:

class UpdateSellerHealthToVersion2 < ActiveRecord::Migration[8.0]
  def change
    update_view :seller_health,
                version: 2,
                revert_to_version: 1,
                materialized: { no_data: false }
  end
end

Bewerk v02.sql, voeg je nieuwe kolom toe en deploy. De migratie vervangt de view atomisch en kan worden teruggerold naar v01. Je db/views/ directory wordt een geversioneerde historie van elke schema-beslissing die de reporting-laag heeft genomen. Als een data-engineer vraagt “wanneer zijn we dispute rate gaan tracken”, beantwoordt git log db/views/ dat in één commando.

Wanneer Materialized Views Het Verkeerde Antwoord Zijn

Ik heb dit patroon geshipt bij pakweg twintig bedrijven. Er zijn drie gevallen waarin ik het weiger te doen.

Het eerste is data die strikt consistent moet zijn met de eigen write van een gebruiker. Als een gebruiker een bestelling plaatst en verwacht die op het volgende scherm te zien, lees dan niet uit een view die iedere vijf minuten refresht. Lees uit de brontabel voor de recente activiteit van die eigen gebruiker, en gebruik de view voor de aggregaat-context. Je kunt in één endpoint zelfs beide bevragen.

Het tweede is een view waarvan een volledige refresh langer duurt dan je acceptabele staleness. Als de query achter de view negentig seconden duurt en je wil vijf minuten freshness, ga je continu een deel van een Postgres-core verbranden. Op dat punt is een incrementeel read model gebouwd met triggers, of een echte event-sourced projection (zie Rails event sourcing) de juiste keuze.

Het derde is data die werkelijk per gebruiker en onbegrensd is — 500k gebruikers met elk hun eigen dashboard-slice. Die view materialiseren betekent 500k slices materialiseren. Het juiste antwoord daar is meestal een real-time query met de juiste indexes, of een partitioned tabel per tenant. Ik ga in op partitioning in Rails Postgres table partitioning.

Overal elders — leaderboards, admin dashboards, cohort reports, seller/buyer health, revenue rollups, moderation queues — is een materialized view die concurrent op een cadence refresht het antwoord dat je zou moeten proberen voordat je het woord “Kafka” typt.

Monitoren Dat De Refresh Ook Écht Draait

De failure mode die niemand ziet aankomen is de refresh-job die stilletjes stopt met draaien. Je dashboard laat data zien. Alleen is dat een week oud. Twee dingen die ik in elke Rails-app zet die materialized views gebruikt.

Ten eerste: exposeer de laatste refresh-timestamp op de view zelf. Voeg een refreshed_at kolom toe via een kleine truc — join naar een metadata-tabel of wrap de refresh in een job die ook naar een matview_refreshes-tabel schrijft:

class RefreshSellerHealthJob < ApplicationJob
  queue_as :low

  def perform
    Scenic.database.refresh_materialized_view(:seller_health, concurrently: true)
    MatviewRefresh.create!(name: "seller_health", refreshed_at: Time.current)
  end
end

Een controller filter of kleine footer-partial leest dan MatviewRefresh.where(name: "seller_health").last.refreshed_at en rendert “data van 3 minuten geleden.” Gebruikers vertrouwen een stale-maar-gelabeld dashboard. Ze verliezen het vertrouwen in een dashboard dat liegt dat het live is.

Ten tweede: alerten op staleness. Zoiets als:

# In een periodieke health check job
class MatviewFreshnessCheckJob < ApplicationJob
  STALENESS = { "seller_health" => 15.minutes, "revenue_rollup" => 2.hours }

  def perform
    STALENESS.each do |name, threshold|
      last = MatviewRefresh.where(name: name).order(:refreshed_at).last
      if last.nil? || last.refreshed_at < threshold.ago
        Sentry.capture_message("Matview #{name} is stale", level: :error)
      end
    end
  end
end

Koppel dit aan je error tracker (die setup beschreef ik in Rails Sentry error tracking) en de failure mode “de refresh-job stierf in juli in stilte” kan niet onopgemerkt blijven.

De Prijs Die Je Betaalt En Waarom Hij Het Waard Is

Materialized views zijn niet gratis. Elke view is een kopie van data, dus je schijfgebruik groeit. De refresh verbrandt CPU evenredig met de kostprijs van de bronquery. Je voegt een job aan de queue toe die ook echt moet draaien, en één ding erbij dat verkeerd geconfigureerd kan raken.

De prijs die je bespaart is groter en komt in drie vormen. Response time zakt van seconden naar milliseconden voor dure read-paden. Database load stopt met pieken elke keer als het marketingteam de admin opent. En het belangrijkste: je ontkoppelt write-side schema-beslissingen van read-side product-beslissingen. Wil product volgend kwartaal een nieuw rapport? Je voegt een view toe. Je zet geen indexes op een write-heavy tabel om een rapport sneller te maken en vraagt je dan niet af waarom het write-pad trager werd.

Voor de meeste Rails-apps tussen één en vijftig miljoen rijen in de hot tables is dit de meest kostenefficiënte performance-investering die je kunt doen. Hij koopt je een jaar of twee voordat je serieus moet nadenken over read replicas, sharden of een warehouse. En als je er wél overheen groeit, worden de views het natuurlijke contract voor wat je in de opvolger moet laden.

FAQ

Hoe vaak moet ik een Rails materialized view refreshen?

Stem de refresh-frequentie af op de freshness die het product écht vraagt. Executive dashboards zijn prima op uurlijks. Ops dashboards zijn meestal blij op vijf minuten. Leaderboards die zichtbaar zijn voor eindgebruikers willen doorgaans dertig tot zestig seconden. Nachtelijke reporting-views mogen één keer per nacht op een rustig uur refreshen. Render altijd een “data van X geleden”-indicator zodat gebruikers weten waar ze naar kijken.

Kan ik een materialized view bevragen met ActiveRecord alsof het een normaal model is?

Ja. Scenic genereert een normaal ActiveRecord model dat op de view rust. Je kunt where, order, joins aanroepen, scopes gebruiken en paginate precies zoals bij elke tabel. Zet self.primary_key expliciet en overschrijf readonly? naar true zodat niets probeert erin te schrijven. Je kunt de view zelfs joinen aan je normale write-side modellen — voor Postgres is de view gewoon een tabel.

Wat is het verschil tussen een Postgres view en een materialized view in Rails?

Een gewone Postgres view is een opgeslagen query die bij elke SELECT opnieuw draait. Een materialized view slaat het queryresultaat op schijf op als een fysieke tabel en draait pas opnieuw als je een REFRESH doet. Reads uit een materialized view zijn snel en te indexeren, tegen de prijs van staleness. Gebruik een gewone view voor lichte abstracties over één of twee tabellen, en een materialized view als de onderliggende query duur is om steeds opnieuw te draaien.

Vereist REFRESH MATERIALIZED VIEW CONCURRENTLY een unique index?

Ja. Postgres gebruikt de unique index om oude en nieuwe rijen te matchen tijdens de concurrent refresh. Zonder die index gooit het CONCURRENTLY keyword een error. Voeg een unique index toe op de natuurlijke primaire sleutel van de view — meestal het id van het bronmodel — via add_index :view_name, :id, unique: true, algorithm: :concurrently in een Scenic-migratie. Dit is de meest gemaakte Scenic setup-fout die ik zie.

Hulp nodig om een trage Rails reporting-laag om te zetten in een saaie, snelle, goed gemonitorde? TTB Software is gespecialiseerd in Rails performance, Postgres-tuning en de read-model patronen die echt schalen. Wij doen dit al negentien jaar.

#rails-materialized-views #scenic-gem-rails #postgres-materialized-views #rails-read-models #rails-scenic #rails-postgres-performance #rails-reporting-queries

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