Rails Database Indexing: Trage Queries Oplossen Met de Juiste Indexstrategie
Een ontbrekende database-index is de meest voorkomende oorzaak van trage Rails queries. Voeg de verkeerde index toe en je verspilt schijfruimte terwijl writes trager worden. Voeg de juiste toe en een query van 3 seconden daalt naar 2 milliseconden.
Deze gids behandelt hoe je het juiste indextype kiest voor je Rails queries, hoe je ontbrekende indexes in productie vindt, en wanneer je niet moet indexeren.
Hoe Rails Migrations Indexes Aanmaken
Wanneer je een migration genereert met add_index, maakt Rails standaard een B-tree index:
# db/migrate/20260226090000_add_index_to_orders.rb
class AddIndexToOrders < ActiveRecord::Migration[8.0]
def change
add_index :orders, :customer_id
end
end
Dit dekt het simpele geval: orders opzoeken op customer_id. Maar de meeste productie-queries zijn niet zo simpel.
Composite Indexes: Kolomvolgorde Is Bepalend
Wanneer een query op meerdere kolommen filtert, heb je een composite index nodig. De kolomvolgorde bepaalt welke queries de index kan bedienen.
add_index :orders, [:customer_id, :status, :created_at]
PostgreSQL gebruikt composite indexes van links naar rechts. Deze index helpt bij:
WHERE customer_id = ?— jaWHERE customer_id = ? AND status = ?— jaWHERE customer_id = ? AND status = ? AND created_at > ?— jaWHERE status = ?— nee (slaat de meest linkse kolom over)WHERE customer_id = ? AND created_at > ?— gedeeltelijk (gebruikt customer_id, scant voor created_at)
De regel: zet equality-condities eerst, range-condities laatst. Als je regelmatig WHERE status = ? AND created_at > ? zonder customer_id query’t, heb je een aparte index nodig.
Ik heb teams gezien die zes single-column indexes op één tabel maakten terwijl twee goed ontworpen composite indexes elk querypatroon zouden dekken. Elke overbodige index vertraagt INSERT en UPDATE operaties. Op een tabel die 10.000 writes per seconde verwerkt, telt die overhead op.
Partial Indexes: Indexeer Alleen Wat Je Queryt
Als 95% van je orders status: 'completed' heeft en je alleen actieve orders queryt:
add_index :orders, :customer_id, where: "status != 'completed'", name: 'idx_orders_active'
Deze partial index is een fractie van de grootte en sneller om te scannen. In een project verving ik een volledige index op een tabel met 40 miljoen rijen door een partial index — de indexgrootte ging van 860MB naar 12MB. Querytijd daalde omdat PostgreSQL minder data hoefde te doorlopen.
Rails scopes werken met partial indexes als de querycondities overeenkomen:
class Order < ApplicationRecord
scope :active, -> { where.not(status: 'completed') }
end
# Deze query gebruikt de partial index
Order.active.where(customer_id: 42)
Expression Indexes voor Berekende Lookups
Wanneer je op een berekende waarde zoekt, helpt een gewone index niet:
# Dit triggert een full table scan ondanks een index op 'email'
User.where("LOWER(email) = ?", email.downcase)
Maak een expression index:
add_index :users, 'LOWER(email)', name: 'idx_users_lower_email', unique: true
In Rails 8 met PostgreSQL kun je met expression indexes ook JSONB-paden indexeren:
add_index :events, "(metadata->>'event_type')", name: 'idx_events_type'
Dit maakt het queryen van JSONB-kolommen op schaal praktisch zonder een aparte kolom toe te voegen.
GIN Indexes voor Full-Text Search en Arrays
B-tree indexes werken niet voor array containment of full-text search. Gebruik GIN (Generalized Inverted Index):
# Voor array-kolommen
add_index :articles, :tags, using: :gin
# Voor full-text search
execute <<-SQL
CREATE INDEX idx_articles_search ON articles
USING gin(to_tsvector('english', title || ' ' || body));
SQL
GIN indexes zijn trager om te updaten dan B-tree indexes maar veel sneller voor containment queries (@>, &&) en full-text search (@@).
Ontbrekende Indexes Vinden in Productie
pg_stat_user_tables: Sequential Scan Detectie
SELECT schemaname, relname, seq_scan, seq_tup_read, idx_scan
FROM pg_stat_user_tables
WHERE seq_scan > 1000
ORDER BY seq_tup_read DESC
LIMIT 20;
Tabellen met hoge seq_scan aantallen en hoge seq_tup_read ten opzichte van idx_scan zijn kandidaten voor ontbrekende indexes.
pg_stat_user_indexes: Ongebruikte Index Detectie
SELECT indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE idx_scan = 0
AND schemaname = 'public'
ORDER BY pg_relation_size(indexrelid) DESC;
Indexes met nul scans verspillen ruimte en vertragen writes. Verwijder ze. Reset eerst de stats-teller (pg_stat_reset()) en wacht een volledige bedrijfscyclus voordat je beslist.
De active_record_doctor Gem
Voor geautomatiseerde detectie in Rails:
# Gemfile
gem 'active_record_doctor', group: :development
# Voer vervolgens uit:
# bundle exec rake active_record_doctor:missing_foreign_key_indexes
# bundle exec rake active_record_doctor:unindexed_foreign_keys
Dit vangt de meest voorkomende misser: foreign keys zonder indexes. Elke belongs_to associatie moet een index hebben op de foreign key kolom.
EXPLAIN ANALYZE: Controleer of Je Index Wordt Gebruikt
Een index toevoegen garandeert niet dat PostgreSQL hem gebruikt. De query planner maakt kosten-gebaseerde beslissingen. Controleer met EXPLAIN ANALYZE:
# In Rails console
Order.where(customer_id: 42, status: 'pending').explain(:analyze)
Zoek naar Index Scan of Index Only Scan in de output. Als je Seq Scan ziet ondanks een index, veelvoorkomende oorzaken:
- Tabel is te klein — PostgreSQL beslist dat een sequential scan sneller is
- Statistics zijn verouderd — Voer
ANALYZE orders;uit - Query matcht niet met indexvorm — Controleer kolomvolgorde in composite indexes
- Type mismatch — Een string vergelijken met een integer-kolom omzeilt de index
Covering Indexes (Index-Only Scans)
PostgreSQL 11+ ondersteunt INCLUDE-kolommen in indexes. Dit laat PostgreSQL een query volledig vanuit de index beantwoorden zonder de tabel te raadplegen:
execute <<-SQL
CREATE INDEX idx_orders_covering ON orders (customer_id, status)
INCLUDE (total_amount, created_at);
SQL
Op I/O-gebonden workloads scheelt dit aanzienlijk in querytijd.
Wanneer Niet Indexeren
Niet elke kolom heeft een index nodig:
- Low-cardinality boolean kolommen — Een index op een
booleankolom met 50/50 verdeling helpt zelden - Write-heavy tabellen met weinig reads — Elke index voegt write-overhead toe
- Kolommen alleen in
SELECTlijsten — Indexes helpen bijWHERE,JOIN,ORDER BYenGROUP BY - Kleine tabellen — Onder een paar duizend rijen is een sequential scan vaak sneller
Index Bloat Monitoren
PostgreSQL indexes worden opgeblazen na verloop van tijd door updates en deletes.
Check bloat:
SELECT
nspname, relname,
pg_size_pretty(pg_relation_size(i.indexrelid)) AS index_size,
idx_scan
FROM pg_stat_user_indexes ui
JOIN pg_index i ON ui.indexrelid = i.indexrelid
JOIN pg_class c ON i.indrelid = c.oid
JOIN pg_namespace n ON c.relnamespace = n.oid
WHERE nspname = 'public'
ORDER BY pg_relation_size(i.indexrelid) DESC
LIMIT 10;
Voor tabellen met veel updates, plan periodiek REINDEX CONCURRENTLY (PostgreSQL 12+). Je zero-downtime migratiestrategie moet indexonderhoud bevatten.
Praktische Checklist
- Audit foreign keys — Elke
_idkolom in associaties heeft een index nodig - Check je slow query log — PostgreSQL’s
log_min_duration_statementop 100ms vangt de ergste gevallen - Ontwerp composite indexes voor je daadwerkelijke queries — Raad niet, check
EXPLAIN ANALYZE - Gebruik partial indexes — Bij status-gefilterde queries, indexeer alleen de rijen die ertoe doen
- Verwijder ongebruikte indexes — Ze zijn pure overhead
- Monitor bloat — Vooral op high-write tabellen
FAQ
Hoeveel indexes is te veel op één tabel?
Er is geen harde limiet, maar elke index voegt write-overhead toe. Ik mik doorgaans op minder dan 8 indexes per tabel. Als je er meer hebt, heb je waarschijnlijk redundante indexes — een composite index op (a, b) maakt een single-column index op a overbodig.
Moet ik indexes toevoegen aan kolommen in ORDER BY?
Ja, als de ORDER BY op een grote resultaatset is. PostgreSQL kan een B-tree index gebruiken om rijen gesorteerd terug te geven zonder een aparte sorteerstap. Combineer het met je WHERE-condities in een composite index voor het beste resultaat.
Kan ik indexes aanmaken zonder downtime?
Ja. Gebruik algorithm: :concurrently in Rails migrations:
class AddIndexConcurrently < ActiveRecord::Migration[8.0]
disable_ddl_transaction!
def change
add_index :orders, :customer_id, algorithm: :concurrently
end
end
De disable_ddl_transaction! is vereist omdat concurrent index creation niet binnen een transactie kan draaien. Zie onze gids over zero-downtime database migraties voor meer hierover.
Hoe weet ik of PostgreSQL mijn index gebruikt?
Voer EXPLAIN ANALYZE uit op je query. Zoek naar Index Scan of Index Only Scan in het plan. Als PostgreSQL Seq Scan kiest op een grote tabel ondanks je index, controleer dat je querycondities overeenkomen met de indexkolommen in de juiste volgorde.
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