Como melhorar o desempenho do aplicativo ASP.NET no Web Farm com cache

Publicados: 2022-03-11

Existem apenas duas coisas difíceis em Ciência da Computação: invalidação de cache e nomeação de coisas.

  • Autor: Phil Karlton

Uma breve introdução ao cache

O armazenamento em cache é uma técnica poderosa para aumentar o desempenho por meio de um truque simples: em vez de fazer um trabalho caro (como um cálculo complicado ou uma consulta de banco de dados complexa) toda vez que precisamos de um resultado, o sistema pode armazenar – ou armazenar em cache – o resultado desse trabalho e simplesmente fornecê-lo na próxima vez que for solicitado sem precisar refazer esse trabalho (e pode, portanto, responder tremendamente mais rápido).

Claro, toda a ideia por trás do cache funciona apenas enquanto o resultado que armazenamos em cache permanece válido. E aqui chegamos à parte difícil do problema: como determinamos quando um item em cache se tornou inválido e precisa ser recriado?

O cache é uma técnica poderosa para aumentar o desempenho

O cache de memória ASP.NET é extremamente rápido
e perfeito para resolver o problema de cache de web farm distribuído.
Tweet

Normalmente, um aplicativo Web típico precisa lidar com um volume muito maior de solicitações de leitura do que solicitações de gravação. É por isso que um aplicativo Web típico projetado para lidar com uma carga alta é arquitetado para ser escalável e distribuído, implantado como um conjunto de nós de camada da Web, geralmente chamado de farm. Todos esses fatos têm impacto na aplicabilidade do cache.

Neste artigo, nos concentramos no papel que o cache pode desempenhar para garantir alta taxa de transferência e desempenho de aplicativos da Web projetados para lidar com uma alta carga, e vou usar a experiência de um dos meus projetos e fornecer uma solução baseada em ASP.NET como uma ilustração.

O problema de lidar com uma carga alta

O problema real que eu tive que resolver não era original. Minha tarefa era fazer com que um protótipo de aplicativo web monolítico ASP.NET MVC fosse capaz de lidar com uma carga alta.

As etapas necessárias para melhorar os recursos de taxa de transferência de um aplicativo da Web monolítico são:

  • Habilite-o para executar várias cópias do aplicativo da Web em paralelo, por trás de um balanceador de carga, e atender a todas as solicitações simultâneas de forma eficaz (ou seja, torná-lo escalável).
  • Crie o perfil do aplicativo para revelar os gargalos de desempenho atuais e otimizá-los.
  • Use o cache para aumentar a taxa de transferência da solicitação de leitura, pois isso normalmente constitui uma parte significativa da carga geral dos aplicativos.

As estratégias de cache geralmente envolvem o uso de algum servidor de cache de middleware, como Memcached ou Redis, para armazenar os valores em cache. Apesar de sua alta adoção e aplicabilidade comprovada, existem algumas desvantagens nessas abordagens, incluindo:

  • As latências de rede introduzidas pelo acesso aos servidores de cache separados podem ser comparáveis ​​às latências de acesso ao próprio banco de dados.
  • As estruturas de dados da camada da Web podem ser inadequadas para serialização e desserialização prontas para uso. Para usar servidores de cache, essas estruturas de dados devem oferecer suporte à serialização e desserialização, o que requer um esforço de desenvolvimento adicional contínuo.
  • A serialização e a desserialização adicionam sobrecarga de tempo de execução com um efeito adverso no desempenho.

Todas essas questões foram relevantes no meu caso, então tive que explorar opções alternativas.

Como funciona o cache

O cache de memória interno do ASP.NET ( System.Web.Caching.Cache ) é extremamente rápido e pode ser usado sem sobrecarga de serialização e desserialização, tanto durante o desenvolvimento quanto no tempo de execução. No entanto, o cache de memória ASP.NET também tem suas próprias desvantagens:

  • Cada nó de camada da web precisa de sua própria cópia dos valores armazenados em cache. Isso pode resultar em maior consumo de camada de banco de dados na inicialização a frio ou na reciclagem do nó.
  • Cada nó de camada da web deve ser notificado quando outro nó tornar qualquer parte do cache inválida gravando valores atualizados. Como o cache é distribuído e sem sincronização adequada, a maioria dos nós retornará valores antigos, o que normalmente é inaceitável.

