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:

Fig1: Select con count

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

Includes más count

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:

includes con size

¿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.

length sin includes

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?

size sin includes

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.