Como evitar que a sua aplicação Rails seja derrubada pelo dog-pile effect

Todo mundo já ouviu falar de escalabilidade. Todo mundo também já ouviu falar de Memcached. O que nem todos ouviram falar ainda é do dog-pile effect e de como evitar esse problema. Mas antes de discutirmos sobre o que é o dog-pile effect, vamos primeiro dar uma olhada em como é simples usar o Memcached com o Rails.

Rails com Memcached

Se você nunca usou o Memcached com Raills ou ainda não conhece muito sobre escalabilidade com Rails, os episódios do Scaling Rails feitos pelo Gregg Pollack são extremamente recomendados, especialmente o episódio sobre Memcached.

Dado que você tem o Memcached instalado e quer usá-lo dentro do seu aplicativo Rails, basta você ir em um dos arquivos de configuração, por exemplo o production.rb e colocar:

config.cache_store = :mem_cache_store

Por padrão, o Rails vai procurar por um processo do Memcached rodando no localhost na porta 11211.

Mas pra quê eu vou querer usar o Memcached? Bem, digamos que o seu aplicativo possui alguma página que precise rodar uma query lenta no banco de dados, tal como uma query para gerar um ranking de blog posts baseados na influencia dos autores, que demora em média 5 segundos. Nesse caso, toda vez que um usuário entrar nessa página do seu aplicativo, essa query será executada e o aplicativo terá um tempo de resposta muito alto.

Como você não vai querer que todo usuário sofra esperando 5 segundos para ver o ranking, o que você faz? Armazena o resultado dessa query no Memcached. Assim, uma vez que o resultado da query estiver cacheado, seus usuários não terão que esperar pelos malditos 5 segundos!

O que é o dog-pile effect?

Legal, você está cacheando o resultado da query do ranking e você finalmente vai poder dormir a noite. Será mesmo?

Vamos supor que você está expirando o cache baseado no tempo, ou seja, digamos que a query cacheada deva expirar após 5 minutos. Vejamos como esse processo funciona diante de dois cenários:

Um único usuário acessando depois do cache ficar obsoleto (stale):

Nesse primeiro cenário, quando um usuário acessar a página do ranking, a query deve ser executada novamente, fazendo com que a CPU do seu sistema tenha um pico de consumo para conseguir processá-la. Depois dos 5 segundos, seu usuário, poderá visualizar o ranking. Até aqui, ok, demorou, mas o usuário conseguiu ver o ranking e seu sistema (ainda) está de pé.

N usuários acessando depois do cache ficar obsoleto (stale):

Digamos que, em um dado horário, essa página do seu aplicativo tenha em média 4 requests/segundo. Nesse cenário, entre a chegada do primeiro request e o banco de dados retornar o resultado da query, passarão 5 segundos e ocorrerá algo em torno de 20 requests. O problema é, todos esses 20 requests irão dar cache miss e seu aplicativo executará a query em todos os casos, consumindo muitos recursos de CPU e memória. Esse é o dog-pile effect.

Dependendo de quantas requests resultarem em cache miss, do quão pesado for o processamento de sua query e dos recursos de infra disponíveis, o dog-pile effect pode acabar derrubando sua aplicação!

Caramba!!! Quer dizer que o dog-pile effect é tão sério assim e pode chegar a derrubar minha aplicação!? Sim, isso pode acontecer. Mas existem algumas soluções para evitarmos esse tipo de problema. Vamos dar uma olhada em uma delas.

O dog-pile effect pode fazer isso com seu aplicativo!

O dog-pile effect pode fazer isso com seu aplicativo!

Como resolver o problema do dog-pile?

Bem, o problema do dog-pile effect é que deixamos mais de um request executar o custoso cálculo da query. Hum… e se isolarmos esse cálculo para apenas o primeiro request e servir o antigo cache para os próximos requests até que o resultado cacheado da nova query esteja disponível? Parece uma boa idéia… Então vamos implementá-la!

Desde o Rails 2.1, temos uma API padrão para acessar o cache dentro do Rails definida por uma classe abstrata chamada ActiveSupport::Cache::Store. Você pode ver mais dessa API nesse post ou nesse ótimo episódio do railscast.

O código abaixo simplesmente cria um novo cache store baseado no já existente MemCacheStore:

module ActiveSupport
  module Cache
    class SmartMemCacheStore < MemCacheStore

      alias_method :orig_read, :read
      alias_method :orig_write, :write

      def read(key, options = nil)
        lock_expires_in = options.delete(:lock_expires_in) if !options.nil?
        lock_expires_in ||= 10

        response = orig_read(key, options)
        return nil if response.nil?

        data, expires_at = response
        if Time.now > expires_at && !exist?("lock_#{key}")
          orig_write("lock_#{key}", true, :expires_in => lock_expires_in)
          return nil
        else
          data
        end
      end

      def write(key, value, options = nil)
        expires_delta = options.delete(:expires_delta) if !options.nil?
        expires_delta ||= 300

        expires_at = Time.now + expires_delta
        package = [value, expires_at]
        orig_write(key, package, options)
        delete("lock_#{key}")
      end
    end
  end
