Anti-padrão Sem Colocação em Cache

Os anti-padrões são falhas de design comuns que podem quebrar o seu software ou aplicações em situações de stress e não devem ser negligenciadas. Um antipadrão sem cache ocorre quando um aplicativo em nuvem que lida com muitas solicitações simultâneas busca repetidamente os mesmos dados. Isso pode reduzir o desempenho e a escalabilidade.

Quando os dados não são colocados em cache, pode causar vários comportamentos indesejáveis, incluindo:

  • Obter repetidamente as mesmas informações de um recurso que é dispendioso de aceder, em termos de custos gerais de E/S ou latência.
  • Construir repetidamente os mesmos objetos ou estruturas de dados para múltiplos pedidos.
  • Efetuar chamadas excessivas para um serviço remoto com uma quota de serviço e limites para os clientes que ultrapassem um determinado valor.

Por sua vez, estes problemas podem originar fracos tempos de resposta, maior contenção no arquivo de dados e fraca escalabilidade.

Exemplos de antipadrão sem cache

O exemplo seguinte utiliza o Entity Framework para ligar a uma base de dados. Cada pedido de cliente resulta numa chamada para a base de dados, mesmo se múltiplos pedidos estejam a obter exatamente os mesmos dados. O custo de pedidos repetidos, em termos de custos gerais de E/S e custos de acesso a dados, pode acumular-se rapidamente.

public class PersonRepository : IPersonRepository
{
    public async Task<Person> GetAsync(int id)
    {
        using (var context = new AdventureWorksContext())
        {
            return await context.People
                .Where(p => p.Id == id)
                .FirstOrDefaultAsync()
                .ConfigureAwait(false);
        }
    }
}

Pode encontrar o exemplo completo aqui.

Este anti-padrão ocorre normalmente porque:

  • A não utilização de uma cache é mais simples de implementar e funciona bem com pouca carga. A colocação em cache torna o código mais complicado.
  • As vantagens e desvantagens de utilizar uma cache não são compreendidas claramente.
  • Não existe nenhuma preocupação relativamente aos custos gerais de manter a precisão e a atualização dos dados em cache.
  • Uma aplicação foi migrada de um sistema no local, em que a latência de rede não foi um problema e o sistema foi executado em hardware de elevado desempenho dispendioso, pelo que a colocação em cache não foi considerada na conceção original.
  • Os programadores não têm consciência de que a colocação em cache é uma possibilidade num determinado cenário. Por exemplo, os programadores podem não pensar em utilizar ETags quando implementarem uma API Web.

Como corrigir o antipadrão sem cache

A estratégia de colocação em cache mais popular é a pedido ou cache-aside.

  • Na leitura, a aplicação tenta ler os dados da cache. Se os dados não estiverem na cache, a aplicação obtém os mesmos da origem de dados e adiciona-os à cache.
  • Na escrita, a aplicação escreve a alteração diretamente na origem de dados e remove o valor antigo da cache. Será obtido e adicionado à cache da próxima vez que for necessário.

Esta abordagem é adequada para dados alterados com frequência. Segue-se o exemplo anterior atualizado para utilizar o padrão Cache-Aside.

public class CachedPersonRepository : IPersonRepository
{
    private readonly PersonRepository _innerRepository;

    public CachedPersonRepository(PersonRepository innerRepository)
    {
        _innerRepository = innerRepository;
    }

    public async Task<Person> GetAsync(int id)
    {
        return await CacheService.GetAsync<Person>("p:" + id, () => _innerRepository.GetAsync(id)).ConfigureAwait(false);
    }
}

public class CacheService
{
    private static ConnectionMultiplexer _connection;

    public static async Task<T> GetAsync<T>(string key, Func<Task<T>> loadCache, double expirationTimeInMinutes)
    {
        IDatabase cache = Connection.GetDatabase();
        T value = await GetAsync<T>(cache, key).ConfigureAwait(false);
        if (value == null)
        {
            // Value was not found in the cache. Call the lambda to get the value from the database.
            value = await loadCache().ConfigureAwait(false);
            if (value != null)
            {
                // Add the value to the cache.
                await SetAsync(cache, key, value, expirationTimeInMinutes).ConfigureAwait(false);
            }
        }
        return value;
    }
}

