Usando o banco de dados NoSQL Redis para otimizar sistemas de alta escalabilidade

Postado por

Veja a experiência da boo-box com bancos de dados NoSQL. Os cases abaixo foram apresentados no The Developer’s Conference 2010 e são exemplos reais de como utilizamos o Redis em nosso sistema de tecnologia para exibição de anúncios em múltiplos websites.

Compartilhar estas soluções é uma das maneiras de agradecer à comunidade de desenvolvedores por usarmos software livre[bb], difundir o conhecimento criado na empresa e melhorar nossa própria ferramenta.

Bancos NoSQL, entende-se “Not only SQL”, surgiram da necessidade de escalar bancos de dados relacionais com propriedades ACID em projetos web de alta disponibilidade que operam em larga escala. Suas principais características são alta performance, escalabilidade, fácil replicação e suporte a dados estruturados.

Este rompimento com os padrões SQL causa sempre grande repercussão e muitas discussões carregadas de sentimentos e emoções, mas a verdade é que os bancos de dados[bb] relacionais ainda servem para resolver muitos problemas que nem sempre (veja bem, nem sempre) poderão ser resolvidos com bancos NoSQL, como por exemplo:

  1. Necessidade de forte consistência de dados, tipagem bem definida, etc;
  2. Pesquisas complexas que exigem um modelo relacional dos dados para realizações de instruções e operações de junção, por exemplo;
  3. Dados que excedam a disponibilidade de memória do servidor, por mais que possamos utilizar swap, ninguém quer prejudicar a performance neste caso.

Ao escolher seu banco de dados, o importante é considerar as funções e características específicas do sistema. Os bancos de dados NoSQL podem ser utilizados especialmente para funções descritas neste artigo. Vamos, neste post, abordar particulamente a nossa experiência com o Redis.

Quer ser um ninja na boo-box e melhorar a publicidade online?
Contratamos programador Ruby em São Paulo, de aprendiz a sensei.

Sobre o banco de dados NoSQL Redis

O NoSQL Redis, que atualmente está na versão 2.0.1, é definido como advanced key-value store. Seu código é escrito em C sob a licença BSD e funciona em praticamente todos sistemas POSIX, como Linux[bb] ou Mac OS X[bb]. Ele foi idealizado e executado por @antirez para escalar o sistema da empresa LLOOGG. Hoje o repósitório é mantido por uma imensa comunidade e patrocinado pela VMWARE.

A simplicidade de operar um banco apenas setando o valor e uma chave continua, entretanto, diferente de soluções famosas como o memcached, podemos fazer diversas operações na camada das chaves, além de contar com um punhado de estruturas de dados.

Além de salvar strings na memória, também é possível trabalhar com conjuntos, listas, ranks e números. De maneira atômica, pode-se fazer operações de união, intersecção  e diferenças entre conjuntos, além de trabalhar com filas, adicionando e removendo elementos de maneira organizada.

Assim como outros bancos NoSQL este projeto é completamente comprometido com velocidade, pouco uso de recursos, segurança e opções de configurações triviais para ganhos de escalabilidade. Para manter a velocidade dos dados com garantia de persistência, de tempos em tempos (ou a cada n mudanças) as alterações são replicadas, de maneira assíncrona, da memória RAM para o disco.

Agora, vamos aos cases. Dentro de tantas possibilidades, mostraremos algumas soluções do sistema boo-box utilizando o Redis.

Cases

Armazenamento de sessões de usuários

Este é um modelo muito simples de como utilizar o Redis para salvar as informações da sessão de um usuário.

Para cada sessão, gera-se uma chave que é gravada no cookie do navegador. Com essa chave, o sistema tem acesso a um hash com informações desta sessão: status do login, produtos e publicidades clicadas, preferências de idioma e outras configurações temporais, que perdem a validade após algumas horas.

O benefício de não guardar tais informações de sessão diretamente no cookie é evidente: ganhamos a segurança de integridade dos dados, não correndo o risco de algum usuário malicioso modificá-los. Com o Redis, utilizamos operações simples de get/set para acessar estes dados diretamente da memória do servidor (ou servidores, caso exista mais de um), sem desperdício de recursos, graças ao eficiente sistema de expiração promovida por este NoSQL.

