ActiveRecord: N+1-queryproblemen oplossen in Ruby on Rails

Volledige gids voor het detecteren en oplossen van N+1-queries in Rails met ActiveRecord. Beheers includes, preload, eager_load en geautomatiseerde detectietools.

N+1-queryproblemen oplossen met ActiveRecord in Ruby on Rails

N+1-queries vormen een van de meest voorkomende prestatieproblemen in Rails-applicaties. Een eenvoudige loop over records kan honderden onnodige SQL-queries veroorzaken en de responstijden drastisch vertragen. Deze gids behandelt detectie- en oplostechnieken om performante Rails-applicaties te garanderen.

Impact in productie

Een pagina met 50 artikelen en hun auteurs kan 51 SQL-queries genereren in plaats van slechts één. In productie met duizenden gebruikers wordt dit probleem kritiek voor responstijden en serverbelasting.

Het N+1-probleem begrijpen

Het N+1-probleem treedt op wanneer de code één query uitvoert om een lijst met records op te halen (1 query) en vervolgens een extra query uitvoert voor elk record om toegang te krijgen tot de bijbehorende associaties (N queries). De naam "N+1" beschrijft precies dit patroon: 1 initiële query + N queries voor de associaties.

Een concreet voorbeeld met artikelen en hun auteurs maakt het probleem duidelijk. Zonder optimalisatie veroorzaakt elke toegang tot de auteur van een artikel een nieuwe SQL-query.

ruby
# app/controllers/articles_controller.rb
# Example code generating an N+1 problem
class ArticlesController < ApplicationController
  def index
    # 1 query: SELECT * FROM articles
    @articles = Article.all
  end
end

In de view veroorzaakt elke aanroep van article.author een extra query naar de database.

erb
<!-- app/views/articles/index.html.erb -->
<!-- This view generates N additional queries -->
<% @articles.each do |article| %>
  <div class="article">
    <h2><%= article.title %></h2>
    <!-- Each call generates: SELECT * FROM users WHERE id = ? -->
    <p>By <%= article.author.name %></p>
  </div>
<% end %>

Voor 100 artikelen genereert deze code 101 SQL-queries. De Rails-logs tonen het probleem duidelijk via repetitieve queries.

sql
-- Rails logs showing the N+1 problem
-- 1 initial query
SELECT "articles".* FROM "articles"

-- N queries for authors (repeated for each article)
SELECT "users".* FROM "users" WHERE "users"."id" = 1 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 2 LIMIT 1
SELECT "users".* FROM "users" WHERE "users"."id" = 3 LIMIT 1
-- ... 97 more queries

Oplossen met includes

De methode includes is de meest gangbare en aanbevolen oplossing voor het verhelpen van N+1-problemen. Ze geeft ActiveRecord opdracht om de associaties vooraf te laden in één of twee geoptimaliseerde queries.

ruby
# app/controllers/articles_controller.rb
# Solution with includes - preloading authors
class ArticlesController < ApplicationController
  def index
    # Preloads authors with articles
    # Generates only 2 queries instead of N+1
    @articles = Article.includes(:author).all
  end
end

Met includes voert ActiveRecord slechts twee queries uit, ongeacht het aantal artikelen. De eerste haalt alle artikelen op, de tweede haalt alle relevante auteurs op.

sql
-- Rails logs with includes (only 2 queries)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)

Geneste associaties kunnen ook vooraf worden geladen met hash-syntaxis. Deze aanpak is essentieel wanneer views toegang hebben tot meerdere niveaus van associaties.

ruby
# app/controllers/articles_controller.rb
# Preloading nested associations
class ArticlesController < ApplicationController
  def index
    # Preloads author -> company and all comments
    @articles = Article.includes(author: :company, comments: :user)
  end
end
Gouden regel

Als een view binnen een loop toegang heeft tot een associatie, moet die associatie in de controller vooraf worden geladen met includes. Het is belangrijk om altijd de toegangspatronen tot associaties in de views te controleren.

Verschillen tussen includes, preload en eager_load

Rails biedt drie methoden om associaties vooraf te laden. Elk gebruikt een andere SQL-strategie, met specifieke gebruiksgevallen.

preload: aparte queries

De methode preload voert altijd aparte queries uit voor elke associatie. Ze werkt efficiënt wanneer geen WHERE-conditie filtert op de associaties.

ruby
# app/models/article.rb
# preload always uses separate queries
class Article < ApplicationRecord
  scope :with_authors, -> { preload(:author) }
end

# Usage in controller
@articles = Article.with_authors.limit(20)

# SQL generated:
# SELECT "articles".* FROM "articles" LIMIT 20
# SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, ...)

eager_load: LEFT OUTER JOIN

De methode eager_load gebruikt een LEFT OUTER JOIN om de gegevens in één query te laden. Ze wordt verplicht bij het filteren of sorteren op kolommen van associaties.

