ActiveRecord : Optimiser les requêtes N+1 dans Ruby on Rails

Guide complet pour détecter et corriger les problèmes de requêtes N+1 dans Rails avec ActiveRecord. Includes, preload, eager_load et outils de détection.

Optimisation des requêtes N+1 avec ActiveRecord dans Ruby on Rails

Les requêtes N+1 représentent l'un des problèmes de performance les plus courants dans les applications Rails. Une simple boucle sur des enregistrements peut déclencher des centaines de requêtes SQL inutiles, ralentissant drastiquement les temps de réponse. Ce guide couvre les techniques de détection et de résolution pour garantir des applications Rails performantes.

Impact en production

Une page affichant 50 articles avec leurs auteurs peut générer 51 requêtes SQL au lieu d'une seule. En production avec des milliers d'utilisateurs, ce problème devient critique pour les temps de réponse et la charge serveur.

Comprendre le problème N+1

Le problème N+1 survient lorsque le code effectue une requête pour récupérer une liste d'enregistrements (1 requête), puis exécute une requête supplémentaire pour chaque enregistrement afin d'accéder à ses associations (N requêtes). Le nom "N+1" décrit exactement ce pattern : 1 requête initiale + N requêtes pour les associations.

Prenons un exemple concret avec des articles et leurs auteurs. Sans optimisation, chaque accès à l'auteur d'un article déclenche une nouvelle requête SQL.

ruby
# app/controllers/articles_controller.rb
# Exemple de code générant un problème N+1
class ArticlesController < ApplicationController
  def index
    # 1 requête : SELECT * FROM articles
    @articles = Article.all
  end
end

Dans la vue, chaque appel à article.author déclenche une requête supplémentaire vers la base de données.

erb
<!-- app/views/articles/index.html.erb -->
<!-- Cette vue génère N requêtes supplémentaires -->
<% @articles.each do |article| %>
  <div class="article">
    <h2><%= article.title %></h2>
    <!-- Chaque appel génère : SELECT * FROM users WHERE id = ? -->
    <p>Par <%= article.author.name %></p>
  </div>
<% end %>

Pour 100 articles, ce code génère 101 requêtes SQL. Les logs Rails montrent clairement le problème avec des requêtes répétitives.

sql
-- Logs Rails montrant le problème N+1
-- 1 requête initiale
SELECT "articles".* FROM "articles"

-- N requêtes pour les auteurs (répétées pour chaque 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 requêtes supplémentaires

Résoudre avec includes

La méthode includes est la solution la plus courante et la plus recommandée pour résoudre les problèmes N+1. Elle indique à ActiveRecord de précharger les associations en une ou deux requêtes optimisées.

ruby
# app/controllers/articles_controller.rb
# Solution avec includes - préchargement des auteurs
class ArticlesController < ApplicationController
  def index
    # Précharge les auteurs avec les articles
    # Génère seulement 2 requêtes au lieu de N+1
    @articles = Article.includes(:author).all
  end
end

Avec includes, ActiveRecord exécute seulement deux requêtes quelle que soit la quantité d'articles. La première récupère tous les articles, la seconde récupère tous les auteurs concernés.

sql
-- Logs Rails avec includes (seulement 2 requêtes)
SELECT "articles".* FROM "articles"
SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2, 3, 4, 5, ...)

Les associations imbriquées peuvent également être préchargées en utilisant une syntaxe en hash. Cette approche est essentielle lorsque les vues accèdent à plusieurs niveaux d'associations.

ruby
# app/controllers/articles_controller.rb
# Préchargement d'associations imbriquées
class ArticlesController < ApplicationController
  def index
    # Précharge author -> company et tous les comments
    @articles = Article.includes(author: :company, comments: :user)
  end
end
Règle d'or

Si une vue accède à une association dans une boucle, cette association doit être préchargée dans le contrôleur avec includes. Vérifier systématiquement les accès aux associations dans les vues.

Différences entre includes, preload et eager_load

Rails propose trois méthodes pour le préchargement des associations. Chacune utilise une stratégie SQL différente, avec des cas d'usage spécifiques.

preload : requêtes séparées

La méthode preload exécute toujours des requêtes séparées pour chaque association. Elle est efficace quand aucune condition WHERE ne filtre sur les associations.

ruby
# app/models/article.rb
# preload utilise toujours des requêtes séparées
class Article < ApplicationRecord
  scope :with_authors, -> { preload(:author) }
end

