No final de 2009, fui convidado para formar um novo time de desenvolvimento que ficaria responsável pela versão 2010 do CartolaFC, um Fantasy Game do SporTV. O maior desafio deste novo time não seria o tempo, embora este também seria decisivo, e sim a tecnologia. Não havia ainda na globo.com um projeto com o requisito de ser 100% dinâmico e com a performance exigida. Diversas questões técnicas levaram a um desempenho extraordinário desta nova versão. Porém, acredito que a nossa decisão mais acertada tenha sido a de reescrever do zero a aplicação e redesenhar completamente sua arquitetura para uma melhor utilização do cache em memória e aumento da performance.
Naturalmente, os desenvolvedores fazem o cache das respostas às consultas aos recursos externos (banco de dados, APIs etc). Dependendo das regras de negócio da aplicação, isso pode até fazer sentido. Consultas simples a objetos sem relacionamento com outros ou que sofram pouca atualização entram nesse universo. Porém, quando temos consultas mais complexas e que são mapeadas em diversos objetos, esse pattern deve ser evitado por dois motivos principais: otimização do uso da memória ocupada pelo cache e performance na expiração ou regeração do conteúdo.
Cenário
Quando um usuário do CartolaFC visualiza a escalação do seu time, diversos jogadores são exibidos. Na abordagem tradicional, faríamos uma query que obteria todos os jogadores escalados, o status de cada jogador (lesionado, suspenso etc) e seu valor (em cartoletas). O resultado desta query seria guardado no cache para um acesso futuro. Quando um segundo usuário acessar sua escalação, este mesmo processo ocorrerá e uma nova entrada de cache será gerada. Parece razoável, não? E se os dois usuários possuírem a mesma escalação? Extrapole esse processo para toda a base de usuários do jogo e o desastre será iminente.
Solução de Cache
Na nossa abordagem, a query obtém e faz o caching apenas da lista com os IDs dos jogadores. Para cada ID, há um objeto <jogador> correspondente armazenado no cache. Assim, o cache de objetos do tipo <jogador> ocupará um espaço fixo na memória e, se aplicamos essa mesma regra a todos os possíveis objetos do Cartola (jogadores, usuários, ligas etc). E mais: o tamanho do cache só aumentará quando um novo usuário entrar no jogo, já que teremos novas listas de jogadores escalados, amigos e ligas. Dessa forma, chegamos ao primeiro (e principal) benefício desta abordagem: uso otimizado de memória.
Este processo também aumenta a performance da aplicação ao efetuar o expurgo e regeração das entradas de cache de forma mais restritiva. Quando, por exemplo, o valor de um atleta é alterado após o fechamento da rodada, na abordagem tradicional deveríamos expirar o cache de cada um dos milhões de times escalados. Na nova abordagem, apenas o cache do objeto <jogador> será expurgado e todas as escalações, que são apenas referências para este objeto, serão atualizadas. Uma lista de IDs só será expurgada quando um novo objeto for criado ou apagado do sistema.
Discutidas as vantagens da arquitetura otimizada de cache, preciso revelar algumas desvantagens (ou, talvez, apenas boas práticas). A primeira: o desenvolvedor deve ter muito cuidado ao escrever o código. A programação orientada a cache possui um paradigma bastante diferente da “sem cache”. Para ilustrar essa afirmação, imagine uma aplicação sem cache e pense no código que você faria para obter todos os jogadores cadastrados no CartolaFC. Pense, então, no código para obter apenas os jogadores lesionados. Muito provavelmente, no primeiro caso seria algo como “select * from jogadores” e, no segundo, algo como “select * from jogadores where <sua cláusula aqui>. Em uma abordagem com caching, como a descrita neste artigo e utilizada no CartolaFC, a resposta seria realizar um loop for sobre a lista de IDs obtidas com o select sobre toda a base de dados e, para cada ID, verificaríamos o status do <jogador> correspondente. Ou seja, um filtro aplicado nos elementos do cache e não do banco de dados.
A segunda desvantagem é o aumento da complexidade do código uma vez que o controle de expurgo dos objetos deve ser feito pelo desenvolvedor. No CartolaFC, desenvolvemos um middleware baseado nos decorators do Python em que o código @cached sinaliza que a resposta daquele método deve ser criada no cache. Esse middleware também foi utilizado para estender os métodos CRUD da ORM e, a cada ação realizada, uma operação era realizada no cache.
Uma última dica: dê preferência a um sistema de caching distribuído, como o memcached ou o redis. Isso evita que a replicação do cache em cada instância da sua aplicação. Deixe que suas APIs se preocupem em encontrar a chave desejada. E, muito importante, escolha uma ORM que lhe ajude nessa arquitetura.
[Crédito da imagem: Performance – ShutterStock]