end

A idéia do código acima é a seguinte:

  1. Digamos que o resultado da sua query já está cacheada;
  2. Durante os 5 minutos seguintes, todas as requests resultarão em cache hit;
  3. Depois dos 5 minutos, o primeiro request que chegar vai ver que o resultado cacheado já está stale, ou seja, obsoleto (linha 17). Esse request vai criar um lock para apenas ele ter direto de calcular o novo valor a ser cacheado;
  4. Durante os próximos 5 segundos, estará sendo feito o cálculo da query, e todos os requests que chegarem, ao invés de resultarem em cache miss, vão resultar em cache hit (devido ao lock), devolvendo o último valor cacheado (linhas 17 e 21);
  5. Após o primeiro request acabar de fazer o cálculo da query, ele vai cachear o novo valor e apagar o lock que tinha criado (linhas 31 e 32);
  6. Todas os novos requests que chegarem nos próximos 5 minutos verão que o valor cacheado não está “stale”, resultarão em cache hit e retornarão o novo valor cacheado (linhas 17 e 21).

Armadilhas e algumas coisas para se lembrar

Primeiramente, não é recomendado setar o valor :expires_in do seu objeto cacheado:

Rails.cache.write('my_key', 'my_value', :expires_in => 300)

Com a nossa solução, basta que você sete o valor do :expires_delta. Isso porque a aplicação será responsável por expirar o cache, e não mais o Memcached.

Rails.cache.write('my_key', 'my_value', :expires_delta => 300)

Entretanto, existem alguns casos em que o Memcached ainda pode expirar o cache. Quando você inicializa o Memcached, ele aloca por default 64 MB de memória. Digamos que os 64 MB estão cheios, o que ele faz quando você tenta salvar o próximo objeto em memória? Ele usa o algorítmo de LRU e deleta o objeto cacheado menos acessado.

Nos casos em que o Memcached expira o cache por conta própria, há uma possibilidade de termos o dog pile effect novamente. Digamos que o ranking não é acessado por muito tempo e o objeto cacheado é descartado devido ao LRU. Se, de repente, muitas pessoas voltam a acessar a página e ocorrem muitos acessos durante os cincos segundos iniciais em que o ranking é gerado, o efeito pode ocorrer novamente.

Vale a pena ter essa situação em mente na hora que você for dimensionar por exemplo o quanto de memória vai ficar alocado para o seu Memcached.

Já consigo lidar com o dog-pile, posso voltar a dormir!

Resumindo, quando você estiver usando uma estratégia de caching, você provavelmente irá precisar expirar o seu cache. Com o processo de expiração, pode surgir o problema de dog-pile effect. Agora você já tem (mais) uma ferramenta na sua toolbox para resolver esse problema.

Basta colocar o código acima do SmartMemCacheStore na sua aplicação Rails (no diretório lib/ por exemplo), setar no seu production.rb (ou em outro environment.rb que você queira) e configurar a cache store para :smart_mem_cache_store (config.cache_store = :smart_mem_cache_store). Se você usa a interface padrão do Rails para acessar o cache (Rail.cache.read, Rails.cache.write) e arquitetou bem sua estrutura com o memcached, você estará protegido do dog-pile effect.

Curiosidade: de onde vem o termo dog-pile effect? Dog-pile é um termo comum em inglês para se referir a um monte de coisas empilhadas. A idéia da metáfora então seria comparar uma pilha de requests uma em cima da outra, esperando pelo processamento da query custosa.

Um dog-pile de verdade  =p

Um dog-pile de verdade =p

  • http://reporterdesandalias.blogspot.com/ Jessi

    Vou falar do que eu entendo: o visual do blog é o máximo!! Rsrsrs!! Parabéns e obrigada pela visita. Bjs

  • http://reporterdesandalias.blogspot.com/ Jessi

    Vou falar do que eu entendo: o visual do blog é o máximo!! Rsrsrs!! Parabéns e obrigada pela visita. Bjs

  • http://daviscabral.com.br Davis

    Olá,

    Acho que na linha 25 deveria ser:
    expires_delta = options.delete(:expires_delta) if !options.nil?

    Certo?

    Valeu pelo post, abraço!

  • http://daviscabral.com.br Davis

    Olá,

    Acho que na linha 25 deveria ser:
    expires_delta = options.delete(:expires_delta) if !options.nil?

    Certo?

    Valeu pelo post, abraço!

  • Hugo Baraúna

    @Davis obrigado pela correção, já atualizamos. ;)

  • Hugo Baraúna

    @Davis obrigado pela correção, já atualizamos. ;)