Estratégias de Caching para Resolver Problemas de Latência e Sobrecarga em Bancos de Dados

Disclaimer Este texto foi inicialmente concebido pela IA Generativa em função da transcrição de uma aula do curso "Os 3 pilares para escalar sistemas distribuídos" da Jornada Dev + Eficiente. Se preferir acompanhar por vídeo, é só dar o play. Introdução Em sistemas distribuídos, a latência e a sobrecarga do banco de dados são desafios constantes que podem comprometer seriamente a experiência do usuário. Mesmo após implementar diversas otimizações na camada de persistência, alguns sistemas ainda enfrentam problemas durante períodos de pico ou spikes. Neste post, vamos explorar como estratégias de caching podem ser a solução para aliviar essa carga e melhorar significativamente o desempenho da sua aplicação. O Problema de Sobrecarga no Banco de Dados Considere um cenário comum: um controller que gera recibos de notas fiscais para usuários. Quando um usuário solicita um recibo, a aplicação consulta o banco de dados para recuperar a nota fiscal, processa seus itens e calcula o valor total para construir o recibo de resposta. Essa operação, aparentemente simples, pode se tornar um gargalo quando o sistema está sob alta demanda. @GetMapping("/api/recibo/{id}") public ReciboResponse gerarRecibo(@PathVariable Long id) { NotaFiscal notaFiscal = notaFiscalRepository.findById(id) .orElseThrow(() -> new ErroException("Nota fiscal não encontrada")); // Processa itens e gera o recibo // ... return reciboResponse; } O principal gargalo frequentemente ocorre na linha notaFiscalRepository.findById(id), que acessa o banco de dados. Quando há um aumento repentino de requisições, o banco pode não suportar a carga, aumentando o tempo de resposta e reduzindo o throughput do sistema. Cache de Primeiro Nível (L1) com Entity Manager O Hibernate, que é utilizado por baixo dos panos pelo Spring Data JPA, já possui um mecanismo de cache integrado chamado "cache de primeiro nível" ou "L1 cache". Este cache é implementado pela Entity Manager, que armazena em memória as entidades já carregadas durante uma transação. Quando você executa a mesma consulta várias vezes usando a mesma instância de Entity Manager, apenas a primeira execução gera uma consulta SQL real. As consultas subsequentes recuperam a entidade diretamente do cache em memória, evitando acessos desnecessários ao banco de dados. 1ª execução: findById(42) → SQL → Banco de Dados 2ª execução: findById(42) → Cache L1 (nenhum SQL gerado) 3ª execução: findById(42) → Cache L1 (nenhum SQL gerado) O problema é que o cache de primeiro nível é limitado ao escopo da Entity Manager, que geralmente é criada por transação, requisição ou thread. Isso significa que diferentes usuários acessando a mesma entidade ainda causarão múltiplos acessos ao banco de dados, um para cada Entity Manager criada. Cache de Segundo Nível (L2): Compartilhando Cache Entre Requisições Para resolver essa limitação, podemos implementar o "cache de segundo nível" (L2), que é um componente que atua entre as Entity Managers e o banco de dados. Este cache é compartilhado globalmente na aplicação, permitindo que entidades carregadas por uma requisição sejam acessíveis a outras requisições sem necessidade de acessar o banco de dados novamente. Configurando o Cache L2 no Hibernate Para habilitar o cache de segundo nível no Hibernate com Spring Boot, basta adicionar algumas configurações ao arquivo application.properties: spring.jpa.properties.hibernate.cache.use_second_level_cache=true spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory spring.jpa.properties.hibernate.cache.use_query_cache=true Em seguida, precisamos marcar quais entidades devem ser cacheadas. Nem todas as entidades são boas candidatas para caching; você precisa identificar aquelas que mais se beneficiariam dessa estratégia: @Entity @Cache(usage = CacheConcurrencyStrategy.READ_ONLY) public class NotaFiscal { // ... } Estratégias de Cache no Hibernate O Hibernate oferece três estratégias principais de cache que devem ser escolhidas com base no comportamento da entidade: 1. READ_ONLY Ideal para entidades que nunca ou raramente são alteradas. Esta estratégia oferece o melhor desempenho, pois não precisa se preocupar com invalidação de cache. @Cache(usage = CacheConcurrencyStrategy.READ_ONLY) 2. READ_WRITE Apropriada para entidades que são frequentemente atualizadas. Esta estratégia mantém a sincronização entre o cache e o banco de dados, utilizando locks para garantir a consistência. É mais lenta que READ_ONLY devido à necessidade de coordenação. @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) 3. NONSTRICT_READ_WRITE Um meio-termo para entidades que são alteradas com baixa frequência e onde uma pequena dessincronização é aceitável. Usa locks mais leves, resultando em melhor desempenho em troca de

Mar 31, 2025 - 00:21
 0
Estratégias de Caching para Resolver Problemas de Latência e Sobrecarga em Bancos de Dados

Disclaimer

Este texto foi inicialmente concebido pela IA Generativa em função da transcrição de uma aula do curso "Os 3 pilares para escalar sistemas distribuídos" da Jornada Dev + Eficiente. Se preferir acompanhar por vídeo, é só dar o play.

Introdução

Em sistemas distribuídos, a latência e a sobrecarga do banco de dados são desafios constantes que podem comprometer seriamente a experiência do usuário. Mesmo após implementar diversas otimizações na camada de persistência, alguns sistemas ainda enfrentam problemas durante períodos de pico ou spikes. Neste post, vamos explorar como estratégias de caching podem ser a solução para aliviar essa carga e melhorar significativamente o desempenho da sua aplicação.

O Problema de Sobrecarga no Banco de Dados

Considere um cenário comum: um controller que gera recibos de notas fiscais para usuários. Quando um usuário solicita um recibo, a aplicação consulta o banco de dados para recuperar a nota fiscal, processa seus itens e calcula o valor total para construir o recibo de resposta. Essa operação, aparentemente simples, pode se tornar um gargalo quando o sistema está sob alta demanda.

@GetMapping("/api/recibo/{id}")
public ReciboResponse gerarRecibo(@PathVariable Long id) {
    NotaFiscal notaFiscal = notaFiscalRepository.findById(id)
        .orElseThrow(() -> new ErroException("Nota fiscal não encontrada"));

    // Processa itens e gera o recibo
    // ...

    return reciboResponse;
}

O principal gargalo frequentemente ocorre na linha notaFiscalRepository.findById(id), que acessa o banco de dados. Quando há um aumento repentino de requisições, o banco pode não suportar a carga, aumentando o tempo de resposta e reduzindo o throughput do sistema.

Cache de Primeiro Nível (L1) com Entity Manager

O Hibernate, que é utilizado por baixo dos panos pelo Spring Data JPA, já possui um mecanismo de cache integrado chamado "cache de primeiro nível" ou "L1 cache". Este cache é implementado pela Entity Manager, que armazena em memória as entidades já carregadas durante uma transação.

Quando você executa a mesma consulta várias vezes usando a mesma instância de Entity Manager, apenas a primeira execução gera uma consulta SQL real. As consultas subsequentes recuperam a entidade diretamente do cache em memória, evitando acessos desnecessários ao banco de dados.

1ª execução: findById(42) → SQL → Banco de Dados
2ª execução: findById(42) → Cache L1 (nenhum SQL gerado)
3ª execução: findById(42) → Cache L1 (nenhum SQL gerado)

O problema é que o cache de primeiro nível é limitado ao escopo da Entity Manager, que geralmente é criada por transação, requisição ou thread. Isso significa que diferentes usuários acessando a mesma entidade ainda causarão múltiplos acessos ao banco de dados, um para cada Entity Manager criada.

Cache de Segundo Nível (L2): Compartilhando Cache Entre Requisições

Para resolver essa limitação, podemos implementar o "cache de segundo nível" (L2), que é um componente que atua entre as Entity Managers e o banco de dados. Este cache é compartilhado globalmente na aplicação, permitindo que entidades carregadas por uma requisição sejam acessíveis a outras requisições sem necessidade de acessar o banco de dados novamente.

Configurando o Cache L2 no Hibernate

Para habilitar o cache de segundo nível no Hibernate com Spring Boot, basta adicionar algumas configurações ao arquivo application.properties:

spring.jpa.properties.hibernate.cache.use_second_level_cache=true
spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.ehcache.EhCacheRegionFactory
spring.jpa.properties.hibernate.cache.use_query_cache=true

Em seguida, precisamos marcar quais entidades devem ser cacheadas. Nem todas as entidades são boas candidatas para caching; você precisa identificar aquelas que mais se beneficiariam dessa estratégia:

@Entity
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)
public class NotaFiscal {
    // ...
}