O algoritmo de expiração não monitora 100% das chaves que podem expirar. Assim como a maioria dos sistemas de cache as chaves são expiradas quando algum cliente tenta acessá-la. Se a chave estiver expirada o valor não é retornado e o registro é removido do banco.

Em bancos que gravam muitos dados que perdem a validade com o tempo, como neste exemplo, algumas chaves nunca seriam acessadas novamente consequentemente elas nunca seriam removidas. Essas chaves precisam ser removidas de alguma maneira, então a cada segundo o Redis testa um conjunto randômico de chaves que possam estar expiradas.  O algoritmo é simples, a cada execução:

  1. Testa 100 chaves com expiração setada.
  2. Deleta todas as chaves expiradas.
  3. Se mais de 25 chaves forem inválidas o algoritmo recomeça do 1.

Esse lógica probabilística continua a expirar até que o nosso conjunto de keys válidas seja próximo de 75% dos registros.

Cache de produtos de terceiros

Todo dia a boo-box exibe para a audiência milhões de produtos – de diferentes e-commerces – vinculados ao conteúdo de publishers. Os e-commerces fornecem APIs, e através delas é possível buscar produtos para serem mostrados em nossas vitrines.

Num modelo ideal, cada requisição de uma vitrine boo-box faria contato com as APIs dos e-commerces parceiros, em busca de produtos compatíveis com o conteúdo em questão. Mas no mundo real da publicidade online, velocidade e escalabilidade são premissas essenciais para a qualidade de produto e, portanto, requisições síncronas a tais APIs tornariam o processamento lento demais.

Portanto, essas operações são cacheadas num banco Redis. Separamos os e-commerces em bancos distintos e obtemos os produtos segundo a keyword que foi utilizada nas buscas de todas as vitrines da rede boo-box.

Diagrama de sequência do cache de produtos quando há resultados para a tag solicitada

Execução do cache de produtos quando há resultados para a tag solicitada.

Porém, sempre existe aquele usuário com poucos acessos e com tags que não são tão populares. Neste caso, tentamos fazer a consulta diretamente da API (com um tempo limite pequeno para não complicar o sistema). Caso não encontremos nenhum produto para esta tag, podemos, como já foi dito acima, buscar chaves similares para mostrar nas vitrines deste Publisher, enquanto um evento paralelo é acionado para adicionar esta tag no cache sem restrições de tempo. Assim, em uma próxima visualização, os produtos já estarão quentinhos no cache! Veja como esse luxo pode ser ilustrado:

Diagrama de sequência do cache de produtos quando há resultados para a tag solicitada

Quando não havia resultados para a tag solicitada, buscávamos os produtos na API, entregávamos ao usuário e gravávamos os resultados no cache.

A separação de e-commerce em bancos distintos facilita as operações de busca por keywords. O Redis, por padrão, habilita 16 bancos que podem ser utilizados separadamente e, por consequência, escalados separadamente. Com as keys de um e-commerce isoladas, podemos buscá-las através de padrões parecidos com regexp e, com sabedoria, isso pode ser um excelente recurso, mas também pode ser um problema tendo em vista que a complexidade desta função é O(n) onde n é o numero de chaves no banco utilizado.

Diagrama de sequência dessa funcionalidade:

Diagrama de sequência que exibe produtos similares quando não há resultados para a tag solicitada no cache de produtos

Quando não há resultados para a tag solicitada no cache de produtos, exibimos produtos similares, depois buscamos pelos produtos exatos no e-commerce e os entregamos diretamente do cache na próxima solicitação.

Veja os logs dessa funcionalidade em ação:

merb : worker (port XXXX) ~ DEBUG get similar keys from redis using the_velvet_underground instead underground 0.012
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using pushing_daisies instead push 0.012
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using deborah_secco instead deborah 0.017
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using oracoes_catolicas instead catolica 0.017
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using uma_linda_mulher instead linda 0.012
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using lutaram instead lutar 0.016
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using videogame_wii instead videogame 0.017
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using mini_craque_prostars instead craque 0.017
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using apple_ipod_shuffle_1_gb_silver instead apple 0.005
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using motocultivador_tratorito_branco_diesel instead motocultivador 0.017
merb : worker (port XXXX) ~ DEBUG get similar keys from redis using suporte_para_bicicleta_automovel instead automovel 0.005

Esse cache é muito custoso e não é remontado com facilidade. Portanto, assim como a primeira solução, além da velocidade a persistêcia dos dados em disco é imprescindível. A expiração, neste caso, também é muito importante pois mudanças nos catálogos ou nos preços do produto acontecem com frequência. Mesmo com uma expiração curta, não é interessante esperar que produtos populares como videogames, celulares e afins saiam do cache. Para evitar isto, consultamos, a cada requisição, o tempo de vida restante deste produto no cache. Caso ele esteja próximo de expirar, um evento assíncrono é acionado para atualizar este produto na API do e-commerce em questão. Na apresentação Usando Redis para otimizar o sistema boo-box feita na Campus Party Brasil 2010, mostramos detalhes deste fluxo.

Os resultados desta solução foram supreendentes como mostram as imagens abaixo. Uma queda no tempo de resposta de parte do sistema, uma economia insana de recursos que levou ao desligamentos de servidores e redução de perfil de máquinas, assim como a melhoria da manutenibilidade do processo todo.

Tempo de resposta do sistema

Gráfico do tempo de resposta da aplicação antes e depois da implementação do Cache de Produtos

Mais velocidade no sistema após a implementação do Cache de Produtos.

Gráfico de uso de memória RAM pelo Redis

Todo dia a boo-box exibe milhões (milhões!) de produtos de diversos e-commerces. Com o Redis cacheamos tudo com apenas 300 MB de RAM.

Busca em catálogos de produtos de terceiros

Algumas vezes temos acesso a um catálogos de produtos de um e-commerce por meio de um arquivo XML[bb]. Diariamente, este arquivo é atualizado pelo parceiro, parseado e salvo no Redis do sistema boo-box. Além de armazenados, esses produtos são também organizados para a realização de consultas. Modelar NoSQL é um pouco diferente do que modelar bancos relacionais. Não existem joins ou queries complexas, a estrutura é organizada para a pesquisa que deve ser feita.

Vamos a um exemplo prático:

Supondo que o e-commerce disponibilize o seu catálogo de produtos em um arquivo XML que tem a estrutura abaixo:

<catalogo>
 <produto>
 <cod>G28T49200F2</cod>
 <nome>Quintette Du Hot Club De France DJANGO REINHARDT</nome>
 <preco>51,90</preco>
 <descricao>CD Quintette Du Hot Club De Frace  1938-1939 - Importado Appel Indirect;Billets Doux;Japanese Sandman;Three Little Words;Stompin´ at Decca;Souvenirs;Sweet Georgia Brown;Tornerai (J´attendrai);If I Had You;It Had to Be You;Nocturne;Black and White;Night and Day;Honeysuckle Rose;Swing;I Wonder Where My Baby is Tonight;Why Shouldn´t I?;Them There Eyes</descricao>
 <imagem>http://www.ecomm.com.br/imgs/cds/cover/img2/284922.jpg</imagem>
 <url>http://www.ecomm.com.br/produto/2/284922/</url>
 <categoria>Musica</categoria>
 </produto>
</catalogo>

Dado que o campo “cod” é uma referência única para este produto neste catálogo, poderíamos transformá-lo em chave e salvar um hash com todas as propriedades do produto, como mostra o código abaixo:

  catalogo_xml.each do |product_xml|
    # Uma vez que transformamos o xml do produto em um hash...
    product = parser_to_hash(product_xml)

    # Para cada identificador cod salvamos todas as
    # informações deste produto
    redis[:product].set(product[:cod], Marshal::dump(product))
  end
end
def get_product(cod)
  product = redis[:product].get(cod) unless cod.nil?
  return Marshal::load(product) unless product.nil?
end

