Uma arquitetura escalável para indexação

Publicado por Pedro Brentan no dia dev

Aplicações atuais são obrigadas a oferecer usabilidade simples e intuitiva. Depois da página de pesquisa do Google, é difícil não ter uma barra de pesquisa simples, rápida e funcional. Veja como fizemos para proporcionar essa funcionalidade em nosso sistema. Nos bastidores, utilizamos uma arquitetura escalável para indexação de termos utilizados na busca.

Sempre foi e sempre será(?) assim

Sistemas de computação, programas computacionais ou aplicações - como queira chamar -, na sua essência, são máquinas que têm como finalidade receber uma entrada de dados, efetuar o processamento e oferecer, como saída, os dados processados. Desde a mais simples calculadora digital, capaz apenas de somar dois números, até o mais complexo sistema de previsão do tempo, tudo o que acontece dentro de um ou vários chips de computador é um exemplo de aplicação de máquina de Turing. O que muda - e muito - é a proporção entre as informações encontradas em todas as etapas do processamento e a forma como essas informações são recebidas, processadas e exibidas. Tudo gira em torno de informações e, garantir bom armazenamento, rápido acesso e confiabilidade dessas informações é essencial para qualquer sistema de qualidade.

Evoluindo o sistema junto com seu crescimento

A evolução de um sistema desenvolvido com conceitos lean deve ser sempre “puxada” pela necessidade do cliente - sendo que essa necessidade deve ser real e entregar valor efetivo. Aqui na Resultados Digitais nós levamos a sério essa cultura. Segundo os critérios de um produto desenvolvido nessa filosofia, um sistema deve ser o mais simples possível desde que sua performance seja aceitável.

O RD Station sempre seguiu essa linha. A implementação de features feitas quando tínhamos poucos clientes parece ingênua perante o tamanho atual, mas elas entregavam o valor desejado e tinham uma performance mais que aceitável. Tinham. O aumento no número de clientes e o consequente aumento na carga do sistema em todas as suas frentes fizeram com que muitas funcionalidades, que antes desempenhavam muito bem, começassem a se degradar de forma brutal. O índice começou a sofrer atrasos de vários minutos, até dezenas de minutos, e isso era inaceitável!

Dentre as várias mudanças ocorridas nos últimos meses do RD Station para suportar o crescimento e tornar escalável tarefas cruciais da aplicação, está a nova arquitetura de indexação. Ela foi criada para garantir que nosso índice de documentos esteja sempre atualizado e dando suporte a muitas funções internas.

Onde estávamos antes da mudança

Desde que decidimos manter um índice para efetuar pesquisas de forma rápida e eficiente, optamos por utilizar o elasticsearch como motor de indexação e busca e o found como seu provedor do serviço. Estamos muito satisfeitos com essas escolhas, pois o que mudou foi a necessidade de uso dos recursos. Se antes tínhamos poucas requisições por minuto, hoje temos muitas por segundo - e isso afeta demais a performance do serviço. A infraestrutura oferece opções realmente boas no que diz respeito à resposta que nós queremos, mas nós temos que evoluir e aumentar os recursos conforme a necessidade.

No início do RD Station, a infraestrutura de indexação era totalmente linear, ou seja, conforme aumentava a necessidade de melhora na performance, nós aumentávamos o recurso que cuidava dela. Mas como funcionava essa estratégia? Ela era executada por um algoritmo que roda indefinidamente: pegam-se todos os registros que são necessários pro nosso índice e que possuem criação ou alteração entre a última atualização do índice e o momento em que o algoritmo é executado.

Consegue perceber o problema?

Essa estratégia não é paralelizável, ou seja, quando o volume de atualizações nos registros que precisavam ser indexados aumentou, ficou simplesmente inviável essa tática: tínhamos atrasos muito grandes quando o volume de atualizações crescia.

O que fazer?