Tenha em atenção que o método GetAsync chama agora a classe CacheService, em vez de chamar diretamente a base de dados. Primeiro, a classe CacheService tenta obter o item da Cache do Azure para Redis. Se o valor não for encontrado na cache, CacheService invoca uma função lambda transmitida ao mesmo pelo chamador. A função lambda é responsável por obter os dados da base de dados. Esta implementação desacopla o repositório da solução de colocação em cache específica e desacopla CacheService da base de dados.

Considerações para a estratégia de cache

  • Se a cache não estiver disponível, talvez devido a uma falha transitória, não devolva um erro ao cliente. Em vez disso, obtenha os dados da origem de dados original. No entanto, tenha em atenção que, enquanto a cache está a ser recuperada, o arquivo de dados original pode ser inundado de pedidos, o que resulta em tempos limite e falhas de ligações. (Afinal, essa é uma das motivações para usar um cache em primeiro lugar.) Use uma técnica como o padrão de disjuntor para evitar sobrecarregar a fonte de dados.

  • As aplicações que colocam dados dinâmicos em cache devem ser concebidas para suportar uma eventual consistência.

  • Para APIs Web, pode suportar a colocação em cache do lado do cliente ao incluir um cabeçalho Cache-Control nas mensagens de pedido e resposta, e ao utilizar ETags para identificar as versões de objetos. Para obter mais informações, veja Implementação de APIs.

  • Não tem de colocar todas as entidades em cache. Se a maior parte de uma entidade for estática, mas apenas uma pequena parte for alterada com frequência, coloque os elementos estáticos em cache e obtenha os elementos dinâmicos da origem de dados. Esta abordagem pode ajudar a reduzir o volume de E/S a efetuar na origem de dados.

  • Em alguns casos, se os dados voláteis tiverem curta duração, pode ser útil colocá-los em cache. Por exemplo, considere um dispositivo que envie continuamente atualizações de estado. Poderá fazer sentido colocar estas informações em cache à medida que chegam e não escrevê-las num arquivo persistente.

  • Para impedir que os dados se tornem obsoletos, muitas soluções de colocação em cache suportam períodos de expiração configuráveis, para que os dados sejam removidos automaticamente da cache após um intervalo especificado. Poderá ter de otimizar o prazo de expiração para o seu cenário. Os dados altamente estáticos podem permanecer na cache por períodos mais longos do que os dados voláteis, que se podem tornar obsoletos rapidamente.

  • Se a solução de colocação em cache não fornecer expiração incorporada, poderá ter de implementar um processo em segundo plano que varra ocasionalmente a cache, para impedir que cresça sem limites.

  • Além da colocação de dados em cache a partir de uma origem de dados externa, pode utilizar a colocação em cache para guardar os resultados de cálculos complexos. No entanto, antes de o fazer, instrumente a aplicação para determinar se está realmente vinculada à CPU.

  • Poderá ser útil avisar a cache quando a aplicação for iniciada. Povoe a cache com os dados com maior probabilidade de serem utilizados.

  • Inclua sempre instrumentação que detete acertos e falhas de acerto na cache. Utilize estas informações para otimizar as políticas de colocação em cache, como quais os dados a colocar em cache e o intervalo de tempo para armazenar os dados na cache antes de expirarem.

  • Se a falta de colocação em cache for um estrangulamento, adicionar a colocação em cache pode aumentar tanto o volume de pedidos que o front-end da Web fica sobrecarregado. Os clientes podem começar a receber erros de HTTP 503 (Serviço Indisponível). Estes são uma indicação de que deve aumentar horizontalmente o front-end.

Como detetar um antipadrão sem cache

Pode efetuar os passos seguintes para ajudar a identificar se a falta de colocação em cache está a causar problemas de desempenho:

  1. Reveja a conceção da aplicação. Faça um inventário de todos os arquivos de dados utilizados pela aplicação. Determine se a aplicação está a utilizar uma cache para cada um deles. Se possível, determine a frequência de alteração dos dados. Bons candidatos iniciais para colocação em cache incluem dados alterados lentamente e dados de referência estáticos lidos com frequência.

  2. Instrumente a aplicação e monitorize o sistema para descobrir com que frequência a aplicação obtém dados ou calcula informações.

  3. Crie um perfil da aplicação num ambiente de teste para capturar as métricas de baixo nível sobre os custos gerais associados a operações de acesso a dados ou a outros cálculos efetuados frequentemente.

  4. Execute testes de carga num ambiente de teste para identificar como o sistema responde a uma carga de trabalho normal e a uma sobrecarga. Os testes de carga devem simular o padrão de acesso a dados observado no ambiente de produção através de cargas de trabalho realistas.

  5. Examine as estatísticas de acesso a dados dos arquivos de dados subjacentes e reveja com que frequência são repetidos os mesmos pedidos de dados.

