Optimización: mejorando las consultas MySQL en Rails (I)

Rails es un gran framework, pero la magia y la ágilidad que te proporciona tiene un precio y no es otro que la velocidad y esto se deriva en problemas de escalabilidad. Para una aplicación con poco tráfico o incluso con un tráfico medio la manera de realizar las consultas en Rails nos puede ser suficiente, pero cuando nos encontramos con una aplicación que soporta un gran volumen de visitas o que maneja una gran cantidad de datos, las consultas “out of the box” a la base de datos se hacen cada vez más lentas hasta llegar a límites insostenibles.

Aquí tenéis algunos consejos para optimizar las consultas a la base de datos y conseguir aumentar considerablemente el redimiento de vuestra aplicación.

Haz pruebas en un entorno real

Las pruebas en local y con una base de datos vacía no sirven de nada. Llena tu base de datos con miles de registros y súbelo al servidor. Haz las pruebas en staging simulando el entorno en el que se ejecutará la aplicación.

Mira la consola y los logs

No cierres la consola donde lances el servidor, verás continuamente que está pasando, las consultas que ejecuta y el tiempo que tardan en ejecutarse. Pega la nariz a la pantalla y revisa linea por linea que está haciendo la aplicación en cada momento.

Reduce el tamaño de los datos que estás accediendo

Extrae de la tabla únicamente aquellos campos que necesitas, normalmente no necesitamos todos los campos de un modelo

User.find(:all) # No
User.find(:all, :select => "user.id, user.name, user.email")

Reduce el número de consultas a las base de datos

Vamos a mostrar diez posts y sus comentarios, la manera “normal” sería

@posts = Post.find(:all, :limit => 10) # 1 consulta
@posts.comments.each do |c| ... end # 10 consultas

Esto se puede mejorar bastante incluyendo los comentarios en la consulta donde sacamos los posts

@posts = Post.find(:all, :limit => 10, :include => [:comment]) # 2 consultas

De esta manera nos hemos ahorrado 9 consultas y no es necesario cambiar ni úna línea de código en las vistas para mostrar los comentarios.

Reduce aún más

Combinado los dos ejemplo anteriores podriamos tener algo como

Post.find(:all, :limit => 10, :select => "post.id, post.title, post.excerpt", :include => [:comment])

Esto está muy bien, hemos reducido considerablemente el numero de accesos a la base de datos, sobretodo si el parámetro :limit es muy grande, pero aún se están cargando todos los campos del modelo asociado, en este caso los somentarios. Podemos utilizar el parámetro :joins para especificar qué campos del modelo asociado queremos

