Estratégias de cache – Cache-Aside (lazy)

Voltando com a série Masterizando Cache em Aplicações, no post de hoje iniciamos a discussão sobre as estratégias de cache.

Uma das estratégias mais utilizadas na indústria é o padrão Cache-Aside também conhecido como Lazy Loading. Nesse modelo, a aplicação assume total responsabilidade por gerenciar o cache — decidindo quando ler, quando escrever e quando invalidar dados.

Ao utilizar um cache distribuído como o Redis, adicionamos uma camada intermediária entre a aplicação e o banco de dados. Essa camada tem como objetivo principal reduzir latência, aliviar a carga no banco e aumentar a capacidade de resposta do sistema.

Diferente de outras abordagens, o cache-aside não é automático. O cache não é preenchido previamente, nem atualizado de forma transparente. Ele é, por definição, reativo.

o dado só vai para o cache quando alguém pede por ele.

O banco de dados continua sendo a referência principal. O cache é apenas uma otimização.


Funcionamento

Fluxo de leitura

Quando a aplicação recebe uma requisição — por exemplo, buscar um usuário — ela não vai diretamente ao banco. Primeiro, ela consulta o cache.

Se o dado estiver presente cache hit, ele é retornado imediatamente. Esse é o cenário ideal: baixa latência, nenhuma carga adicional no banco e resposta rápida para o usuário.

Por outro lado, se o dado não estiver no cache cache miss, a aplicação precisa recorrer ao banco de dados. Após obter o resultado, ela armazena esse dado no cache geralmente com um TTL e então retorna a resposta.

⚠️ Concorrência e efeitos colaterais

Agora imagine um cenário mais realista.

Uma chave de cache expira exatamente no momento em que múltiplas requisições chegam simultaneamente. Todas elas vão perceber que o cache está vazio e irão consultar o banco ao mesmo tempo.

Esse fenômeno é conhecido como cache stampede e pode gerar picos de carga significativos no banco de dados. Esse tipo de problema não aparece em ambientes simples, mas é extremamente comum em sistemas de alta escala.

Fluxo de escrita

Diferente da leitura, o cache não é automaticamente atualizado. A aplicação precisa decidir o que fazer após modificar um dado no banco.

A abordagem mais comum é o padrão write + invalidate.

Nesse fluxo, a aplicação primeiro atualiza o banco de dados e, em seguida, remove a chave correspondente do cache. Isso garante que, na próxima leitura, o dado será reconstruído com o valor mais recente.

Essa estratégia é amplamente adotada porque reduz o risco de inconsistência, especialmente em sistemas distribuídos com múltiplas instâncias da aplicação.

Existe também a alternativa de atualizar o cache diretamente após a escrita write + update, mas essa abordagem exige maior controle e pode introduzir inconsistências sutis, principalmente quando há concorrência entre múltiplas instâncias.

invalidar é mais seguro do que tentar manter o cache sincronizado manualmente.


Trade-offs

O cache-aside é extremamente popular, mas não é uma solução mágica. Ele traz benefícios claros, mas também impõe desafios que precisam ser compreendidos.

👍 Vantagens

Um dos maiores atrativos desse padrão é a sua simplicidade. Ele não exige mudanças profundas na arquitetura e pode ser introduzido de forma incremental.

Outro ponto importante é a resiliência. Se o Redis falhar, o sistema continua funcionando — apenas mais lento. Isso mantém o banco como fallback seguro.

Além disso, o cache-aside escala muito bem em cenários de leitura intensa. Ao descarregar o banco, ele permite que o sistema suporte um volume muito maior de requisições.

Por fim, ele oferece controle total à aplicação. Você decide exatamente o que cachear, por quanto tempo e em quais situações.

👎 Desvantagens

A principal desvantagem é a inconsistência temporal.

Entre o momento em que um dado é atualizado no banco e o momento em que o cache é invalidado ou reconstruído, existe uma janela onde o sistema pode servir dados desatualizados.

Outro problema relevante é o já mencionado cache stampede, que pode causar picos inesperados de carga no banco.

Além disso, embora o padrão pareça simples, ele esconde uma complexidade significativa. Estratégias de invalidação, controle de concorrência e tuning de TTL são aspectos que exigem atenção cuidadosa em sistemas reais.