Diagnóstico de exemplo

As secções seguintes aplicam estes passos para o exemplo de aplicação descrito anteriormente.

Instrumentar a aplicação e monitorizar o sistema

Instrumente a aplicação e monitorize-a para obter informações sobre os pedidos específicos que os utilizadores fazem enquanto a aplicação está em produção.

A imagem seguinte mostra dados de monitorização capturados pelo New Relic durante um teste de carga. Neste caso, a única operação GET de HTTP efetuada é Person/GetAsync. No entanto, num ambiente de produção, conhecer a frequência relativa em que cada pedido é efetuado pode dar-lhe informações sobre que recursos devem ser colocados em cache.

New Relic showing server requests for the CachingDemo application

Se precisar de uma análise mais detalhada, pode utilizar um gerador de perfis para capturar os dados de desempenho de baixo nível num ambiente de teste (não o sistema de produção). Observe as métricas, como taxas de pedidos de E/S, utilização da memória e utilização da CPU. Estas métricas podem mostrar um elevado número de pedidos para um arquivo de dados ou serviço, bem como o processamento repetido que executa o mesmo cálculo.

Testar a carga da aplicação

O gráfico seguinte mostra os resultados do teste de carga da aplicação de exemplo. O teste de carga simula uma carga de até 800 utilizadores a executar uma série típica de operações.

Performance load test results for the uncached scenario

O número de testes com êxito efetuados por segundo atinge um patamar, o que origina um abrandamento dos pedidos adicionais. O tempo médio de teste aumenta firmemente com a carga de trabalho. O tempo de resposta uniformiza após os picos de carga dos utilizadores.

Examinar as estatísticas de acesso a dados

As estatísticas de acesso a dados e outras informações fornecidas por um arquivo de dados pode fornecer informações úteis, como que consultas são repetidas com mais frequência. Por exemplo, no Microsoft SQL Server, a vista de gestão sys.dm_exec_query_stats tem informações estatísticas para consultas executadas recentemente. O texto para cada consulta está disponível na vista sys.dm_exec-query_plan. Pode utilizar uma ferramenta como o SQL Server Management Studio para executar a seguinte consulta SQL e determinar a frequência de execução das consultas.

SELECT UseCounts, Text, Query_Plan
FROM sys.dm_exec_cached_plans
CROSS APPLY sys.dm_exec_sql_text(plan_handle)
CROSS APPLY sys.dm_exec_query_plan(plan_handle)

A coluna UseCount nos resultados indica a frequência de execução de cada consulta. A imagem seguinte mostra que a terceira consulta foi executada mais de 250 000 vezes, significativamente mais do que qualquer outra consulta.

Results of querying the dynamic management views in SQL Server Management Server

Segue-se a consulta SQL Server que está a causar muitos pedidos da base de dados:

(@p__linq__0 int)SELECT TOP (2)
[Extent1].[BusinessEntityId] AS [BusinessEntityId],
[Extent1].[FirstName] AS [FirstName],
[Extent1].[LastName] AS [LastName]
FROM [Person].[Person] AS [Extent1]
WHERE [Extent1].[BusinessEntityId] = @p__linq__0

Esta é a consulta que o Entity Framework gera no método GetByIdAsync apresentado anteriormente.

Implementar a solução de estratégia de cache e verificar o resultado

Depois de incorporar uma cache, repita os testes de carga e compare os resultados com os testes de carga anteriores sem uma cache. Seguem-se os resultados do teste de carga depois de adicionar uma cache à aplicação de exemplo.

Performance load test results for the cached scenario

O volume de testes com êxito continua a atingir um patamar, mas com uma carga de utilizadores superior. A taxa de pedidos nesta carga é significativamente maior do que a anterior. O tempo médio de teste ainda aumenta com a carga, mas o tempo máximo de resposta é de 0,05 ms, em comparação com 1 ms anteriormente, uma melhoria de 20×.