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
. 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.N+1 queries
¿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
de la siguiente forma: Gemfile
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 #reference
s 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.