Back-End

29 out, 2010

Acelerando o Ruby on Rails

Publicidade

Ruby on Rails, uma estrutura de desenvolvimento Web popular com base na
linguagem de programação Ruby, torna fácil acessar seu banco de dados,
mas nem sempre o faz de maneira eficiente. Saiba mais sobre problemas
comuns de desempenho com o Rails e descubra como corrigi-los neste artigo.

Apresentação

A linguagem Ruby é frequentemente citada por sua flexibilidade. É
possível, como disse Dick Sites, “escrever programas para escrever
programas”. O Ruby on Rails estende a linguagem principal Ruby, mas o
Ruby em si torna aquela extensibilidade possível.

O Ruby on Rails usa a
flexibilidade da linguagem para facilitar escrever programas altamente
estruturados sem muito código ou padronização extra: você obtém uma
grande quantidade de comportamento padrão sem trabalho extra. Apesar de
este comportamento gratuito nem sempre ser perfeito, obtém-se muita
arquitetura boa com seu aplicativo sem muito trabalho.

Por exemplo, o Ruby on Rails é baseado em um padrão
Model-View-Controller (MVC), o que significa que a maioria dos
aplicativos Rails é dividida de forma limpa em três partes. O modelo
contém o comportamento necessário para gerenciar os dados de um
aplicativo. Tipicamente, em um aplicativo Ruby on Rails, há um
relacionamento 1:1 entre os modelos e as tabelas de banco de dados.

O ActiveRecord,
o object-relation mapping (ORM) que o Ruby on Rails usa por padrão,
gerencia a interação do modelo com o banco de dados, o que significa que
o programa Ruby on Rails médio tem pouca, se tiver, codificação SQL.

A
segunda parte, a visualização, consiste no código que cria a saída
enviada ao usuário – tipicamente consiste de HTML, JavaScript etc. A
parte final, o controlador, transforma a entrada do usuário em chamadas
para os modelos corretos e, a seguir, renderiza a resposta usando as
visualizações apropriadas.

Proponentes do Rails frequentemente citam este paradigma MVC ?
juntamente com outros benefícios do Ruby e do Rails ? como aumentando
sua facilidade de uso, alegando que menos programadores podem produzir
mais funcionalidade em menos tempo. Isto, é claro, significa mais valor
de negócio para cada dólar de desenvolvimento de software, portanto o
desenvolvimento em Ruby on Rails tornou-se significativamente mais
popular.

No entanto, o custo inicial de desenvolvimento não é a figura
completa. Existem outros custos contínuos, como custos de manutenção e
custos de hardware para executar o aplicativo.

Desenvolvedores do Ruby
on Rails frequentemente usam testes e outras técnicas de desenvolvimento
ágil para manter os custos de desenvolvimento baixos, mas pode ser
fácil dar muito menos atenção à execução eficiente de seu aplicativo
Rails com grandes quantidades de dados. Apesar de o Rails tornar fácil
acessar seu banco de dados, nem sempre ele o faz de forma tão eficiente.

Por que os aplicativos Rails executam lentamente?

Aplicativos Rails podem executar lentamente por alguns motivos
fundamentais. O primeiro é simples: O Rails faz pressuposições para
acelerar o desenvolvimento. Normalmente, essas pressuposições são
corretas e úteis. Mas elas não são sempre benéficas para o desempenho e
podem resultar em um uso ineficiente de recursos ? particularmente
recursos de banco de dados.

Por exemplo, ActiveRecord seleciona todos os campos em uma consulta por padrão, usando uma instrução SQL equivalente a SELECT *. Em situações com um grande número de colunas ? particularmente se algumas forem campos grandes VARCHAR ou BLOB ? este comportamento poderá ser um problema significativo em termos de uso de memória e desempenho.

Outro desafio significativo é o problema N+1, que este
artigo examina em detalhes. Essencialmente, isto resulta em várias
consultas pequenas sendo realizadas, em vez de uma consulta grande.

ActiveRecord
não tem como saber, por exemplo, que um registro filho está sendo
solicitado para cada conjunto de registros pai, portanto ele produzirá
uma consulta de registro filho para cada registro pai. Por causa do
custo adicional por consulta, este comportamento pode causar problemas
significativos de desempenho.

Outros desafios estão relacionados mais intimamente a hábitos e
atitudes de desenvolvimento de desenvolvedores de Ruby on Rails. Como o ActiveRecord
facilita tantas tarefas, os desenvolvedores Rails podem muitas vezes
criar um pensamento de que “SQL é ruim”, desprezando o SQL mesmo quando
seu uso faz mais sentido.