ruby
# app/controllers/articles_controller.rb
# eager_load allows filtering on associations
class ArticlesController < ApplicationController
  def verified_authors
    # Filters articles by author status
    # Requires eager_load because WHERE references users
    @articles = Article.eager_load(:author)
                       .where(users: { verified: true })
                       .order("users.name ASC")
  end
end

# SQL generated (single query with JOIN):
# SELECT "articles"."id", "articles"."title", ...
# FROM "articles"
# LEFT OUTER JOIN "users" ON "users"."id" = "articles"."author_id"
# WHERE "users"."verified" = TRUE
# ORDER BY "users"."name" ASC

includes: slim gedrag

De methode includes kiest automatisch de beste strategie. Standaard gebruikt ze preload, maar schakelt over op eager_load als een WHERE-clausule naar de associatie verwijst.

ruby
# app/controllers/articles_controller.rb
# includes adapts automatically to context
class ArticlesController < ApplicationController
  def index
    # No condition on association: uses preload (2 queries)
    @articles = Article.includes(:author).all
  end

  def by_verified_authors
    # With condition on association: uses eager_load (JOIN)
    @articles = Article.includes(:author)
                       .where(users: { verified: true })
  end
end

De volgende tabel vat de verschillen tussen de drie methoden samen.

| Methode | SQL-strategie | Toepassing | |---------|---------------|------------| | preload | Aparte queries | Eenvoudige preloading, geen filtering | | eager_load | LEFT OUTER JOIN | Filteren/sorteren op associaties | | includes | Automatisch | Algemeen gebruik, aanbevolen standaard |

Geautomatiseerde N+1-detectie

Het handmatig opsporen van N+1-problemen is moeizaam en foutgevoelig. Verschillende tools automatiseren deze detectie in development en CI.

Bullet: realtime detectie

De gem Bullet analyseert SQL-queries in realtime en waarschuwt voor gedetecteerde N+1-problemen. Daarnaast stelt ze passende oplossingen voor.

ruby
# Gemfile
# Bullet detects N+1 in development
group :development do
  gem 'bullet'
end

De configuratie in de development-omgeving maakt het mogelijk om verschillende waarschuwingsmodi in te schakelen.

ruby
# config/environments/development.rb
# Bullet configuration to detect N+1
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    # Display JavaScript alert in browser
    Bullet.alert = true
    # Add footer with details
    Bullet.bullet_logger = true
    # Display in Rails logs
    Bullet.rails_logger = true
    # Raise exception (useful in CI)
    Bullet.raise = false
  end
end

Wanneer een N+1-probleem wordt gedetecteerd, toont Bullet een duidelijke melding met de aanbevolen oplossing.

text
# Example Bullet alert in logs
USE eager loading detected
  Article => [:author]
  Add to your query: .includes([:author])
Call stack:
  /app/views/articles/index.html.erb:5
Bullet in CI

In continuous integration zorgt het inschakelen van Bullet.raise = true ervoor dat tests falen als een N+1-probleem wordt gedetecteerd. Zo worden prestatieregressies voorkomen.

Prosopite: lichtgewicht alternatief

De gem Prosopite biedt een lichter alternatief voor Bullet, met minimale configuratie en compatibiliteit met tests.

ruby
# Gemfile
# Prosopite as an alternative to Bullet
group :development, :test do
  gem 'prosopite'
end
ruby
# config/environments/development.rb
# Prosopite configuration
Rails.application.configure do
  config.after_initialize do
    Prosopite.rails_logger = true
    Prosopite.raise = Rails.env.test?
  end
end

Geavanceerde optimalisatietechnieken

Naast de basismethoden zijn er meerdere technieken om de optimalisatie van ActiveRecord-queries fijn af te stemmen.

Strict Loading: preventie als standaard

Rails 6.1+ biedt een strict-loadingmodus die een uitzondering oproept als er toegang wordt verkregen tot een niet vooraf geladen associatie. Deze preventieve aanpak dwingt om N+1's tijdens de ontwikkeling op te lossen.

ruby
# app/models/article.rb
# Enable strict loading by default on the model
class Article < ApplicationRecord
  # Any non-preloaded association access raises an exception
  self.strict_loading_by_default = true

  belongs_to :author
  has_many :comments
end

Strict loading kan ook per specifieke query worden ingeschakeld.

ruby
# app/controllers/articles_controller.rb
# Strict loading on a specific query
class ArticlesController < ApplicationController
  def index
    # Raises StrictLoadingViolationError if a non-included
    # association is accessed
    @articles = Article.strict_loading.includes(:author)
  end
end

Select en Pluck voor gedeeltelijke gegevens

Wanneer slechts bepaalde kolommen nodig zijn, verminderen select en pluck de hoeveelheid gegevens die uit de database wordt overgedragen.

