Memcached
A solução mais simples que eu pensei foi em colocar o memcached na frente disso. Num Ubuntu para instalar o daemon basta fazer:
1 |
sudo apt-get install memcached libsasl2-dev |
E no Mac, se você estiver usando o Homebrew, basta fazer:
1 |
brew install memcached |
Daí precisa instalar a gem:
1 |
gem install memcached |
Agora no config/environment.rb coloque:
1 2 3 4 |
config.gem 'memcached' ... end require 'digest/sha1' |
E no seu config/environments/production.rb coloque:
1 2 |
require 'memcached' config.cache_store = :mem_cache_store, Memcached::Rails.new |
Isso habilita o cache via Memcached. Em ambiente de desenvolvimento e teste, ele vai armazenar tudo em memória mesmo, e em produção vai usar o Memcached. Desde o Rails 2.3 o sistema de cache foi abstraído e você pode escolher diversos tipos de armazenadores como memória, arquivo, o próprio memcached e outros. Todos são gerenciados através de uma API única, a partir de Rails.cache.
Agora, basta fazer algo assim no controller:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def show # Parte 1 cache_key = Digest::SHA1.hexdigest("post_#{[:year, :month, :day, :slug].collect {|x| params[x] }.join('_')}") etag = Rails.cache.read(cache_key) options = { :public => true } if etag fresh_when( :etag => etag, :public => true ) unless Rails.env.development? options = {} end # Parte 2 unless request.fresh?(response) @post = Post.find_by_permalink(*([:year, :month, :day, :slug].collect {|x| params[x] } << {:include => [:tags]})) etag = @post.updated_at.to_i Rails.cache.write(cache_key, etag, :expires_in => 1.day) fresh_when( options.merge(:etag => etag, :last_modified => @page.updated_at.utc) ) unless Rails.env.development? end end |
Existem 2 partes nessa lógica. Na primeira, montamos a chave do cache, que tem que ser única para cada elemento – no caso, um post – que você quer gerenciar. Em especial para meu blog eu monto uma chave baseada nos parâmetros que vem na requisição. Ou seja se o usuário mandar a URL “/2010/01/01/foo” ele vai montar a chave “post_2010_01_01_foo”. Daí ele faz um Rails.cache.read para ver se já existe um ETAG armazenado no memcached. Se já existir ele vai tentar chamar o fresh_when para ver se pode já só enviar o cabeçalho 304.
Na parte 2 ele checa request.fresh?(response). Se voltar falso quer dizer que o navegador do usuário mandou um ETAG diferente do que temos armazenado no memcached, ou seja, provavelmente o post foi atualizado. Então temos que mandar uma versão nova. Daí ele faz a lógica normal de procurar pelo post, gerar o ETAG. Daí ele grava o ETAG novo no cache, pra garantir, e também manda esse novo ETAG no cabeçalho de volta ao navegador. Da próxima vez, o navegador vai mandar o novo ETAG e daí vai receber de volta apenas o cabeçalho 304. Além disso também estou configurando o cabeçalho “Last-Modified” para facilitar o cache da página.
Um pequeno detalhe é a hash options. Logo na parte 1 notem que eu faço isto:
1 2 3 4 5 |
options = { :public => true } if etag fresh_when( :etag => etag, :public => true ) unless Rails.env.development? options = {} end |
E mais abaixo eu faço isto:
1 |
options.merge(:etag => etag, :last_modified => @page.updated_at.utc) |
Isso porque na implementação do método fresh_when tem um trecho que é assim:
1 2 3 4 |
if options[:public] ... cache_control << "public" end |
Ou seja, se eu chamar o método fresh_when múltiplas vezes com a opção :public => true, ele vai ficar adicionando na lista cache_control e daí no cabeçalho Cache-Control vai voltar uma string tipo public, public. Então, se o fresh_when já foi chamado no começo, na segunda vez eu tomo o cuidado de não passar o :public de novo.
Finalmente, no administrador de posts, eu invalido o cache caso eu atualize ou apague um post. Assim:
1 2 3 4 5 6 7 8 9 10 11 |
class Admin::PostsController < Admin::BaseController after_filter :clean_cache, :only => [:create, :update, :destroy] ... protected def clean_cache Rails.cache.delete(Digest::SHA1.hexdigest("post_#{@post.permalink.gsub("/", "_")}")) Rails.cache.delete("recent_posts_etag") end end |
No caso faço um after_filter que vai rodar só depois dos métodos create, update e destroy. No caso ele apaga o ETAG do post (método show do controller público) que tem aquele formato “post_2010_01_01_foo” (no caso eu criei um método chamado permalink no model Post que formata isso) e também deleta o ETAG no caso da página principal (que eu guardo com a chave recent_posts_etag), que busca vários posts. Eu assumo que se um post mudar, é melhor recriar a homepage inteira porque não sei se esse post aparece listado lá ou não. Poderia ter uma lógica melhor, mas considerando que eu não fico atualizando ou deletando posts o tempo todo, isso deve bastar.
Meu blog é bem simples, mas se o administrador fosse mais complexo e precisasse invalidar o cache a partir de múltiplos pontos, o melhor é criar um Observer que centraliza a lógica e invalidação do cache de ETAGs.
Sumarizando
Não é um processo complicado mas precisa entender direito para que serve um ETAG e como funciona um cache. O uso do cache em si é bem simples, basicamente você lê (read) ou grava (write) nele a partir do objeto Rails.cache. Agora o importante é saber quando você invalida esse cache.
Logo que o servidor sobe, o cache está vazio, então ele precisa ir ao banco o tempo todo para cada requisição nova. Porém, é só a primeira vez porque a partir de agora ele vai armazenar o ETAG gerado no memcached e depois de 1 dia ou menos, a maioria dos meus posts já estarão com suas ETAGs no cache. Então o MySQL vai basicamente ficar sentado sem fazer nada – que é o ideal.
Então, minha otimização fez duas coisas: na primeira versão, que só gerava e processava ETAGs, eu economizei o tempo de processamento do ERB e envio do HTML. Agora estou economizando comunicação com o banco de dados, o tráfego dos dados entre o banco e o Rails e a geração dos ETAGs em si. Ou seja, o Rails está fazendo muito pouco, praticamente só servindo como um roteador mesmo.
Isso baixou o tempo de processamento médio de uma requisição do meu Rails de uns 50ms, antes, para algo entre 7ms e 1ms!. Somado ao upgrade de memória de 512 para 768 MB de RAM e de 512 MB para 1 GB de swap, meu blog deve estar preparado para aguentar algumas ordens de grandeza mais tráfego simultâneo.