Pensando em uma estratégia que fosse realmente escalável, chegamos à conclusão que tínhamos que criar uma forma de paralelizar o processamento de indexação. Para isso, é necessário garantir que a execução de uma rodada de indexação não vá interferir na outra, ou seja, o que for indexado por uma execução não pode estar na outra - e vice-versa.

My Job is my job!

Com essa premissa, decidimos criar background jobs que seriam responsáveis pela indexação, mas o problema da paralelização não se limita a isso: como fazer para rodar esses jobs que seriam responsáveis por um conjunto único de registros a serem indexados?

Primeiro passo

A primeira etapa da garantia de unicidade (garantir que cada registro seria indexado uma, e apenas uma vez, após a criação e qualquer alteração) era encontrar uma forma de monitorar a alteração no registro e fazer com que essa alteração iniciasse o processo de indexação. Para isso, decidimos usar triggers no banco de dados, afinal, se tem uma camada ciente de tudo que acontece nos registros, é o banco de dados. Esse monitoramento é muito eficiente com praticamente nenhum overhead, uma vez que a transação que inicia a indexação é isolada da transação no banco de dados.

Nossa aplicação é um app RubyOnRails, e nosso sistema gerenciador de banco de dados é o PostgreSQL. Nessa plataforma, achamos esse artigo que descrevia exatamente aquilo que precisávamos para disparar o processo de indexação. Adaptamos à nossa necessidade e implementamos um canal que é escrito a cada criação ou alteração do registro e lido pela aplicação.

Alterações feitas, como processá-las?

Com o registro das alterações chegando incessantemente à nossa aplicação, precisamos processá-las e gerar os jobs a serem executados. Para isso, nós utilizamos uma estratégia de inserir os ids dos registros a serem indexados em um conjunto ordenado no Redis. O Redis é um banco de dados NoSQL em memória com a estrutura de chave-valor. Isso significa que a escrita e a leitura nesse banco de dados são extremamente rápidas - justamente o que necessitamos.

Esse conjunto ordenado será preenchido pelos ids dos registros a serem indexados, e a ordem será definida pelo timestamp da alteração. O fato de ser um conjunto garante que teremos apenas uma entrada nele para cada id, e a ordenação faz com que registros que foram alterados antes sejam também indexados antes. Se um registro sofrer duas alterações seguidas, sendo a segunda antes de a primeira ter sido indexada, ele vai ser colocado novamente no final da fila de indexação.

Essa estratégia garante que as alterações agora estejam registradas, e as ferramentas utilizadas garantem que não perderemos nenhum registro de alteração. Agora precisamos “consumir” essa fila e efetuar a indexação de verdade.

Seria x Paralela

Finalmente indexado

Com os ids dos registros na fila, nós agora temos um processo que roda a cada intervalo de tempo parametrizável e que lê um número, também parametrizável, de entradas na fila e cria um job que será executado pelo nosso framework de execução de background jobs. Esse framework atualmente é o Resque, mas migraremos, num futuro próximo, para o Sidekiq.

Sendo assim, caso exista uma carga maior no sistema e a necessidade de acelerar tudo apareça, apenas precisamos alterar os parâmetros bem como o número de workers que executam os jobs.

Resultado

Vínhamos sofrendo bastante com atrasos no índice sempre que a carga era grande. Esses atrasos chegavam a até uma hora nas piores situações, mas todos os dias sofríamos com atrasos de mais de 10 minutos. Após a nova estratégia estar implementada, durante o período de “calibragem” dos parâmetros de indexação, os maiores atrasos chegavam a 3 minutos. Após calibrados, os raros atrasos chegam a 1 minuto. O índice está quase sempre sincronizado. Tudo isso usando uma estratégia que não tem invenções mirabolantes, apenas opções que já estão disponíveis: trigger no banco de dados com listen/notify, redis e resque. E, claro, um pouco de criatividade.

Pedro Brentan

Pedro Brentan

Full Stack Developer

Comentários