ruby
# app/controllers/reports_controller.rb
# Optimization with select and pluck
class ReportsController < ApplicationController
  def titles_only
    # select returns Article objects with only id and title
    @articles = Article.select(:id, :title)
  end

  def title_array
    # pluck returns an Array of values, not AR objects
    # More performant when only values are needed
    @titles = Article.pluck(:title)
    # => ["First article", "Second article", ...]
  end
end

Counter Cache voor tellingen

Associatietellingen (article.comments.count) genereren bij elke aanroep een SQL-query. Counter cache slaat deze telling rechtstreeks op in de bovenliggende tabel.

ruby
# app/models/comment.rb
# Counter cache configuration
class Comment < ApplicationRecord
  # Rails automatically maintains the counter in articles.comments_count
  belongs_to :article, counter_cache: true
end

De migratie voegt de tellingskolom toe met een standaardwaarde.

ruby
# db/migrate/20260223_add_comments_count_to_articles.rb
# Migration to add counter cache
class AddCommentsCountToArticles < ActiveRecord::Migration[7.1]
  def change
    add_column :articles, :comments_count, :integer, default: 0, null: false

    # Initialize counters for existing data
    Article.find_each do |article|
      Article.reset_counters(article.id, :comments)
    end
  end
end

Na deze configuratie leest article.comments_count de kolom rechtstreeks zonder extra SQL-queries.

ruby
# app/views/articles/index.html.erb
# Using counter cache (no SQL query)
<% @articles.each do |article| %>
  <p><%= article.title %> - <%= article.comments_count %> comments</p>
<% end %>

Klaar om je Ruby on Rails gesprekken te halen?

Oefen met onze interactieve simulatoren, flashcards en technische tests.

Best practices en checklist

Een systematische aanpak voorkomt N+1-problemen bij nieuwe ontwikkelingen en lost bestaande code geleidelijk op.

View-analyse vóór de code

Voordat de controllercode wordt geschreven, is het verstandig om de view te analyseren om alle benaderde associaties te identificeren. Deze vroege inventarisatie voorkomt omissies.

ruby
# app/controllers/articles_controller.rb
# Pre-analyze view to identify required includes
class ArticlesController < ApplicationController
  def show
    # View accesses: author, author.company, comments, comments.user
    # All these must be preloaded
    @article = Article.includes(
      author: :company,
      comments: :user
    ).find(params[:id])
  end
end

Herbruikbare scopes

Frequente includes centraliseren in scopes vereenvoudigt het onderhoud en zorgt voor consistentie.

ruby
# app/models/article.rb
# Reusable scopes for preloading
class Article < ApplicationRecord
  # Scope for list display
  scope :with_author, -> { includes(:author) }

  # Scope for detailed display
  scope :with_full_details, -> {
    includes(
      author: :company,
      comments: { user: :avatar_attachment },
      tags: []
    )
  }

  # Scope for admin with all relations
  scope :for_admin, -> {
    includes(:author, :comments, :tags, :category)
      .with_attached_cover_image
  }
end

Preventieve checklist

Deze checklist vat de essentiële controlepunten samen om N+1-problemen te vermijden:

  • Bullet of Prosopite installeren en configureren in development
  • Bullet.raise inschakelen in CI om regressies te blokkeren
  • Views analyseren om associaties te identificeren voordat controllers worden geschreven
  • Standaard includes gebruiken, eager_load bij filtering op associaties
  • Herbruikbare scopes maken voor frequente preloadingpatronen
  • Strict loading toepassen op gevoelige modellen
  • Counter caches toevoegen voor frequente tellingen
  • Regelmatig de SQL-logs in development controleren
Pas op voor te veel preloaden

Te veel associaties vooraf laden verbruikt onnodig geheugen. Alleen associaties die de view daadwerkelijk gebruikt, dienen te worden vooraf geladen. Tools zoals Bullet detecteren ook "unused eager loading".

Conclusie

N+1-queries vormen een belangrijk prestatieprobleem dat in Rails-applicaties eenvoudig te voorkomen is. De combinatie van geautomatiseerde detectietools en goede ontwikkelpraktijken elimineert dit probleem effectief.

Belangrijkste punten:

  • includes lost de meeste N+1-gevallen op door associaties vooraf te laden
  • eager_load is vereist bij het filteren of sorteren op associaties
  • Bullet en Prosopite detecteren problemen automatisch in development
  • Strict loading voorkomt N+1's door uitzonderingen op te roepen
  • Counter caches optimaliseren frequente tellingen
  • Views analyseren vóór het schrijven van controllers voorkomt omissies

Begin met oefenen!

Test je kennis met onze gespreksimulatoren en technische tests.

Tags

#ruby on rails
#activerecord
#performance
#n+1 queries
#sql optimization

Delen

Gerelateerde artikelen