Query caching no nível da aplicação

1. Introdução ao Query Caching na Aplicação

Cachear consultas SQL no lado da aplicação é uma das estratégias mais eficazes para reduzir latência e aliviar a carga no banco de dados. Diferente do cache nativo do banco (como o buffer pool do MySQL ou shared buffers do PostgreSQL), que opera em nível de páginas de disco, o cache na aplicação armazena resultados completos de queries, evitando que o banco precise reexecutá-las.

Diferença fundamental: O cache do banco acelera a leitura de blocos físicos, mas não impede que o motor SQL reexecute joins, agregações e filtros. Já o cache de aplicação devolve o resultado processado instantaneamente, sem tocar no banco.

Benefícios principais:
- Redução de latência: respostas em milissegundos (vs. dezenas/centenas de ms no banco)
- Descarga de carga: banco livre para processar escritas e queries não cacheáveis
- Escalabilidade horizontal: o cache pode ser distribuído entre instâncias da aplicação

2. Estratégias de Cache: Quando e O Que Cachear

Nem toda query merece cache. Os candidatos ideais são consultas frequentes, estáveis e lentas:

-- Exemplo de query elegível: relatório diário de vendas
SELECT 
    DATE(v.data_venda) AS dia,
    COUNT(*) AS total_pedidos,
    SUM(v.valor_total) AS receita
FROM vendas v
WHERE v.data_venda >= CURRENT_DATE - INTERVAL '30 days'
GROUP BY DATE(v.data_venda)
ORDER BY dia;

Critérios de elegibilidade:
- Executada muitas vezes por segundo (alta frequência)
- Resultado não muda a cada requisição (estabilidade)
- Tempo de execução > 100ms (lentidão justifica cache)

Cache de resultados completos vs. paginação:
- Resultados completos: ideais para relatórios e dashboards
- Paginação: cachear páginas individuais (OFFSET/LIMIT) ou cachear todo o conjunto e paginar na aplicação

Evite cache em tabelas com alta taxa de escrita (logs, eventos, sensores). A invalidação constante anularia os benefícios.

3. Arquiteturas de Cache na Prática

Cache local em memória (Redis/Memcached):
- Redis: suporte a estruturas complexas (hashes, sets), TTL, pub/sub para invalidação
- Memcached: mais simples, apenas chave-valor, excelente para cache puro

# Exemplo de chave no Redis
cache:produtos:categoria_eletronicos:pagina_1

Cache distribuído vs. embutido (in-process):
- Distribuído (Redis): todas as instâncias da aplicação compartilham o mesmo cache
- Embutido (guava cache, Python lru_cache): rápido, mas não compartilhado entre nós

Integração com ORMs:
- Django: cache_page para views, cached_db backend
- Hibernate: cache de segundo nível (EhCache, Redis)
- ActiveRecord: cache_key com expiração automática

4. Implementação Típica com Redis (Exemplo)

Fluxo básico: verificar cache → consultar banco → popular cache

# Pseudocódigo: cache-aside com Redis
def buscar_produtos_por_categoria(categoria_id):
    chave = f"cache:produtos:categoria:{categoria_id}"

    # 1. Tentar cache
    dados = redis.get(chave)
    if dados:
        return json.loads(dados)

    # 2. Cache miss: consultar banco
    produtos = db.query("SELECT * FROM produtos WHERE categoria_id = %s", [categoria_id])

    # 3. Popular cache com TTL de 5 minutos
    redis.setex(chave, 300, json.dumps(produtos))

    return produtos

Estrutura de chave recomendada:

cache:{schema}:{tabela}:{identificador_unico}
cache:publico:produtos:categoria_5
cache:publico:usuarios:perfil_123

TTL e expiração por evento:
- TTL fixo (ex: 5 minutos) para dados semi-estáticos
- Expiração forçada via evento: ao atualizar um produto, deletar a chave cache:produtos:*

5. Invalidação de Cache: O Grande Desafio

Invalidar cache é o problema mais difícil em ciência da computação (junto com nomear coisas). Três abordagens principais:

Invalidação manual pós-escrita:

# Após INSERT/UPDATE/DELETE em produtos
def atualizar_produto(produto_id, novos_dados):
    db.execute("UPDATE produtos SET ... WHERE id = %s", [produto_id])
    # Invalidar caches relacionados
    redis.delete(f"cache:produtos:id:{produto_id}")
    redis.delete(f"cache:produtos:categoria:{categoria_id}")  # cache de listas

Invalidação baseada em eventos:
- Triggers no banco que enviam notificações (LISTEN/NOTIFY no PostgreSQL)
- Listeners na aplicação que escutam canais e invalidam caches

Estratégia cache-aside com expiração forçada:
- Write-through: atualiza cache e banco simultaneamente (consistente, mas mais lento)
- Write-behind: atualiza banco, depois cache de forma assíncrona (mais rápido, risco de dados sujos)

6. Armadilhas e Boas Práticas

Stale reads (dados desatualizados):
- Mitigação: TTL conservador + invalidação por evento
- Para dados críticos: sempre ler do banco, usar cache apenas para não-críticos

Cache stampede (thundering herd):
- Múltiplas requisições simultâneas tentam popular o cache após expiração
- Proteção: lock distribuído (Redis SETNX) para que apenas uma requisição popule

# Exemplo com lock distribuído
def popular_cache_com_lock(chave, funcao_banco, ttl):
    lock_key = f"lock:{chave}"
    if redis.setnx(lock_key, "1", ex=10):  # lock por 10s
        try:
            dados = funcao_banco()
            redis.setex(chave, ttl, dados)
        finally:
            redis.delete(lock_key)
    else:
        time.sleep(0.1)
        return redis.get(chave)  # esperar lock liberar

Métricas de monitoramento:
- Hit ratio: % de requisições servidas pelo cache (ideal > 90%)
- Latência de cache: tempo de ida/volta ao Redis
- Taxa de invalidação: quantas chaves são deletadas por minuto

7. Casos de Uso Comuns e Exemplos

Cache de listas de produtos em e-commerce:

# Cache de categoria com filtros
chave = f"cache:produtos:categoria:{cat_id}:preco_entre:{min}_{max}:ordenacao:{ord}"
produtos_cacheados = redis.get(chave)

Cache de dados de sessão e perfis:

# Perfil de usuário (atualizado raramente)
chave = f"cache:usuarios:perfil:{user_id}"
perfil = redis.hgetall(chave)  # Redis hash para campos individuais

Cache de relatórios pesados:

# Relatório mensal de vendas por região (executa 10s no banco)
chave = f"cache:relatorios:vendas_mensais:{ano}:{mes}"
relatorio = redis.get(chave)
if not relatorio:
    relatorio = gerar_relatorio_vendas(ano, mes)
    redis.setex(chave, 3600, relatorio)  # TTL de 1 hora

8. Comparação com Alternativas no Nível do Banco

Connection pooling (PgBouncer) vs. cache de aplicação:
- Pooling reduz overhead de conexões, mas não acelera queries individuais
- Cache de aplicação complementa: pooling + cache = melhor performance

Read replicas vs. cache de query:
- Réplicas distribuem leituras, mas cada query ainda é executada
- Cache evita execução completamente — ideal para queries pesadas e repetitivas
- Use réplicas para queries que não podem ser cacheadas (dados em tempo real)

Combinação de estratégias:

# Estratégia híbrida: cache + réplica
1. Tentar cache Redis (latência ~1ms)
2. Se miss, consultar réplica de leitura (evita sobrecarregar primary)
3. Popular cache com TTL adequado

Referências