Estratégias de Cache no Hibernate

O Hibernate oferece três estratégias principais de cache que devem ser escolhidas com base no comportamento da entidade:

1. READ_ONLY

Ideal para entidades que nunca ou raramente são alteradas. Esta estratégia oferece o melhor desempenho, pois não precisa se preocupar com invalidação de cache.

@Cache(usage = CacheConcurrencyStrategy.READ_ONLY)

2. READ_WRITE

Apropriada para entidades que são frequentemente atualizadas. Esta estratégia mantém a sincronização entre o cache e o banco de dados, utilizando locks para garantir a consistência. É mais lenta que READ_ONLY devido à necessidade de coordenação.

@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)

3. NONSTRICT_READ_WRITE

Um meio-termo para entidades que são alteradas com baixa frequência e onde uma pequena dessincronização é aceitável. Usa locks mais leves, resultando em melhor desempenho em troca de possível inconsistência temporária.

@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE)

Um bom exemplo para NONSTRICT_READ_WRITE seria o perfil de um usuário, que é frequentemente acessado mas raramente modificado.

Configurando o Provedor de Cache (EHCache)

Além de configurar o Hibernate, também precisamos configurar o provedor de cache. Um exemplo comum é o EHCache, que requer um arquivo de configuração XML:


     
        name="br.com.dev.exe.domain.Produto" 
        maxElementsInMemory="100000" 
        eternal="false" 
        timeToIdleSeconds="86400"
        timeToLiveSeconds="259200" 
        overflowToDisk="true" 
        memoryStoreEvictionPolicy="LRU">
    