Se a carga de camada de banco de dados adicional não levar a um gargalo por si só, implementar um cache distribuído adequadamente parece uma tarefa fácil de lidar, certo? Bem, não é uma tarefa fácil , mas é possível . No meu caso, os benchmarks mostraram que a camada de banco de dados não deveria ser um problema, pois a maior parte do trabalho acontecia na camada da web. Então, decidi usar o cache de memória ASP.NET e focar na implementação da sincronização adequada.

Apresentando uma solução baseada em ASP.NET

Conforme explicado, minha solução foi usar o cache de memória ASP.NET em vez do servidor de cache dedicado. Isso implica que cada nó do web farm tenha seu próprio cache, consulte o banco de dados diretamente, execute os cálculos necessários e armazene os resultados em um cache. Dessa forma, todas as operações de cache serão extremamente rápidas graças à natureza in-memory do cache. Normalmente, os itens armazenados em cache têm um tempo de vida claro e se tornam obsoletos após alguma alteração ou gravação de novos dados. Portanto, a partir da lógica do aplicativo da Web, geralmente fica claro quando o item de cache deve ser invalidado.

O único problema que resta aqui é que quando um dos nós invalida um item de cache em seu próprio cache, nenhum outro nó saberá sobre essa atualização. Portanto, solicitações subsequentes atendidas por outros nós fornecerão resultados obsoletos. Para resolver isso, cada nó deve compartilhar suas invalidações de cache com os outros nós. Ao receber tal invalidação, outros nós podem simplesmente descartar seu valor em cache e obter um novo na próxima solicitação.

Aqui, o Redis pode entrar em jogo. O poder do Redis, comparado a outras soluções, vem de seus recursos Pub/Sub. Cada cliente de um servidor Redis pode criar um canal e publicar alguns dados nele. Qualquer outro cliente é capaz de ouvir esse canal e receber os dados relacionados, muito semelhante a qualquer sistema orientado a eventos. Essa funcionalidade pode ser usada para trocar mensagens de invalidação de cache entre os nós, para que todos os nós possam invalidar seu cache quando necessário.

Um grupo de nós de camada da Web ASP.NET usando um backplane Redis

O cache de memória do ASP.NET é direto em alguns aspectos e complexo em outros. Em particular, é simples, pois funciona como um mapa de pares chave/valor, mas há muita complexidade relacionada às suas estratégias e dependências de invalidação.

Felizmente, os casos de uso típicos são bastante simples e é possível usar uma estratégia de invalidação padrão para todos os itens, permitindo que cada item de cache tenha no máximo uma única dependência. No meu caso, terminei com o seguinte código ASP.NET para a interface do serviço de cache. (Observe que este não é o código real, pois omiti alguns detalhes para simplificar e a licença proprietária.)

 public interface ICacheKey { string Value { get; } } public interface IDataCacheKey : ICacheKey { } public interface ITouchableCacheKey : ICacheKey { } public interface ICacheService { int ItemsCount { get; } T Get<T>(IDataCacheKey key, Func<T> valueGetter); T Get<T>(IDataCacheKey key, Func<T> valueGetter, ICacheKey dependencyKey); }

Aqui, o serviço de cache permite basicamente duas coisas. Primeiro, ele permite armazenar o resultado de alguma função de obtenção de valor de maneira segura para o encadeamento. Em segundo lugar, garante que o valor então atual seja sempre retornado quando solicitado. Uma vez que o item de cache se torna obsoleto ou é explicitamente despejado do cache, o valor getter é chamado novamente para recuperar um valor atual. A chave de cache foi abstraída pela interface ICacheKey , principalmente para evitar a codificação de strings de chave de cache em todo o aplicativo.

Para invalidar itens de cache, introduzi um serviço separado, que se parecia com isso:

 public interface ICacheInvalidator { bool IsSessionOpen { get; } void OpenSession(); void CloseSession(); void Drop(IDataCacheKey key); void Touch(ITouchableCacheKey key); void Purge(); }