@posts = Post.find(:all, :limit => 10, :select => "post.id, post.title, post.excerpt, comment.body AS comment_body', :joins => [:comment])

Utiliza find_by_sql

Algunas consultas son difíciles de optimizar, pero siempre podemos recurrir al método find_by_sql que nos permitirá realizar las consultas exactamente como las queremos.

User.find_by_name("Pepe") # No
User.find_by_sql(SELECT user.* WHERE user.name = 'Pepe')

No utilices consultas complejas, mejor varias consultas simples

No os volváis unos talibanes de la optimización y pretendáis hacerlo todo con una consulta enorme, a veces es mejor realizar más consultas pequeñas, ya que aunque se realicen varios accesos a la base de datos, la suma de los tiempos de latencia puede ser menor que el tiempo que tarda en ejecutarse una mega-consulta.

stuff = Stuff.find_by_sql("SELECT everything.* FROM everything JOIN (box_1, box_2) ON (everything.id = box_1.everything_id box_2.box1_id = box_1.id") # Consulta lenta

# Alternativa
boxes = Boxes.find_by_sql("SELECT box_1.everything_id FROM box_1 JOIN box_2 ON box_2.box1_id = box_1.id")

boxes.each do |box_1|
stuff = Stuff.find_by_sql("SELECT everything.* FROM everything WHERE id = #{box_1.everything_id}
end

Usa tablas MyISAM en lugar de InnoDB

A no ser que necesites transacciones, bloqueos o el nivel de escrituras en tu aplicación sea muy elevado, usa MyISAM, notarás una mejora considerable del rendimiento. Cuando se realiza una migración en Rails, por defecto se crean tablas InnoDB, esto se puede cambiar en las migraciones de la siguiente manera

def self.up
     create_table :users, :options => 'engine=MyISAM' do |t|
       t.string   :name
       t.string   :surname
       t.string   :email

       t.timestamps
    end
  end

Puedes crear una migración que pase todas tus tablas InnoDB a MyISAM

class ConvertToMyIsam < ActiveRecord::Migration
  def self.up
    execute 'ALTER TABLE users ENGINE = MyISAM'
    execute 'ALTER TABLE posts ENGINE = MyISAM'
    ...
  end

  def self.down
    execute 'ALTER TABLE users ENGINE = InnoDB'
    execute 'ALTER TABLE posts ENGINE = InnoDB'
   ...
  end
end

Utiliza cache de modelos

Rails 2.1 proporciona más opciones de cache que pueden ser muy útiles para reducir el número de consultas. Supongamos que nuestra aplicación muestra siempre los diez usuarios registrados. Continuamente estaremo realizando una consulta como esta

@users = User.find(:all, :limit => 10, :order => "created_at desc")

Utilizando cahé podemos ahorrarnos esta consulta mientras no haya nuevos usuarios registrados. En el modelo User definimos

def self.recent_cached
  Rails.cache.fetch('User.recent_cached') { User.recent }
end

def self.recent
  User.find(:all, :limit => 10, :order => 'created_at desc')
end

Utiliza índices

En todos aquellos campos que no sean índices primarios sobre los cuales estés realizando búsquedas, crea un índice que te permita encontrarlos más rápidamente. Por ejemplo si continuamente buscas usuarios por su nickname

@user = User.find_by_sql(SELECT user.* WHERE user.nickname = 'PepeGrillo')

Crea un índice sobre el campo nickname y verás reducido el tiempo de la consulta considerablemente, sobretodo si tienes 100.000 usuarios. Crea una migración

./script/generate migration add_index_to_user

y añade el índice de esta manera

class AddIndexToUser < ActiveRecord::Migration
  def self.up
    add_index :users, :nickname
end

De momento es todo, espero que estos trucos te sirvan para poder aumentar el rendimento de tu aplicación, pero aún hay más técnicas que puedes utilizar. Las explicaré en un próximo post.

6 Comments to Optimización: mejorando las consultas MySQL en Rails (I)

  1. September 13, 2008 at 09:59 | Permalink

    Justo estaba yo pegándome con la optimización de consultas en el proyecto en el que estoy trabajando ahora y este post me viene de perlas.
    Gracias Emili

  2. September 14, 2008 at 02:23 | Permalink

    Super bien resumido… hemos aprendido bastante con este último megaproyecto, eh?

    Sobre el tema de llenar con muchos datos nuestra base de pruebas, he publicado este post:

    http://www.jaimeiniesta.com/2008/09/13/como-generar-contenido-ficticio-para-tus-aplicaciones/

  3. Malonecab's Gravatar Malonecab
    September 17, 2008 at 12:56 | Permalink

    Con respecto al uso de InnoDB y MyISAM he encontrado este post donde dice que no hay tanta diferencia en la velocidad de consulta entre las tablas de un tipo y la de otro.
    Está claro que la experiencia empírica es la que manda :)

    http://www.forosdelweb.com/f86/faqs-mysql-489891/#post2024134

    Un saludo.

  4. September 18, 2008 at 15:19 | Permalink

    Muy buen post si señor!

    Gracias!

  5. September 25, 2008 at 15:44 | Permalink

    Buen post, sólo dos apuntes:

    - respecto a InnoDB y MyISAM el rendimiento no se nota tanto, y según en qué casos InnoDB es mucho más rápido. Lo interesante es tener un sistema maestro - esclavo y que el maestro sea InnoDB para asegurar transacciones y demás y el esclavo MyISAM para acelerar lecturas.

    - hay que tener mucho cuidado con los :include en una relación has_many, ya que, tomando prestado tu ejemplo, si el post tiene 1000 comentarios, estás haciendo una query que devuelve mil filas, y eso no es nada eficiente. El :include hay que utilizarlo en sentido contrario de la relación (cuando seleccionas los comments haces un include del post al que pertence) o en relaciones 1 - 1.

    Un saludo!