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
- Redis Documentation: Cache Pattern — Guia oficial do Redis sobre padrões de cache, incluindo cache-aside e invalidação
- PostgreSQL: LISTEN/NOTIFY for Cache Invalidation — Documentação oficial sobre notificações assíncronas para invalidar cache
- Hibernate Second-Level Cache — Documentação oficial sobre cache de segundo nível no Hibernate
- Django Cache Framework — Documentação oficial do Django sobre cache, incluindo integração com Redis e Memcached
- AWS ElastiCache Best Practices — Melhores práticas da AWS para cache distribuído com Redis/Memcached
- Martin Fowler: Cache-Aside Pattern — Artigo do Martin Fowler explicando o padrão cache-aside e suas variações