# Utilisation dans le controller
@articles = Article.with_authors.limit(20)

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

eager_load : LEFT OUTER JOIN

La méthode eager_load utilise un LEFT OUTER JOIN pour charger les données en une seule requête. Elle est obligatoire pour filtrer ou trier sur les colonnes des associations.

ruby
# app/controllers/articles_controller.rb
# eager_load permet de filtrer sur les associations
class ArticlesController < ApplicationController
  def verified_authors
    # Filtre les articles par statut de l'auteur
    # Nécessite eager_load car WHERE porte sur users
    @articles = Article.eager_load(:author)
                       .where(users: { verified: true })
                       .order("users.name ASC")
  end
end

# SQL généré (une seule requête avec 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 : comportement intelligent

La méthode includes choisit automatiquement la meilleure stratégie. Elle utilise preload par défaut, mais bascule vers eager_load si une clause WHERE référence l'association.

ruby
# app/controllers/articles_controller.rb
# includes s'adapte automatiquement au contexte
class ArticlesController < ApplicationController
  def index
    # Sans condition sur l'association : utilise preload (2 requêtes)
    @articles = Article.includes(:author).all
  end

  def by_verified_authors
    # Avec condition sur l'association : utilise eager_load (JOIN)
    @articles = Article.includes(:author)
                       .where(users: { verified: true })
  end
end

Le tableau suivant résume les différences entre les trois méthodes.

| Méthode | Stratégie SQL | Cas d'usage | |---------|---------------|-------------| | preload | Requêtes séparées | Préchargement simple, pas de filtrage | | eager_load | LEFT OUTER JOIN | Filtrage/tri sur associations | | includes | Automatique | Usage général, recommandé par défaut |

Détecter les requêtes N+1 automatiquement

La détection manuelle des problèmes N+1 est fastidieuse et sujette aux oublis. Plusieurs outils automatisent cette détection en développement et en CI.

Bullet : détection en temps réel

La gem Bullet analyse les requêtes SQL en temps réel et alerte sur les problèmes N+1 détectés. Elle propose également les corrections appropriées.

ruby
# Gemfile
# Bullet détecte les N+1 en développement
group :development do
  gem 'bullet'
end

La configuration dans l'environnement de développement active les différents modes d'alerte.

ruby
# config/environments/development.rb
# Configuration de Bullet pour détecter les N+1
Rails.application.configure do
  config.after_initialize do
    Bullet.enable = true
    # Affiche une alerte JavaScript dans le navigateur
    Bullet.alert = true
    # Ajoute un footer avec les détails
    Bullet.bullet_logger = true
    # Affiche dans les logs Rails
    Bullet.rails_logger = true
    # Lève une exception (utile en CI)
    Bullet.raise = false
  end
end

Lorsqu'un problème N+1 est détecté, Bullet affiche un message explicite avec la solution recommandée.

text
# Exemple d'alerte Bullet dans les logs
USE eager loading detected
  Article => [:author]
  Add to your query: .includes([:author])
Call stack:
  /app/views/articles/index.html.erb:5
Bullet en CI

En intégration continue, activer Bullet.raise = true permet de faire échouer les tests si un problème N+1 est détecté. Cela empêche les régressions de performance.

Prosopite : alternative légère

La gem Prosopite offre une alternative plus légère à Bullet, avec une configuration minimale et une compatibilité avec les tests.

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

Techniques avancées d'optimisation

Au-delà des méthodes de base, plusieurs techniques permettent d'optimiser finement les requêtes ActiveRecord.

Strict loading : prévention par défaut

Rails 6.1+ propose le mode strict loading qui lève une exception si une association non préchargée est accédée. Cette approche préventive force à résoudre les N+1 dès le développement.

ruby
# app/models/article.rb
# Active le strict loading par défaut sur le modèle
class Article < ApplicationRecord
  # Toute association non préchargée lèvera une exception
  self.strict_loading_by_default = true

  belongs_to :author
  has_many :comments
end

Le strict loading peut aussi être activé ponctuellement sur une requête spécifique.

ruby
# app/controllers/articles_controller.rb
# Strict loading ponctuel sur une requête
class ArticlesController < ApplicationController
  def index
    # Lève StrictLoadingViolationError si une association
    # non incluse est accédée
    @articles = Article.strict_loading.includes(:author)
  end
end

Select et pluck pour les données partielles

Quand seules certaines colonnes sont nécessaires, select et pluck réduisent la quantité de données transférées depuis la base.