Também existem problemas clássicos associados ao uso de cache, como:

  • cache penetration (consultas para dados inexistentes)
  • cache avalanche (muitas chaves expirando ao mesmo tempo)

Esses problemas geralmente surgem apenas em escala, mas precisam ser considerados desde o início.


Quando utilizar

O cache-aside não é uma solução universal. Ele funciona muito bem em determinados cenários, mas pode ser inadequado em outros.

Ele é especialmente indicado para sistemas com alto volume de leitura e baixa frequência de escrita. Casos como catálogos, perfis de usuário e dashboards são exemplos clássicos.

Também é uma boa escolha quando o sistema pode tolerar consistência eventual. Ou seja, quando pequenas defasagens nos dados não comprometem a experiência do usuário.

Outro fator importante é a capacidade de reconstrução do dado. Se o dado pode ser facilmente obtido novamente a partir do banco, o cache-aside funciona muito bem.

Por outro lado, existem cenários onde ele deve ser evitado.

Sistemas que exigem consistência forte — como controle de saldo financeiro em tempo real — não se beneficiam desse padrão. Da mesma forma, aplicações com alta taxa de escrita tendem a invalidar o cache com frequência, reduzindo seu valor.


Implementação

Sendo bem simples, a implementação foi construída com o intuito de apresentar a otimização de um fluxo de busca de e-mails de usuários. Abordaremos aqui apenas os trechos de código relevantes; toda a implementação está disponível no GitHub

Fluxo de leitura

@Override
    public UserEmail getUserEmail(UUID userId) {
        var key = cacheKey(userId);
        var cachedEmail = redisTemplate.opsForValue().get(key);

        if (cachedEmail != null) {
            meterRegistry.counter("cache.email.hit")
                    .increment();
            return new UserEmail(cachedEmail.toString(), true);
        }

        var email = slowDatabaseLookup(userId);
        redisTemplate.opsForValue().set(key, email, CACHE_TTL);
        meterRegistry.counter("cache.email.miss")
                .increment();
        return new UserEmail(email, false);
    }

Basicamente, o trecho de código acima primeiro realiza a busca do e-mail diretamente na camada de cache. Caso o dado exista, ele é retornado imediatamente para a camada de apresentação, e a métrica cache.email.hit é incrementada.

Caso não seja encontrado, é necessário buscar o dado diretamente na base de dados. Por motivos de teste, a função slowDatabaseLookUp simula a latência do banco aplicando um Thread.sleep() aleatório. Em seguida, o dado retornado é armazenado na camada de cache, otimizando futuras requisições. Por fim, a métrica cache.email.miss é incrementada.

Fluxo de escrita

@Override
    public void updateUserEmail(UUID userId, String email) {
        var user = repository.findById(userId).orElseThrow(() -> new NoSuchElementException("User not found: " + userId));
        user.setEmail(email);

        //Using here Write + Invalidate strategy
        repository.save(user);
        redisTemplate.delete(cacheKey(userId));
    }

Aqui aplicamos a estratégia write + invalidate no fluxo de atualização, visando mitigar inconsistências de dados.

Eficiência do cache

Existem métodos para determinar se o cache implementado é eficiente ou não. Particularmente, costumo utilizar duas métricas: o cache hit ratio e a redução na latência.

Para o cálculo do cache hit ratio, incluí duas métricas — cache.email.hit e cache.email.miss — que podem ser coletadas diretamente no endpoint do actuator /user/actuator/metrics/{metricName}.

{“availableTags”:[],”measurements”:[{“statistic”:”COUNT”,”value”:148.0}],”name”:”cache.email.hit”}{ “availableTags”: [], “measurements”: [ { “statistic”: “COUNT”, “value”: 7 } ], “name”: “cache.email.miss” }

Aplicando os dados a formula abaixo

cache hit ratio = cache hit/(cache hit + cache miss) * 100

CHR = 148/(148+7) = 148/155*100 = 95.48%

De acordo com a Cloudflare, um bom hit ratio está entre 95% e 99%. Logo, pode-se concluir que a implementação do cache-aside cumpriu seu papel de otimização com eficiência.

Na próxima publicação, abordaremos a estratégia de cache read-through.

Deixe um comentário

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *