Banco de Dados

20 set, 2010

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

Publicidade

Trabalho na boo-box e temos uma experiência bem interessante 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, 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
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, aqui, abordar particulamente a nossa experiência com o Redis.

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 ou Mac OS X. 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.

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.

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:

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:

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ência 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

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

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. 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

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, difundir o conhecimento criado na empresa e melhorar nossa própria ferramenta. Contribua 🙂