Além dos métodos básicos de soltar itens com dados e tocar em teclas, que tinham apenas itens de dados dependentes, existem alguns métodos relacionados a algum tipo de “sessão”.

Nosso aplicativo da web usou Autofac para injeção de dependência, que é uma implementação do padrão de design de inversão de controle (IoC) para gerenciamento de dependências. Esse recurso permite que os desenvolvedores criem suas classes sem a necessidade de se preocupar com dependências, pois o contêiner IoC gerencia essa carga para eles.

O serviço de cache e o invalidador de cache têm ciclos de vida drasticamente diferentes em relação ao IoC. O serviço de cache foi registrado como um singleton (uma instância, compartilhada entre todos os clientes), enquanto o invalidador de cache foi registrado como uma instância por solicitação (uma instância separada foi criada para cada solicitação recebida). Por quê?

A resposta tem a ver com uma sutileza adicional que precisávamos lidar. A aplicação web está usando uma arquitetura Model-View-Controller (MVC), que ajuda principalmente na separação de UI e preocupações lógicas. Assim, uma ação típica do controlador é encapsulada em uma subclasse de um ActionFilterAttribute . Na estrutura ASP.NET MVC, esses atributos C# são usados ​​para decorar a lógica de ação do controlador de alguma forma. Esse atributo específico foi responsável por abrir uma nova conexão com o banco de dados e iniciar uma transação no início da ação. Além disso, ao final da ação, a subclasse do atributo filter era responsável por confirmar a transação em caso de sucesso e revertê-la em caso de falha.

Se a invalidação do cache ocorresse bem no meio da transação, poderia haver uma condição de corrida em que a próxima solicitação para esse nó colocaria com êxito o valor antigo (ainda visível para outras transações) no cache. Para evitar isso, todas as invalidações são adiadas até que a transação seja confirmada. Depois disso, os itens do cache podem ser removidos com segurança e, no caso de uma falha de transação, não há necessidade de modificação do cache.

Esse era o propósito exato das partes relacionadas à “sessão” no invalidador de cache. Além disso, esse é o propósito de seu tempo de vida estar vinculado à solicitação. O código ASP.NET ficou assim:

 class HybridCacheInvalidator : ICacheInvalidator { ... public void Drop(IDataCacheKey key) { if (key == null) throw new ArgumentNullException("key"); if (!IsSessionOpen) throw new InvalidOperationException("Session must be opened first."); _postponedRedisMessages.Add(new Tuple<string, string>("drop", key.Value)); } ... public void CloseSession() { if (!IsSessionOpen) return; _postponedRedisMessages.ForEach(m => PublishRedisMessageSafe(m.Item1, m.Item2)); _postponedRedisMessages = null; } ... }

O método PublishRedisMessageSafe aqui é responsável por enviar a mensagem (segundo argumento) para um determinado canal (primeiro argumento). Na verdade, existem canais separados para soltar e tocar, então o manipulador de mensagens para cada um deles sabia exatamente o que fazer - soltar/tocar na tecla igual à carga útil da mensagem recebida.

Uma das partes complicadas foi gerenciar a conexão com o servidor Redis corretamente. No caso de o servidor ficar inativo por qualquer motivo, o aplicativo deve continuar funcionando corretamente. Quando o Redis estiver online novamente, o aplicativo deve começar a usá-lo novamente e trocar mensagens com outros nós novamente. Para conseguir isso, usei a biblioteca StackExchange.Redis e a lógica de gerenciamento de conexão resultante foi implementada da seguinte forma:

 class HybridCacheService : ... { ... public void Initialize() { try { Multiplexer = ConnectionMultiplexer.Connect(_configService.Caching.BackendServerAddress); ... Multiplexer.ConnectionFailed += (sender, args) => UpdateConnectedState(); Multiplexer.ConnectionRestored += (sender, args) => UpdateConnectedState(); ... } catch (Exception ex) { ... } } private void UpdateConnectedState() { if (Multiplexer.IsConnected && _currentCacheService is NoCacheServiceStub) { _inProcCacheInvalidator.Purge(); _currentCacheService = _inProcCacheService; _logger.Debug("Connection to remote Redis server restored, switched to in-proc mode."); } else if (!Multiplexer.IsConnected && _currentCacheService is InProcCacheService) { _currentCacheService = _noCacheStub; _logger.Debug("Connection to remote Redis server lost, switched to no-cache mode."); } } }