Bonito e inútil! Ter um hash referenciado por um id a princípio não ajudaria a fazer buscas, entretanto esse será o nosso banco principal que guardará todas as informações dos produtos. Se pensarmos em como indexar estes produtos por uma busca mais trivial (por exemplo, nome) devemos criar um novo banco e teríamos que alterar o nossa função de parser para organizar estes produtos ou melhorar suas chaves por nome:

catalogo_xml.each do |product_xml|
  # Uma vez que transformamos o xml do produto em um hash...
  product = parser_to_hash(product_xml)

  # Para cada identificador cod salvamos todas as
  # informações deste produto
  redis[:product].set(product[:cod], Marshal::dump(product))

  # Para cada nome (que pode ser repetido no catalogo)
  # adicionamos o cod deste produto em uma estrutura de lista
  redis[:name].addmember(slugfy(product[:nome]), product[:cod])
end

A função slugfy foi utilizada para evitar que a mesma palavra seja tratada diferentemente por conta de caracteres de acento ou em caixa alta. Esse é um ponto crucial que pode aumentar muito a contextualização da busca. Muitos algoritmos linguísticos podem ser úteis neste caso, mas voltando ao ponto deste case, um método simples de busca para essa indexação seria:

def search(keyword)
  keyword = slugfy(keyword.to_s)
  return [] if keyword.empty?
  names = redis.keys("*" + name + "*")
  cods = redis[:name].union(names.join(" "))
  products = []
  cods.each do |cod|
    products << get_product(cod)
  end
  return products
end

Legal! Agora podemos buscar por nome em nosso catálogo, mas essa busca é muito engessada. Para melhorá-la, poderíamos buscar por todas as palavras do produto, esteja ela no título, nome na categoria ou até mesmo na descrição.

Aqui temos um ponto importante. Assim como o uso da função slugfy precisamos definir algumas stopwords para que, nesta função, palavras muito comuns sem valor semântico não atrapalhem a busca. Por stopwords podemos considerar artigos, pronomes, etc.

A estratégia agora é criar um terceiro banco com ids que contenham uma tag específica. Mudaríamos novamente o nosso parser para preencher este banco de busca e definiríamos uma nova função:

catalogo_xml.each do |product_xml|
  # Uma vez que transformamos o xml do produto em um hash...
  product = parser_to_hash(product_xml)

  # Para cada identificador cod salvamos todas as
  # informações deste produto
  redis[:product].set(product[:cod], Marshal::dump(product))

  # Para cada nome (que pode ser repetido no catalogo)
  # adicionamos o cod deste produto em uma estrutura de lista
  redis[:name].addmember(slugfy(product[:nome]), product[:cod])

  # Os campos passiveis de busca deste produtos são transformados
  # em uma grande string e
  raw_tags = [slugfy(product[:nome]),slugfy(product[:categoria]), slugfy(product[:descricao])].join("-")

  # Apos remover palavras que não tem nenhum valor semantico
  # temos as tags deste produto
  tags = remove_stopwords(raw_tags.split("-").uniq)

  # Para cada tag faremos o mesmo que foi feito com o nome
  # salvamos uma lista que organizará todas os produtos que contenham uma tag em comum
  tags.each do |tag|
    redis[:tags].addmember(tag, product[:cod])
  end
end

Para realizar busca com tags, poderíamos fazer uma intersecção entre as tags, dessa forma teríamos os ids que contemplassem esta busca. Vejamos uma maneira simples de fazer isso:

def search_tags(keyword)
  tags = remove_stopwords(slugfy(keyword.to_s).split("-").uniq)
  return [] if tags.empty?
  cods = redis[:tags].inter(tags.join(" "))
  products = []
  cods.each do |cod|
    products << get_product(cod)
  end
  return products
end

Este esboço de algoritmo pode nos dar uma idéia de como modelar NoSQL. Exitem muitas maneiras de melhorar esta busca: quantidade de vezes que a palavra é citada, proximidade de palavras e até mesmo a posição da palavra. Muitos algoritmos de pagerank por exemplo podem nos guiar nessas melhorias.

