# 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.
- Published: 2026-02-23
- Updated: 2026-03-28
- Author: SharpSkill
- Tags: ruby on rails, activerecord, performance, requêtes n+1, optimisation sql
- Reading time: 10 min
---
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
<% @articles.each do |article| %>
<%= article.title %>
Par <%= article.author.name %>
<% 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| %>