Aqui, ConnectionMultiplexer é um tipo da biblioteca StackExchange.Redis, responsável pelo trabalho transparente com Redis subjacente. A parte importante aqui é que, quando um nó específico perde a conexão com o Redis, ele volta para o modo sem cache para garantir que nenhuma solicitação receba dados obsoletos. Depois que a conexão é restaurada, o nó começa a usar o cache na memória novamente.

Aqui estão exemplos de ação sem uso do serviço de cache ( SomeActionWithoutCaching ) e uma operação idêntica que o utiliza ( SomeActionUsingCache ):

 class SomeController : Controller { public ISomeService SomeService { get; set; } public ICacheService CacheService { get; set; } ... public ActionResult SomeActionWithoutCaching() { return View( SomeService.GetModelData() ); } ... public ActionResult SomeActionUsingCache() { return View( CacheService.Get( /* Cache key creation omitted */, () => SomeService.GetModelData() ); ); } }

Um trecho de código de uma implementação de ISomeService pode ter esta aparência:

 class DefaultSomeService : ISomeService { public ICacheInvalidator _cacheInvalidator; ... public SomeModel GetModelData() { return /* Do something to get model data. */; } ... public void SetModelData(SomeModel model) { /* Do something to set model data. */ _cacheInvalidator.Drop(/* Cache key creation omitted */); } }

Benchmarking e resultados

Depois que o código ASP.NET de armazenamento em cache estava pronto, era hora de usá-lo na lógica do aplicativo da Web existente, e o benchmarking pode ser útil para decidir onde colocar mais esforços para reescrever o código para usar o cache. É crucial escolher alguns casos de uso operacionalmente mais comuns ou críticos para serem comparados. Depois disso, uma ferramenta como o Apache jMeter pode ser usada para duas coisas:

  • Para comparar esses principais casos de uso por meio de solicitações HTTP.
  • Para simular alta carga para o nó da web em teste.

Para obter um perfil de desempenho, qualquer criador de perfil capaz de se conectar ao processo de trabalho do IIS pode ser usado. No meu caso, usei o JetBrains dotTrace Performance. Depois de algum tempo experimentando para determinar os parâmetros corretos do jMeter (como contagem simultânea e de solicitações), torna-se possível começar a coletar instantâneos de desempenho, que são muito úteis na identificação de pontos de acesso e gargalos.

No meu caso, alguns casos de uso mostraram que cerca de 15% a 45% do tempo total de execução do código foi gasto nas leituras do banco de dados com os gargalos óbvios. Depois que apliquei o cache, o desempenho quase dobrou (ou seja, foi duas vezes mais rápido) para a maioria deles.

Relacionado: Oito razões pelas quais o Microsoft Stack ainda é uma escolha viável

Conclusão

Como você pode ver, meu caso pode parecer um exemplo do que se costuma chamar de “reinventar a roda”: por que se preocupar em tentar criar algo novo, quando já existem boas práticas amplamente aplicadas por aí? Basta configurar um Memcached ou Redis e deixá-lo ir.

Eu definitivamente concordo que o uso das melhores práticas é geralmente a melhor opção. Mas antes de aplicar cegamente qualquer melhor prática, deve-se perguntar: quão aplicável é essa “melhor prática”? Se encaixa bem no meu caso?

A meu ver, as opções adequadas e a análise de tradeoffs são essenciais ao se tomar qualquer decisão significativa, e essa foi a abordagem que escolhi porque o problema não era tão fácil. No meu caso, havia muitos fatores a serem considerados, e eu não queria adotar uma solução de tamanho único quando talvez não fosse a abordagem certa para o problema em questão.

No final, com o armazenamento em cache adequado, obtive quase 50% de aumento de desempenho em relação à solução inicial.