Usando ETAG e Memcached

2010 July 06, 05:43 h - tags: beginner rails learning

Atualização 02/07/2010: Eu esqueci que o máximo de caracteres de uma chave de memcached é 250. Como estava gerando chaves dos posts usando os permalinks, obviamente em muitos casos vai dar mais de 250. Então o que eu fiz foi gerar as chaves normalmente, criar um digest SHA1 e truncar até 250. Isso deve resolver. Descobri isso porque estou usando o plugin exception_notifier e hoje comecei a receber dezenas de e-mails com a exception Memcached::ABadKeyWasProvidedOrCharactersOutOfRange. Fica a dica :-)

Aproveitando o episódio de ontem da lentidão do meu site, resolvi fazer um pequeno ajuste adicionando memcached à equação.

Recordando, eu estou usando ETAGs para economizar processamento. Leia meu artigo sobre ETAG para entender do que isso se trata. Basicamente a primeira vez ele vai ao banco, busca os dados, abre o template ERB, gera o HTML e envia de volta ao usuário, o caminho padrão. Com ETAG, na segunda vez ele checa que o dado no banco não mudou e devolve apenas um cabeçalho “304 Not Modified”, evitando o processamento do template ERB e transporte do HTML. Só isso dá uma boa acelerada na requisição.

Porém, eu gero o ETAG usando o campo ‘updated_at’, ou seja, eu preciso acabar indo ao banco e buscar essa informação. Algo parecido com isso:

1
2
3
4
5
def show
  @post = Post.find_by_permalink(*([:year, :month, :day, :slug].collect {|x| params[x] } << {:include => [:tags]}))
  etag = @post.updated_at.to_i
  fresh_when( :etag => etag, :public => true ) unless Rails.env.development?
end

O método find_by_permalink é específico do meu blog (veja o código do Enki para referência). Daí uso o método fresh_when para gerar o cabeçalho caso necessário. O ponto é: ele vai executar o find ao banco em todas as requisições. No caso que aconteceu ontem, com muitas requisições simultâneas, isso pode se tornar um ponto de contenção crítico, mesmo se a query for muito rápida.

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.

Comments

comentários deste blog disponibilizados por Disqus