ruby
# app/controllers/reports_controller.rb
# Optimisation avec select et pluck
class ReportsController < ApplicationController
  def titles_only
    # select retourne des objets Article avec seulement id et title
    @articles = Article.select(:id, :title)
  end

  def title_array
    # pluck retourne un Array de valeurs, pas d'objets AR
    # Plus performant quand seules les valeurs sont nécessaires
    @titles = Article.pluck(:title)
    # => ["Premier article", "Second article", ...]
  end
end

Counter cache pour les comptages

Les comptages d'associations (article.comments.count) génèrent une requête SQL à chaque appel. Le counter cache stocke ce comptage directement dans la table parente.

ruby
# app/models/comment.rb
# Configuration du counter cache
class Comment < ApplicationRecord
  # Rails maintient automatiquement le compteur dans articles.comments_count
  belongs_to :article, counter_cache: true
end

La migration ajoute la colonne de comptage avec une valeur par défaut.

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

    # Initialise les compteurs pour les données existantes
    Article.find_each do |article|
      Article.reset_counters(article.id, :comments)
    end
  end
end

Après cette configuration, article.comments_count lit directement la colonne sans requête SQL supplémentaire.

ruby
# app/views/articles/index.html.erb
# Utilisation du counter cache (pas de requête SQL)
<% @articles.each do |article| %>
  <p><%= article.title %> - <%= article.comments_count %> commentaires</p>
<% end %>

Prêt à réussir tes entretiens Ruby on Rails ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

Bonnes pratiques et checklist

Une approche systématique permet d'éviter les problèmes N+1 dans les nouveaux développements et de corriger progressivement le code existant.

Analyse des vues avant le code

Avant d'écrire le code d'un contrôleur, analyser la vue pour identifier toutes les associations accédées. Cette anticipation évite les oublis.

ruby
# app/controllers/articles_controller.rb
# Analyse préalable de la vue pour identifier les includes nécessaires
class ArticlesController < ApplicationController
  def show
    # La vue accède à : author, author.company, comments, comments.user
    # Tous ces éléments doivent être préchargés
    @article = Article.includes(
      author: :company,
      comments: :user
    ).find(params[:id])
  end
end

Scopes réutilisables

Centraliser les includes fréquents dans des scopes facilite la maintenance et garantit la cohérence.

ruby
# app/models/article.rb
# Scopes réutilisables pour le préchargement
class Article < ApplicationRecord
  # Scope pour l'affichage en liste
  scope :with_author, -> { includes(:author) }

  # Scope pour l'affichage détaillé
  scope :with_full_details, -> {
    includes(
      author: :company,
      comments: { user: :avatar_attachment },
      tags: []
    )
  }

  # Scope pour l'admin avec toutes les relations
  scope :for_admin, -> {
    includes(:author, :comments, :tags, :category)
      .with_attached_cover_image
  }
end

Checklist de prévention

Cette checklist résume les points de vérification essentiels pour éviter les problèmes N+1 :

  • Installer et configurer Bullet ou Prosopite en développement
  • Activer Bullet.raise en CI pour bloquer les régressions
  • Analyser les vues pour identifier les associations avant d'écrire les contrôleurs
  • Utiliser includes par défaut, eager_load si filtrage sur associations
  • Créer des scopes réutilisables pour les patterns de préchargement fréquents
  • Utiliser le strict loading sur les modèles sensibles
  • Ajouter des counter caches pour les comptages fréquents
  • Vérifier les logs SQL en développement régulièrement
Attention au sur-préchargement

Précharger trop d'associations consomme de la mémoire inutilement. Précharger uniquement ce qui est réellement utilisé dans la vue. Les outils comme Bullet détectent aussi le "unused eager loading".

Conclusion

Les requêtes N+1 constituent un problème de performance majeur mais facilement évitable dans les applications Rails. Une combinaison d'outils de détection automatique et de bonnes pratiques de développement permet d'éliminer ce problème.

Points clés à retenir :

  • includes résout la majorité des cas N+1 en préchargeant les associations
  • eager_load est nécessaire pour filtrer ou trier sur les associations
  • Bullet et Prosopite détectent automatiquement les problèmes en développement
  • Le strict loading prévient les N+1 en levant des exceptions
  • Les counter caches optimisent les comptages fréquents
  • Analyser les vues avant d'écrire les contrôleurs évite les oublis

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#ruby on rails
#activerecord
#performance
#requêtes n+1
#optimisation sql

Partager

Articles similaires