Esta configuração define:

  • Uma região de cache para a entidade Produto
  • Capacidade para até 100.000 produtos em memória
  • Política de expiração: remover após 24 horas de inatividade ou 72 horas independentemente do acesso
  • Política de despejo: produtos menos recentemente usados (LRU) são movidos para o disco em vez de descartados

Cache na Camada de Controller

O cache não precisa se limitar à camada de persistência. Podemos também implementar caching na camada de controller usando o Spring Boot Caching:

@GetMapping("/api/recibo/{id}")
@Cacheable(value = "recibosCache", key = "#id")
public ReciboResponse gerarRecibo(@PathVariable Long id) {
    // Lógica de geração do recibo
}

Esta abordagem cacheia a resposta completa do endpoint, evitando não só o acesso ao banco de dados, mas também todo o processamento necessário para construir o recibo. Isso torna o cache ainda mais próximo do usuário, reduzindo ainda mais a latência.

O Impacto do Caching no Desempenho

O uso adequado de caching pode melhorar o desempenho de uma aplicação em 5, 10 ou até 20 vezes. Em um caso real, uma API foi otimizada de 5 requisições por segundo para 1.000 requisições por segundo apenas com otimizações na camada de persistência. Após a implementação de caching, esse número aumentou para impressionantes 5.000 requisições por segundo.

Isso demonstra que é possível desenhar e implementar APIs de alto desempenho, mesmo aquelas que são IO-bound, utilizando as técnicas adequadas de otimização na camada de persistência e estratégias de caching.

Conclusão

Implementar estratégias de caching é uma solução poderosa para resolver problemas de latência e sobrecarga em bancos de dados. O cache pode ser aplicado em diferentes camadas da aplicação, desde a camada de persistência até a camada de controller, cada uma com seus próprios benefícios.

Lembre-se que o caching também tem seus trade-offs, e a configuração ideal depende do seu caso de uso específico. É essencial conhecer bem o seu domínio, os requisitos funcionais e não funcionais, e realizar o fine-tuning adequado para maximizar os benefícios do cache.

Ao implementar corretamente essas estratégias, você estará no caminho para construir sistemas distribuídos mais resilientes, com menor latência e maior throughput, proporcionando uma melhor experiência para seus usuários.

Sobre a Jornada Dev + Eficiente

A Jornada Dev + Eficiente é um treinamento focado em fazer você crescer na carreira como uma pessoa cada vez mais especializada em Entregar Software que Gera Valor com o Máximo de Qualidade e Fluidez.

A Jornada pavimenta este caminho através de uma abordagem integrada, trabalhando diversos aspectos que influenciam na qualidade da entrega final, tais como: Engenharia de Requisitos, Design de Código, Arquitetura, Testes etc. É o único local que você vai encontrar que é 100% focado em fazer você crescer como uma pessoa desenvolvedora de software completa.

Para conhecer mais, acesse https://deveficiente.com