A la chasse aux requĂȘtes N+1 avec Ruby on Rail

  • optimization
  • performance
  • rails
  • ruby
  • sql

Publié le 2018-06-22

IntĂ©ressĂ© pour crĂ©er un API avec Ruby on Rails? Jette un coup d'Ɠil Ă  mon livre: API on Rails 6. Tu peux tĂ©lĂ©charger une version gratuite au format PDF sur Github. Si tu aimes mon travail tu peux acheter un version payante sur Leanpub.

Les requĂȘtes N+1 sont une plaie qui peuvent avoir un impact Ă©norme sur les performances d'une application. Ce phĂ©nomĂšne se produit souvent lorsqu'on utilise un ORM.

T'inquiùtes pas, je m’occupe de tout.

Active Record de Ruby on Rails

L'ORM gĂ©nĂšre automatiquement les requĂȘtes SQL et nous Ă©vite ainsi de les taper Ă  la main. Cet outil bien pratique est Ă  double tranchant car il peut gĂ©nĂ©rer un grand nombre de requĂȘte SQL.

Exemple

Voici un exemple oĂč l'on veut rĂ©cupĂ©rer tous les utilisateurs qui ont dĂ©jĂ  crĂ©Ă© une recette. Sans rĂ©flĂ©chir, on serait tentĂ© de faire plus ou moins comme ça:

users = Recipe.all.map{|recipe| recipe.user}
# SELECT "recipes".* FROM "recipes"
# SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", 1]]
# SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1  [["id", 2]]

On voit ici qu'on gĂ©nĂšre trois requĂȘtes.

  • Recipe.all = 1 requĂȘte pour rĂ©cupĂ©rer les recettes
  • recipe.user = 1 requĂȘte SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT 1 [["id", 1]] par recette

Donc, si nous avons 1000 recettes en base, nous gĂ©nĂ©rons 1001 requĂȘtes SQL.

Comment corriger?

C'est lĂ  qu'intervient includes. includes va prĂ©-charger les objets enfants dans une seule requĂȘte. Son utilisation est trĂšs facile. Si on reprend l'exemple prĂ©cĂ©dent

users = Recipe.includes(:user).all.map{|recipe| recipe.user}
# SELECT "recipes".* FROM "recipes"
# SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?) [["id", 1],["id", 2]]

Et voilĂ :

  • Recipe.all = 1 requĂȘte SELECT "recipes".* FROM "recipes"
  • recipe.user = 1 requĂȘte SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 2)

includes ne s'arrĂȘte pas lĂ  et nous permet mĂȘme de prĂ©charger les sous-liaisons trĂšs facilement:

Recipe.includes(comments: :user)

Active Record est magique!

Comment prévenir?

Nous avons vu qu'il est malheureusement trĂšs facile de crĂ©er une requĂȘte N+1. Heureusement, il existe une gem qui permet de nous alerter lorsque ce genre de situation arrive: Bullet. Bullet va nous prĂ©venir (par mail, notification growl, console, etc..) lorsqu’il trouve une requĂȘte N+1.

Pour l'installer, on ajoute la gem au GemFile

# Gemfile

group :development do
  gem 'bullet'
end

On n'oublie pas de lancer un bundle install pour installer la dépendance et on crée un initializer pour configurer Bullet.

# config/initializers/bullet.rb

Rails.application.config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
end

Changer la configuration nécessite le redémarrage du serveur

Ici j'ai seulement activĂ© une window.alert JavaScript. Il suffit ensuite d'utiliser l'application normalement et lorsque l'on rencontrera une requĂȘte N+1:

Affichage de test.fr/load.php sans mise en cache

Affichage de test.fr/load.php sans mise en cache

Articles liés