Resolviendo el problema de n+1 en la cuenta de datos.
En este tutorial vamos a aprender un poco de eager loading en rails, y como evitar los problemas de n+1 cuando se trata de contar elementos hijos o padres y cuál es la diferencia entre los métodos count, size y length a la hora de contar datos.
Setup
Para el ejemplo vamos a ocupar un mini twitter, en este hay tweets y cada tweet pertenece un usuario, o sea dos modelos:
creamos el modelo de usuario
rails g model user name:string
creamos el modelo de tweets
rails g model tweet tweet:string user:references
y luego migramos.
rake db:migrate
Dentro del modelo de usuarios vamos a agregar la relación con los tweets
has_many :tweets
Para probar vamos a necesitar datos, para eso vamos a agregar la gema faker al gemfile y vamos a utilizar el siguiente archivo seed.
User.destroy_all Tweet.destroy_all
10.times.each do |u| u = User.create name: Faker::Name.name 20.times.each do |t| t = Tweet.create tweet: Faker::Hacker.say_something_smart, user: u end end
luego corremos el seed.
rake db:seed
Además vamos a necesitar el controller de usuarios para mostrar un index
rails g controller users index
y finalmente hacemos correr el servidor
rails s
Contando elementos de la forma iterativa
La forma más fácil de mostrar a todos los usuarios con la cuenta de sus respectiva es en el controller seleccionar a todos, y luego dentro de la vista iterar mostrando los resultados.
o sea nuestro controller users sería:
class UsersController < ApplicationController def index @users = User.all end end
luego creamos una sencilla vista de usuarios que muestre a los usuarios con la cuenta de tweets.
<% @users.each do |user| %> <p> Nombre: <%= user.name %> </p> <p> Tweets: <%= user.tweets.count %> </p> <% end %>
ahora si observamos nuestro terminal en el tab donde corre el servidor, veremos lo siguiente:
Lo que sucedió fue que un query que consistía en mostrar a todos los usuarios se terminó convirtiendo en N+1 Query. En mi base de datos el primer usuario es el 2, y el último el 11, o sea hay 10 usuarios y 11 queries y no es coincidencia, es realmente un problema de n+1
Solución: Utilizar includes
Al igual que todos los problemas de n+1 este lo podemos resolver ocupando includes.
class UsersController < ApplicationController def index @users = User.includes(:tweets) end end
Includes permite precargar los datos de la tabla de tweets en memoria, eso lo podemos ver en la siguiente imagen, donde se gatilla una consulta nueva y se seleccionan todos los tweets de los usuarios involucrados
Sin embargo, si revisamos debajo de la precarga de tweets, veremos que todavía tenemos el mismo problema, y esto se debe al count que tenemos en la vista siempre gatilla una consulta count SQL nueva, en lugar de count deberíamos ocupar length, o size.
En cambio si ocupamos size o length, obtendremos lo siguiente:
¿Cuál es la diferencia entre count, length y size en Rails?
Vamos a partir explicando que no es lo mismo hablar de esto en rails que en ruby, en rails count realiza siempre un query sql para contar los elementos, length en cambio sólo cuenta si ya la tabla está precargada y si no trae la información a memoria, y size determina de forma automática cual ocupar.
Length parece una muy buena alternativa pero debemos tener cuidado de siempre ocuparlo en conjunto con includes de lo contrario en bases de datos muy grandes podríamos terminar trayendo centenas de miles de datos que pueden ocupar gigas de memoria, y por regla general SQL es más eficiente que rails para contar datos y de esa forma traer a memoria sólo lo que necesitamos.
Para ejemplificar el caso anterior, este screenshot muestra una vista ocupando length, sin embargo en el controller no se especifica el includes, y como resultado generamos un query para traer los tweets del usuario a memoria para contarlos dentro de ruby, se imaginan lo ineficiente que sería esto en un Twitter real, con 5000 tweets por usuario?
Si las consultas están paginadas, o los resultados son pocos entonces no hay problema alguno y podemos ocupar length, pero por regla es mejor ocupar size, la eficiencia que se logra ocupando length en lugar de size es bastante baja, pero siempre corresponde medir.
¿Entonces que sucede si ocupamos include sin size?
Obtenemos lo mismo que ocupando count, puesto que size determina de forma inteligente que hacer.
Resumen:
Count | Length | Size | |
---|---|---|---|
Sin includes | Gatilla siempre una consulta SQL count, buena opción de ser necesario | Trae todos los tweets a memoria, mala opción | Utiliza Count |
Con includes | Gatilla siempre una consulta SQL count, mala opción, ya los tenemos en memoria | Ocupa los tweets que ya tenemos en memoria para contar | Utiliza Length |
Otra opción:
Utilizar counter cache, para contar los tweets, es una buena solución en general con excepción de que la tasa de ingresos de elementos de la base de datos sea muy alta, en especial si es mucho más alta que la de consulta. Además este método no permite resolver otro tipo de operaciones especiales, claro resuelve la cuenta pero en algunos casos nosotros queremos el promedio o la suma.
En caso de ocupar Counter Cache deberíamos ocupar el método size ya que este automáticamente ocupa la cuenta si existe.
la documentación oficial explica como implementar esta solución http://guides.rubyonrails.org/association_basics.html
Si te gustó el artículo, puedes ver la segunda parte, que utilizaremos la aprendido para crear gráficos con ruby on rails.