Criar e manipular grandes quantidades de
objetos ActiveRecord pode ser lento, portanto, em alguns
casos, poderá ser muito mais rápido escrever uma consulta SQL
diretamente que não instancie nenhum objeto.

Como o Ruby on Rails é frequentemente usado para reduzir o tamanho
de equipes de desenvolvimento, e como os desenvolvedores de Ruby on
Rails frequentemente realizam algumas das tarefas de administração de
sistemas necessárias para implementar e manter seus aplicativos em
produção, o conhecimento limitado sobre seu ambiente poderá causar
problemas.

Configurações de sistema operacional e banco de dados poderão
não ser definidos corretamente. Apesar de não serem ideais, as
configurações MySQL my.cnf são frequentemente deixadas como padrão em
implementações do Ruby on Rails, por exemplo. Adicionalmente, poderá não
haver ferramentas suficientes de monitoração e avaliação de desempenho
para desenvolver um panorama de desempenho em longo prazo.

Esta não é
uma crítica aos desenvolvedores Ruby on Rails, é claro. É simplesmente
uma consequência na falta de especialização; em alguns casos,
desenvolvedores do Rails podem ser especialistas em ambas as áreas.

Uma questão final é que o Ruby on Rails encoraja programadores a
desenvolver em um ambiente local. Fazer isto tem uma série de benefícios
? como menor latência de desenvolvimento e maior distribuição ? mas
isto significa que é possível trabalhar com um conjunto de dados
limitado por causa do tamanho menor das estações de trabalho.

A
diferença entre como eles desenvolvem e onde o código será implementado
pode ser um grande problema. Pode ocorrer de trabalhar por muito tempo
com um tamanho de dados pequeno em um servidor local sem carga com bom
desempenho para, no final, descobrir que o aplicativo tem problemas
significativos de desempenho com um tamanho maior de dados em um
servidor congestionado.

É claro, existem muitos outros motivos possível para explicar
porque um aplicativo Rails tem problemas de desempenho. A melhor forma
de descobrir que problemas potenciais de desempenho seu aplicativo Rails
tem é verificando as ferramentas de diagnóstico que podem oferecer
medições precisas e repetíveis.

Detectando problemas de desempenho

Uma das melhores ferramentas é o registro de desenvolvimento do
Rails, que reside em cada máquina de desenvolvimento no arquivo de log
development/log. Ela tem várias métricas brutas disponíveis: tempo total
gasto para responder à solicitação, porcentagem de tempo gasto no banco
de dados, porcentagem do tempo gasto gerando a visualização etc. Estão
disponíveis ferramentas para analisar o arquivo de log, como o development-log-analyzer.

Durante a produção, é possível encontrar informações valiosas examinando o mysql_slow_log. Os detalhes completos estão fora do escopo desta discussão, mas é possível saber mais ao final do artigo na seção Recursos.

Uma das ferramentas mais poderosas e úteis é o plug-in query_reviewer.
Este plug-in mostra quantas consultas estão sendo executadas na página e
quanto tempo a página leva para ser gerada. E analisa automaticamente
código SQL que o ActiveRecord gera para problemas
potenciais.

Por exemplo, ele encontra consultas que não usam um índice
MySQL, portanto, se você tiver esquecido de indexar uma coluna
importante e isto estiver causando problemas de desempenho, é possível
encontrá-lo facilmente. O plug-in exibe todas essas informações em um pop-up <div>, que é visível somente durante o modo de desenvolvimento.

Finalmente, não se esqueça de usar ferramentas como Firebug, yslow, Ping e tracert para detectar se seus problemas de desempenho vêm de problemas de carga de ativos ou de rede.

A seguir, lidaremos com alguns problemas específicos do Rails e suas soluções.

O problema da consulta N+1

O problema da consulta N+1 é um dos maiores problemas com
aplicativos Rails. Por exemplo, quantas consultas o código na Listagem 1
produz? Este código é um ciclo simples através de todos os tópicos em
uma tabela hipotética de tópicos, exibindo a categoria e o corpo do
tópico.

Listagem 1. Código Post.all não otimizado

<%@posts = Post.all(@posts).each do |p|%> 
<h1><%=p.category.name%></h1>
<p><%=p.body%></p>
<%end%>