Um ponto importante, levantado pelo @jdrowell é a utilização da busca por regexp:

redis.keys("*" + name + "*")

Este é sem dúvida o comando que deve ser utilizado com maior cautela, ele pode ser um gargalo devido a sua complexidade. No nosso caso, como a quantidade de keys é fixa, esse comando não pode nos comprometer devido ao tamanho do catálogo de produtos.

Para quem quer se aprofundar neste case, sugiro a leitura do artigo indicado pelo @jdrowell que mostra um case similar de busca de textos utilizando o Redis.

Validação de visualizações e clicks de produtos

Este último é também o mais recente case de utilização do Redis, ainda esta em fase de testes e validação. O adserver da boo-box hoje exibe cerca de 15 milhões de vitrines de produtos e campanhas diariamente.  Todas visualizações e clicks são logadas e, apartir destes logs, exibimos estatísticas para os nossos publishers e anunciantes. Salvamos todos os tipos de informação que podemos: as dimensões das peças, dados sobre o usuário que interagiu com a vitrine como IP, navegador e até mesmo o seu perfil de navegação. Sim, nós gostamos de dados!

Dado o volume de informações, esperar a inserção para pegar um id que possa servir de referência para esta tupla em um banco de dados relacional é muito perigoso, e fazer queries para recuperá-los seria também uma tarefa lenta. Gravar os logs em um banco rápido como o Redis e ainda poder acessá-los diretamente do adserver abre um leque de possibilidades. Podemos por exemplo, confirmar a renderização das vitrines pelo navegador, medir o intervalo entre clicks, controlar a veiculação de campanha no decorrer do dia, entre outras tantas coisas.

Ao colocar pela primeira vez essa feature em produção tivemos que trabalhar muito para – acredite – melhorar o tempo de inserção dos dados das vitrines no Redis. Na verdade, foi necessário tunar diversos pontos da infra estrutura (pois utilizavamos um servidor para diversos fins e tinhamos apenas uma grande instancia do Redis que era utilizado por todas as features). Além do mais, estavamos utilizando uma versão antiga do Redis, com uma política de gravação dos dados da memória para o disco que prejudicava demais a performaçe devido a grande quantidade de alterações. Outro fator relevante para prejudicar a velocidade foi o numeros de bytes por objeto salvo e o tempo de serialização e deserialização deles.

Veja o que modificamos para recolocar esse recurso no ar e melhorar o tempo de inserção chegando a 0.001 milésimos ;)

  1. Isolamos o servidor e habilitamos instâncias diferentes do Redis em diversas portas. Dessa forma toda a memória e processamento ficou dedicada para este serviço e o numero de núcleos do processador pode ser melhor aproveitado, o sistema passou a escalonar o Redis em diversas CPUs paralelamente.
  2. Atualizamos o Redis e modificamos as suas configurações para não gravar os dados em disco tendo em vista que estes dados são voláteis e não são reutilizados.
  3. Otimizamos a maneira como salvamos os dados no Redis. Ao invés de objetos complexos salvamos apenas um hash com as informações essenciais da visualização e modificamos o metodo de serialização de YAML para Marshal.

Com essas modificações o tempo de resposta caiu drasticamente, porém, o uso de recurso ainda é uma questão preocupante. Por mais que a tendência da memória RAM seja ficar a cada dia mais barata, esse recurso ainda é muito custoso. Como a quantidade de clicks é menor que de visualizações, muitos dados são gravados no Redis e nunca acessados (neste caso mais de 99% dos objetos). É fundamental previnir estes problemas configurando adequadamente o uso de swap pelo Redis e limpando os dados antigos para liberar memória. Em casos extremos, podemos monitorar atráves das estátisticas do Redis o processo e automatizar as ações de limpeza da memória, previnindo o uso de swap.

No FAQ do projeto existem dicas preciosas para evitar o disperdício de memória primeramente utilizar diversas instancias de 32 bits ao invéz de uma com 64 bits, modificar variáveis de ambiente e escolher o tipo de dados adequado para a sua aplicação pode ajudar a previnir muita dor de cabeça com este gargalo.

