#372 Bullet
- Download:
- source codeProject Files in Zip (55.7 KB)
- mp4Full Size H.264 Video (17.4 MB)
- m4vSmaller H.264 Video (7.75 MB)
- webmFull Size VP8 Video (10 MB)
- ogvFull Size Theora Video (16.6 MB)
Bullet, de Richard Huang, es una gema que sirve para recibir avisos cuando nuestra aplicación realiza consultas ineficientes a la base de datos como por ejemplo una consulta N+1 o cuando falta una columna con una caché de contador. En este episodio vamos a usarla para optimizar una aplicacion Rails.
Optimizando la página de productos
La página muestra los productos por la categoría a la que pertenecen. En esta página hay dos modelos implicados: uno es Category
, que puede tener muchos Products
.
La página sufre del problema de las N+1 consultas como puede verse viendo la traza de Rails que muestra que hacemos una consulta para obtener las categorías y luego una consulta separada para cada recuperar los productos de cada categoría.
Category Load (0.2ms) SELECT "categories".* FROM "categories" ORDER BY name Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 3 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 1 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 5 Product Load (0.2ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 4 Product Load (0.3ms) SELECT "products".* FROM "products" WHERE "products"."category_id" = 2
Este es el problema de las N+1 consultas: hacer una consulta para obtener los padres y luego tantas consultas como hijos para obtener los otros registros. Este tipo de problemas puede pasarse fácilmente por alto, y aquí es donde interviene Bullet. Lo añadiremos al Gemfile
de nuestra aplicación, pero sólo en el grupo de desarrollo, ejecutando bundle
para instalarlo.
gem 'bullet', group: :development
Vamos a activar Bullet en un nuevo fichero inicializador. Dado que no se va a cargar en todos los entornos, primero tenemos que consultar si está definido, en cuyo caso lo activamos e indicamos cómo queremos ser notificados de los problemas que haya en las consultas. Vamos a establecer alert
a true
, lo que nos avisará mediante el navegador.
if defined? Bullet Bullet.enable = true Bullet.alert = true end
Al reiniciar el servidor y recargar la página veremos una alerta en JavaScript que nos indica que Bullet ha encontrado una consulta de tipo N+1, indicando además los pasos para resolver el problema.
Vamos a seguir el consejo de Bullet, obteniendo los productos a la vez que las categorías.
class CategoriesController < ApplicationController def index @categories = Category.order(:name).includes(:products) end end
De esta forma recuperamos los productos de antemano porque ya sabemos que vamos a necesitarlos de todas formas. Al recargar la página ya no saldrá la alerta porque estamos recuperando los datos de manera eficiente. Si miramos en el fichero de trazas veremos que los datos se recuperan con dos consultas, una para las categorías y otra para los productos de dichas categorías.
Category Load (0.2ms) SELECT "categories".* FROM "categories" ORDER BY name Product Load (0.4ms) SELECT "products".* FROM "products" WHERE "products"."category_id" IN (3, 1, 5, 4, 2)
Bullet también puede indicarnos si hacemos esta carga anticipada de forma innecesaria. Supongamos que tenemos que sacar de esta página los listados de productos y queremos llevarlas a la página de cada producto. Eliminaremos el código que lista los productos en la plantilla index
de forma que sólo mostremos información acerca de las categorías.
<h1>Categories</h1> <% @categories.each do |category| %> <div class="category"> <h2><%= link_to category.name, category %></h2> </div> <% end %>
Al recargar la página veremos otra vez la alerta pero esta vez nos indicará que Bullet ha detectado una carga anticipada de datos que luego no se usan.
Esto se corrige al quitar la llamada a includes
en el controlador CategoriesController
.
class CategoriesController < ApplicationController def index @categories = Category.order(:name).includes(:products) end end
Veremos que al cargar la página ya no nos sale la alerta.
Columnas con caché de contador
Bullet también nos puede indicar cuándo deberíamos usar una columna como caché de contador. Supongamos que debajo de los nombres de cada categoría queremos mostrar el número de productos que tiene. Podemos hacerlo así:
<h1>Categories</h1> <% @categories.each do |category| %> <div class="category"> <h2><%= link_to category.name, category %></h2> <%= pluralize category.products.size, "product" %> </div> <% end %>
Al recargar la página, veremos la siguiente alerta:
Esta vez nos indica que deberíamos añadir una columna con una caché de contador. La aplicación tiene que realizar consultas a la base de datos para obtener el recuento de los productos de cada una de las categorías. Esto es similar al problema de las N+1 consultas que vimos antes, y podemos arreglarlo fácilmente incluyendo la opción counter_cache
en la llamada a belongs_to
del modelo Product
.
class Product < ActiveRecord::Base belongs_to :category, counter_cache: true attr_accessible :name, :price, :category_id end
Tenemos que generar una migración para añadir esta columna a la tabla de productos.
$ rails g migration add_products_count_to_categories products_count:integer
Antes de ejecutar la migración la modificaremos para hacer que counter_cache
tenga un valor por defecto.
class AddProductsCountToCategories < ActiveRecord::Migration def change add_column :categories, :products_count, :integer, default: 0, null: false end end
Para que esto funcione con los registros ya existentes tenemos que rellenar dicha columna, por lo que vamos a crear una migración separada para hacerlo.
$ rails g migration cache_product_count
En la migración podemos usar ActiveRecord para rellenar la columna pero en su lugar lo haremos con SQL.
class CacheProductsCount < ActiveRecord::Migration def up execute "update categories set products_count=(select count(*) from products where category_id=categories.id)" end def down end end
Con esto actualizaremos la cuenta de productos para cada categoría. Podemos ejecutar las migraciones con rake db:migrate
y al recargar la página veremos que la alerta ya no sale.
Otras opciones de notificación
Hasta ahora sólo hemos visto una de las maneras que tiene Bullet de enviarnos notificaciones: mediante un mensaje de alerta. Pero hay otras opciones posibles para recibir las notificaciones, y las gestiona la gema Uniform Notifier que es un proyecto muy interesante en sí mismo. Tras probar Bullet en nuestra aplicación podemos cambiar el método de notificación para que sea menos intrusivo, como por ejemplo bullet_logger
, de esta manera podemos seguir desarrollando la aplicación y comprobarlo de vez en cuando para ver si hay problemas con las consultas.
Como con cualquier herramienta de este estilo es muy importante no limitarse a seguir ciegamente las sugerencias que nos de. Si un mensaje de alerta nos indica que tenemos que añadir la carga anticipada de registros no deberíamos simplemente añadirla para quitarnos el mensaje de encima. En ocasiones la carga anticipada puede empeorar el rendimiento por lo que hay que considerar otro tipo de optimizaciones como el uso de cachés. Cuando dudemos, siempre es buena idea realizar pruebas comparativas del rendimiento relativo de cada solución. También debemos tener en cuenta que el entorno de ejecución de nuestro servidor de producción también influye, por ejemplo la latencia de las conexiones de base de datos.