Ruby on Rails es uno de los framework más usados por las startups por la facilidad de crear aplicaciones robustas en poco tiempo pero el uso de malas practicas provoca aplicaciones lentas debido al problema de las N+1 queries.

Normalmente cuando una startup crece la aplicación debe esforzarse más para consultar y manipula información lo que podría causar que una aplicación se alenté. El problema de aplicaciones lentas es la falta de buenas practicas a la hora de escribir código

Uno de los errores mas comunes en los que caen los desarrolladores de Ruby on Rails es el problema de los N+1 queries. Muchas veces delegamos toda la responsabilidad a Active Record que a mediano/largo plazo es la causa de la mayoría de los problemas de rendimiento.

¿Cómo caemos en este problema de las N+1 queries?

Esto sucede cuando listamos una serie de objetos y posteriormente realizamos otra consulta por cada uno de los registros previamente consultados.

# app/models/user.rb

class User < ApplicationRecord
  has_many :images
end


# app/models/image.rb

class Image < ApplicationRecord
  belongs_to :user
end

Cómo puedes ver un usuario tiene multiples imágenes y una imagen pertenece a un usuario. Ahora supongamos que deseamos obtener los usuarios desde nuestro controller.

# app/controllers/images_controller.rb

class ImagesController < ApplicationController
  def index
    @images = Image.limit(10)
  end
end

Ahora si queremos listar los datos en una vista, tendríamos que hacer algo así.

@images.each do |image|
  image.size
  image.user.name
end

Como vemos el código funciona correctamente pero no estamos siguiendo ninguna buena practica por lo que esto nos estará generando un gran problema.

Started GET "/images" for ::1 at 2019-11-08 14:30:42 -0500
Processing by ImagesController#index as HTML
  Rendering images/index.haml within layouts/application
  Image Load (0.1ms)  SELECT  "images".* FROM "images" LIMIT ?  [["LIMIT", 10]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
  User Load (0.1ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 4], ["LIMIT", 1]]
  CACHE User Load (0.0ms)  SELECT  "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?  [["id", 3], ["LIMIT", 1]]
  Rendered images/index.haml within layouts/application (15.9ms)
Completed 200 OK in 35ms (Views: 31.0ms | ActiveRecord: 1.5ms)

Como vemos en el log hay 11 consultas, una de ellas es para obtener las imágenes y N para obtener el usuario. Este tipo de consultas crea malas practicas pero ¿Qué pasaría si fueran miles o incluso millones de registros?

Cuando tenemos una aplicación pequeña podemos identificar y reparar fácilmente pero conforme crece nuestra aplicación se convierte en un verdadero pain in the ass . Por suerte existen algunos métodos y diversas herramientas que nos ayudan a evitar caer en este problema como por ejemplo.

Esta ultima esta especialmente diseñada para ayudarte a incrementar el performance de la aplicación reduciendo el numero de consultas a la base de datos.

Para poder usar la gema dentro de tu proyecto necesitas agregarla en tu Gemfile de la siguiente forma: gem 'bullet', group: 'development’, posteriormente en el archivo config/environments/development.rb agrega las siguientes lineas.

# config/environments/development.rb

config.after_initialize do
  Bullet.enable = true
  Bullet.rails_logger = true
end

Cuando ejecutemos nuevamente el query Images la gema Bullet nos mostrará en la consola algo similar a esto.

GET /images
USE eager loading detected
  Image => [:user]
  Add to your finder: :includes => [:user]

Este mensaje nos confirma que estamos haciendo algo mal y que deberíamos repararlo. La solución más común a este problema es usar métodos Eager Loading, de esta forma estaremos reduciendo el numero de queries realizados tanto como sea posible.

Preload method

Preload es la función predeterminada para el método #includes. Este método realizará 2 queries, uno para los datos principales y el otro para los datos de modelos asociados.

Esto significa que no podemos segmentar el resultado utilizando where

En este caso reutilizaremos el ejemplo anterior pero esta vez le diremos a Active Record que incluya el usuario cuando carguemos las imágenes.

# app/controllers/images_controller.rb

class ImagesController < ApplicationController
  def index
    @images = Image.preload(:user).limit(10)
  end
end

Como podrás ver el resultado es diferente, solo se realizaron dos consultas a la base de datos obteniendo el mismo resultado y con buen performance.

Started GET "/images" for ::1 at 2019-11-08 15:20:45 -0500
Processing by ImagesController#index as HTML
  Rendering images/index.haml within layouts/application
  Image Load (0.1ms)  SELECT  "images".* FROM "images" LIMIT ?  [["LIMIT", 10]]
  User Load (0.1ms)  SELECT "users".* FROM "users" WHERE "users"."id" IN (1, 3, 2, 4)
  Rendered images/index.haml within layouts/application (17.2ms)
Completed 200 OK in 49ms (Views: 44.6ms | ActiveRecord: 1.3ms)

Include method

Include es otro método con el cual podremos agregar asociaciones mediante sentencias where lo que generará un query de SQL más complejo.

# app/controllers/images_controller.rb

class ImagesController < ApplicationController
  def index
    @images = Image.includes(:user).where(users: { role: 'Admin' }).each { |image| image.user.role }  )
  end
end

Este ejemplo cargará solo las imágenes donde el usuario tengan el rol de Admin.

User Load (0.2ms)  SELECT "images"."id" AS t0_r0, "images"."title" AS t0_r1, "images"."description" AS t0_r2, "images"."size" AS t0_r3, "images"."user_id" AS t0_r4, "users"."id" AS t1_r0, "users"."role" AS t1_r1, "users"."description" AS t1_r2 FROM "images" LEFT OUTER JOIN "users" ON "users"."id" = "images"."user_id" WHERE "categories"."role" = ?  [["title", "Admin"]]

De igual forma también puedes forzar el método #include a realizar un query utilizando la función #references el cual internamente usa un LEFT OUTER JOIN

# app/controllers/images_controller.rb

class ImagesController < ApplicationController
  def index
    @images = Image.includes(:user).references(:users).each { | image | image.user.role }  
  end
end

Este método internamente ejecutará un query como este.

SQL (0.1ms)  SELECT "images"."id" AS t0_r0, "images"."role" AS t0_r1, "images"."description" AS t0_r2, "images"."size" AS t0_r3, "images"."user_id" AS t0_r4, "users"."id" AS t1_r0, "users"."role" AS t1_r1, "users"."description" AS t1_r2 FROM "images" LEFT OUTER JOIN "users" ON "users"."id" = "images"."user_id"

Como puedes ver Ruby on Rails es un framework amigable te ayudará a realizar aplicaciones escalables siempre y cuando consideres tener buenas prácticas.

Si consideras que este aporte fue de valor compártelo para juntos crear un ambiente donde todos deseemos colaborar.