Resposta: O código gera uma consulta mais uma consulta por linha em @posts. Por causa do custo adicional por consulta, este pode ser um desafio significativo. A culpada é a chamada para p.category.name. Esta chamada se aplica somente àquele objeto particular do tópico, não à array @posts inteira. Felizmente, é possível corrigir isto usando carga antecipada.

Carga antecipada significa que o Rails realizará
automaticamente as consultas necessárias para carregar o objeto com
quaisquer objetos filhos especificados. O Rails usará uma instrução SQL JOIN
ou uma estratégia em que várias consultas são realizadas.

No entanto,
presumindo que todos os filhos que forem usados sejam especificados,
isto nunca resultará em uma situação N+1, onde cada iteração em um ciclo produz uma consulta adicional. A Listagem 2 é uma versão do código da Listagem 1 que usa carga antecipada para evitar o problema N+1.

Listagem 2. Código Post.all otimizado com carga antecipada

<%@posts = Post.find(:all, :include=>[:category]
@posts.each do |p|%>
<h1><%=p.category.name%></h1>
<p><%=p.body%></p>
<%end%>

Aquele código gera no máximo duas consultas, não importa quantas colunas existam na tabela tópicos.

É claro, nem todos os casos são tão simples. É mais trabalhoso lidar com situações de consulta N+1 mais complicadas. Vale a pena o esforço? Vamos fazer um rápido teste.

Testando N+1

Usando o script na Listagem 3, é possível descobrir o quão lentas ? ou
rápidas ? as consultas podem ser. A Listagem 3 demonstra como usar o ActiveRecord em um script independente para estabelecer uma conexão com o banco de
dados, definir suas tabelas e carregar dados.

A seguir, a biblioteca
integrada de avaliação de desempenho do Ruby é usada para ver qual
abordagem é mais rápida e o quanto mais rápida ela é.

Listagem 3. Script de referência de carga antecipada

require 'rubygems'
require 'faker'
require 'active_record'
require 'benchmark'

# This call creates a connection to our database.

ActiveRecord::Base.establish_connection(
:adapter => "mysql",
:host => "127.0.0.1",
:username => "root", # Note that while this is the default setting for MySQL,
:password => "", # a properly secured system will have a different MySQL
# username and password, and if so, you'll need to
# change these settings.
:database => "test")

# First, set up our database...
class Category < ActiveRecord::Base
end

unless Category.table_exists?
ActiveRecord::Schema.define do
create_table :categories do |t|
t.column :name, :string
end
end
end

Category.create(:name=>'Sara Campbell\'s Stuff')
Category.create(:name=>'Jake Moran\'s Possessions')
Category.create(:name=>'Josh\'s Items')
number_of_categories = Category.count

class Item < ActiveRecord::Base
belongs_to :category
end

# If the table doesn't exist, we'll create it.

unless Item.table_exists?
ActiveRecord::Schema.define do
create_table :items do |t|
t.column :name, :string
t.column :category_id, :integer
end
end
end

puts "Loading data..."

item_count = Item.count
item_table_size = 10000

if item_count < item_table_size
(item_table_size - item_count).times do
Item.create!(:name=>Faker.name,
:category_id=>(1+rand(number_of_categories.to_i)))
end
end

puts "Running tests..."

Benchmark.bm do |x|
[100,1000,10000].each do |size|
x.report "size:#{size}, with n+1 problem" do
@items=Item.find(:all, :limit=>size)
@items.each do |i|
i.category
end
end
x.report "size:#{size}, with :include" do
@items=Item.find(:all, :include=>:category, :limit=>size)
@items.each do |i|
i.category
end
end
end
end

Este script esta a velocidade de realização de ciclos em 100, 1.000 e
10.000 objetos com e sem carga antecipada usando a cláusula :include.
Para executar este script, poderá ser preciso substituir os parâmetros
apropriados de conexão com o banco de dados, próximos à parte superior
do script, por parâmetros adequados a seu ambiente local.

Também será
preciso criar um banco de dados MySQL chamado test. Finalmente, será preciso os gems ActiveRecord e faker, que podem ser obtidos executando gem install activerecord faker. Executar o script em minha máquina produziu os resultados mostrados na Listagem 4.

Listagem 4. Saída do script de referência de carga antecipada

-- create_table(:categories)
-> 0.1327s
-- create_table(:items)
-> 0.1215s
Loading data...
Running tests...
user system total real
size:100, with n+1 problem 0.030000 0.000000 0.030000 ( 0.045996)
size:100, with :include 0.010000 0.000000 0.010000 ( 0.009164)
size:1000, with n+1 problem 0.260000 0.040000 0.300000 ( 0.346721)
size:1000, with :include 0.060000 0.010000 0.070000 ( 0.076739)
size:10000, with n+1 problem 3.110000 0.380000 3.490000 ( 3.935518)
size:10000, with :include 0.470000 0.080000 0.550000 ( 0.573861)

Em todos os casos, o teste usando :include foi mais rápido ?
especificamente, 5,02, 4,52 e 6,86 vezes mais rápido, respectivamente. É
claro, o resultado exato depende de sua situação particular, mas a
carga antecipada pode claramente levar a ganhos de desempenho
significativos.

Carga antecipada aninhada

E se você quiser referenciar uma relação aninhada ? uma relação de uma
relação? A Listagem 5 demonstra uma situação comum onde tal coisa poderá
acontecer: o ciclo por todos os tópicos com exibição da imagem do
autor, onde o Author tem um relacionamento belongs_to com Image.

Listagem 5. Caso de uso de carga antecipada aninhada

@posts = Post.all	
@posts.each do |p|
<h1><%=p.category.name%></h1>
<%=image_tag p.author.image.public_filename %>
<p><%=p.body%>
<%end%>

Este código sofre do mesmo problema N+1 que antes, mas a
sintaxe para correção não é imediatamente aparente, pois estão sendo
usados relacionamentos de relacionamentos. Como, então, são feitas
cargas antecipadas de relacionamentos aninhados?

A resposta correta é usar uma sintaxe de hash para a cláusula :include. A Listagem 6 fornece um exemplo de tal carga antecipada aninhada usando hashes.

Listagem 6. Solução de carga antecipada aninhada

@posts = Post.find(:all, :include=>{ :category=>[],
:author=>{ :image=>[]}} )
@posts.each do |p|
<h1><%=p.category.name%></h1>
<%=image_tag p.author.image.public_filename %>
<p><%=p.body%>
<%end%>

Como pode-se ver, é possível aninhar literais de hash e array. Note que a
única diferença entre um hash e uma array, neste caso, é que o hash
pode ter subitens aninhados, e a matriz não. Com exceção disso, eles são
equivalentes.

Carga antecipada indireta

Nem todas as instâncias do problema N+1 são tão facilmente percebidas. Por exemplo, quantas consultas a Listagem 7 produz?

Listagem 7. Caso de uso de carga antecipada indireta

<%@user = User.find(5)
@user.posts.each do |p|%>
<%=render :partial=>'posts/summary', :locals=>:post=>p
%> <%end%>

É claro, determinar o número de consultas requer conhecimento do
posts/summary parcial. É possível ver uma parcial na Listagem 8.

Listagem 8. Carga antecipada indireta parcial: posts/_summary.html.erb

<h1><%=post.user.name%></h1>

Infelizmente, a resposta é que a Listagem 7 e a Listagem 8 geram uma consulta extra por linha em post, buscando o nome do usuário ? apesar de o objeto post ter sido gerado automaticamente por ActiveRecord a partir de um objeto User já em memória. Em resumo, o Rails ainda não associa registros filhos a seus pais.

A correção é usar carga antecipada autorreferencial.
Essencialmente, como o Rails recarrega registros filhos gerados por
registros pais, é preciso carregar antecipadamente os registros pais
como se fossem um relacionamento totalmente separado. Ele se parece com o
código na Listagem 9.

Listagem 9. Solução de carga antecipada indireta

<%@user = User.find(5, :include=>{:posts=>[:user]})
...snip...

Apesar de não ser intuitiva, esta técnica funciona de forma muito
parecida com as técnicas acima. Infelizmente, é fácil aninhar de forma
excessiva usando esta técnica, particularmente se for uma hierarquia
complicada.

Casos de uso simples são fáceis, como o mostrado na Listagem 9,
mas aninhamentos pesados poderão causar problemas. Em alguns casos, a
carga excessiva de objetos Ruby poderá, de fato, ser mais lenta do que
lidar com o problema N+1 ? particularmente se algum dos objetos não tiver toda a árvore percorrida. Naquele caso, outras soluções para o problema N+1 poderão ser mais adequadas.

Uma forma de fazer isto é usando técnicas de armazenamento em
cache. O Rails V2.1 tem acesso a cache simples integrado. Usando o Rails.cache.read, Rails.cache.write
e métodos relacionados, é possível criar seu próprio mecanismo de
armazenamento em cache facilmente e o backend poderá ser um backend
simples de memória – um backend baseado em cache ou um servidor com cache
de memória.

No entanto, não é preciso criar sua própria solução de armazenamento em
cache; é possível usar um plug-in pré-construído do Rails, como o
plug-in cache money de Nick Kallen. Este plug-in fornece armazenamento de cache com gravação e é baseado no código em uso pelo Twitter.

É claro, nem todos os problemas do Rails são relacionados ao número de consultas.

Cálculos agregados e agrupamento no Rails

Um problema que poderá ser encontrado envolve trabalho no Ruby que
deveria ser feito pelo banco de dados. Esta é uma prova do quão poderoso
o Ruby é.

É difícil imaginar pessoal reimplementando voluntariamente
partes de seus códigos de banco de dados em C sem um incentivo
significativo, mas é fácil fazer cálculos similares em grupos de objetos ActiveRecord no Rails. Infelizmente, o Ruby é
invariavelmente mais lento que o código de seu banco de dados. Não
realize cálculos usando uma abordagem de Ruby puro, como mostrado na
Listagem 10.

Listagem 10. Forma incorreta de realizar cálculos de agrupamento

all_ages = Person.find(:all).group_by(&:age).keys.uniq
oldest_age = Person.find(:all).max

Em vez disso, o Rails fornece uma série de funções agregadas e de agrupamento. Use-as como mostrado na Listagem 11.

Listagem 11. Forma correta de realizar cálculos de agrupamento

all_ages = Person.find(:all, :group=>[:age])  
oldest_age = Person.calcuate(:max, :age)

Há várias opções para ActiveRecord::Base#find que podem ser usadas para imitar o SQL. Saiba mais na documentação do Rails.

Note que o método calculate funciona com qualquer função agregada válida que seu banco de dados suporta, como :min, :sum e :avg. Adicionalmente, calculate pode usar uma série de argumentos, como :conditions. Consulte a documentação do Rails para obter detalhes.

Mas nem tudo o que é possível fazer no SQL pode ser feito no Rails.
Se os elementos integrados não forem suficientes, use SQL
personalizado.

SQL personalizado com Rails

Suponha que haja uma tabela com uma lista de pessoas, suas profissões,
idades e o número de acidentes nos quais estiveram envolvidas no último
ano. É possível usar uma instrução SQL personalizada para recuperar as
informações, como mostrado na Listagem 12.

Listagem 12. SQL personalizado com ActiveRecord exemplo

sql = "SELECT profession,
AVG(age) as average_age,
AVG(accident_count)
FROM persons
GROUP
BY profession"

Person.find_by_sql(sql).each do |row|
puts "#{row.profession}, " <<
"avg. age: #{row.average_age}, " <<
"avg. accidents: #{row.average_accident_count}"
end

Este script produziria resultados como os da Listagem 13.

Listagem 13. SQL personalizado com ActiveRecord saída

Programmer, avg. age: 18.010, avg. accidents: 9
System Administrator, avg. age: 22.720, avg. accidents: 8

É claro, este é um caso simples. Pode-se imaginar, no entanto, como é
possível estender este exemplo para instruções SQL de qualquer
complexidade. É possível executar também outros tipos de instruções SQL,
como instruções ALTER TABLE, usando o método ActiveRecord::Base.connection.execute, como mostra a Listagem 14.

Listagem 14. SQL personalizado não buscador com ActiveRecord

ActiveRecord::Base.connection.execute "ALTER TABLE some_table CHANGE COLUMN..."

A maioria das manipulações de esquema, como adicionar e remover colunas,
pode ser feita usando os métodos integrados do Rails. Mas a habilidade
de executar código SQL arbitrário está disponível, se necessário.

Conclusão

Como todas as estruturas de trabalho, o Ruby on Rails pode sofrer com
problemas de desempenho sem o cuidado e a atenção adequados. Felizmente,
as técnicas corretas para monitorar e corrigir estes desafios são
relativamente simples e fáceis de aprender, e mesmo problemas complexos
podem ser solucionados com alguma paciência e o conhecimento da origem
dos problemas de desempenho.

Recursos

Aprender

Obter produtos e tecnologias

Discutir

***

artigo publicado originalmente no developerWorks Brasil, por David Berube


David Berube é um consultor, palestrante e o autor de Practical Rails Plugins, Practical Reporting with Ruby and Rails e Practical Ruby Gems. É possível entrar em contato com ele em info@berubeconsulting.com.