Considerações finais

Estes cases são exemplos bem sucedidos ou promissores do experimento de novas tecnologias no sistema boo-box. Muitas vezes, a tentativa de utilizar uma nova tecnologia não é bem sucedida. Já utilizamos, por exemplo, outros bancos NoSQL, que por muito tempo foram uma boa solução mas passaram a ser um gargalo em um novo contexto.

Muitos são os desafios que enfrentamos para garantir qualidade, velocidade e estabilidades em sistemas altamente escaláveis. Conhecer as novas tecnologias, estudar e aplicar novas soluções é um esforço importante e muitas vezes ainda desconhecido. Como dissemos lá no início, compartilhar soluções é uma das maneiras de agradecer à comunidade de desenvolvedores por usarmos software livre[bb], difundir o conhecimento criado na empresa e melhorar nossa própria ferramenta. Contribua :)

Quer ser um ninja na boo-box e melhorar a publicidade online?
Contratamos programador Ruby em São Paulo, de aprendiz a sensei.

Compartilhe com seus amigos


9 Responses to “Usando o banco de dados NoSQL Redis para otimizar sistemas de alta escalabilidade”

  1. Bruno Souza

    setembro 15, 2010 @ 17:23 - Responder

    Post de grande valia!
    Muito legal o engajamento da boo-box com a comunidade.
    Parabéns e obrigado!!!

  2. Danillo César

    setembro 15, 2010 @ 18:21 - Responder

    Gostei muito da maneira que vocês trabalham usando software livre e compatilhando suas experiências com a comunidade. Vocês são uma das poucas empresas do Brasil que dão vontade de se espelhar. Parabéns.

    Só fiquei curioso em saber qual foram os outros bancos NoSql que vocês usaram antes. =)

    • Marco Gomes

      setembro 15, 2010 @ 18:34 - Responder

      Obrigado pelos elogios Danillo :)

      Antes do Redis usávamos os NoSQL MongoDB, CouchDB e memcached. Conforme o Felipe comentou no texto, eles foram muito importantes em momentos anteriores do sistema e nos ajudaram muito, mas a evolução do negócio gerou um novo contexto e eles passaram a ser o gargalo, por isso, trocamos pra Redis e temos tido excelentes resultados.

  3. Suissa

    setembro 15, 2010 @ 21:25 - Responder

    Ótimo artigo é sempre bom ver a comunidade NOSQL crescendo e tendo bons cases como exemplo como o de vocês. Parabéns.

    Linkei este post no nosqlbr.com.br

    []s

  4. Bruno Soares

    setembro 19, 2010 @ 22:13 - Responder

    Ótimo post! Muito legal a boo-box compartilhar estas informações com a comunidade! É sempre mais difícil encontrar casos reais de uso de novas tecnologias, vlw!

  5. Jeferson Sigales

    outubro 20, 2010 @ 23:03 - Responder

    Sou fã do boo-box desde a epoca de ferramentas testes do marcos.
    Sempre que posso, dou uma passada para ver como andam as coisas.

    Fico extremamente feliz e orgulhoso de ver este trupe crescendo de verdade. Exemplos para a galera.

    Sobre o post, bastante e informação e coisas novas pra mim, acredito que falte um pouco disto pra galera que esta tendo as dores dos crescimento, e isto é muito importante, diria até fundamental.

    Acredito que já passou a época dos brazucas aprenderem com erros, temos que compartilhar boas experiência e cultivar a cultura do inovação, ainda mais agora que ta esta aparecendo bastante projetos bons por ai.

    Duvida: Li a pouco tempo atras um artigo do Marco, onde ele fala sobre não ter medo de desconsiderar tudo que ja foi feito e começar do zero. Estas tecnicas descritas neste post, já são do sistema ‘novo’?

    Obrigado pela atenção.

    Grande abraçao e muito sucesso!

  6. Vinicius Viana

    dezembro 17, 2010 @ 3:08 - Responder

    Muito bom o artigo e aprendizado que foi mostrado ao longo do tempo para otimizar o sistema ;)

Leave a Reply