Cache em camadas: browser, CDN, aplicação e banco de dados

1. Introdução à arquitetura de cache em camadas

Cache não é bala de prata. Em sistemas distribuídos, a latência de rede, o custo de armazenamento e a complexidade de consistência tornam o design de cache um dos desafios mais sutis da engenharia de software. A arquitetura de cache em camadas busca endereçar esses problemas explorando o princípio da localidade: dados acessados recentemente tendem a ser acessados novamente, e dados próximos no espaço de endereçamento também.

Cada camada opera em uma faixa de velocidade distinta:
- Browser cache: milissegundos (memória local)
- CDN: dezenas de milissegundos (rede de borda)
- Aplicação: microssegundos a milissegundos (RAM)
- Banco de dados: submilissegundos (buffer pool)

A hierarquia é clara: quanto mais próximo do usuário, mais rápido e mais caro por byte. O desafio é decidir o que armazenar em cada nível.

2. Cache no browser: a primeira linha de defesa

O navegador é a camada mais próxima do usuário e a mais barata — não custa nada para o servidor. O HTTP caching é a base:

Cache-Control: public, max-age=3600, immutable
ETag: "abc123"
Last-Modified: Tue, 15 Mar 2025 12:00:00 GMT

O cabeçalho immutable indica que o recurso não muda entre versões, evitando revalidações desnecessárias. Para recursos dinâmicos, a estratégia stale-while-revalidate permite servir conteúdo envelhecido enquanto atualiza em segundo plano:

Cache-Control: max-age=300, stale-while-revalidate=600

Service Workers ampliam esse controle com a Cache API:

self.addEventListener('fetch', event => {
  event.respondWith(
    caches.match(event.request).then(cached => {
      const fetchPromise = fetch(event.request).then(response => {
        caches.put(event.request, response.clone());
        return response;
      });
      return cached || fetchPromise;
    })
  );
});

Armadilha comum: versionar assets com hash no nome do arquivo (app.abc123.js) evita que o browser sirva versões antigas após deploy.

3. CDN: distribuindo conteúdo globalmente

CDNs (Content Delivery Networks) colocam servidores de borda próximos geograficamente aos usuários. O fluxo típico:

  1. Usuário solicita https://cdn.exemplo.com/img/foto.jpg
  2. DNS resolve para o edge server mais próximo
  3. Se cache hit, serve imediatamente
  4. Se cache miss, busca no origin server e armazena

As zonas de cache podem ser pull (o CDN busca do origin sob demanda) ou push (você envia os arquivos antecipadamente). A invalidação é crítica:

# Purga por URL
curl -X POST https://api.cloudflare.com/client/v4/zones/ZONE_ID/purge_cache \
  -H "Authorization: Bearer TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"files":["https://exemplo.com/img/foto.jpg"]}'

# Purga por tag
Cache-Tag: banner-principal, promocao-2025

Edge computing (Cloudflare Workers, Lambda@Edge) permite cache de conteúdo dinâmico:

// Cloudflare Worker: cache de API com TTL variável
async function handleRequest(request) {
  const url = new URL(request.url);
  const cacheKey = new Request(url.toString(), request);
  const cache = caches.default;

  let response = await cache.match(cacheKey);
  if (!response) {
    response = await fetch(request);
    const ttl = url.pathname.startsWith('/api/status') ? 10 : 3600;
    response = new Response(response.body, response);
    response.headers.set('Cache-Control', `public, max-age=${ttl}`);
    cache.put(cacheKey, response.clone());
  }
  return response;
}

4. Cache na aplicação: memória, Redis e padrões

A aplicação é onde o controle fino acontece. Cache em memória local (in-process) é rápido mas não escala entre instâncias. Cache distribuído (Redis, Memcached) resolve isso.

Padrões comuns:

Cache-Aside (Lazy Loading):

function getUser(id):
    key = "user:" + id
    user = cache.get(key)
    if user is None:
        user = database.query("SELECT * FROM users WHERE id = ?", id)
        cache.set(key, user, ttl=3600)
    return user

Read-Through (cache gerencia a busca):

cache.get("user:123", callback=lambda: database.query(...))

Write-Through (atualiza cache e banco simultaneamente):

function updateUser(id, data):
    database.update("users", data, where="id = ?", id)
    cache.set("user:" + id, data)

Write-Behind (atualiza cache imediatamente, banco assíncrono):

function updateUser(id, data):
    cache.set("user:" + id, data)
    queue.enqueue(lambda: database.update(...))

Políticas de evicção: LRU (menos recentemente usado) para padrão, LFU (menos frequente) para hotspots, FIFO para filas. TTLs devem ser baseados na natureza dos dados:

# Chave composta para cache de feed
key = f"feed:{user_id}:{page}:{timestamp}"
cache.set(key, feed_data, ttl=300)  # 5 minutos

5. Cache no banco de dados: buffer pool e query cache

O banco de dados gerencia seu próprio cache no buffer pool (MySQL InnoDB) ou shared buffers (PostgreSQL). Páginas de dados e índices são mantidas em RAM para acesso rápido:

# MySQL: verificar tamanho do buffer pool
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
# Saída: 134217728 (128 MB)

# PostgreSQL: verificar shared buffers
SHOW shared_buffers;
# Saída: 128MB

O query cache (MySQL) armazena resultados de consultas SELECT idênticas. Porém, em tabelas com alta taxa de escrita, o overhead de invalidação supera o ganho:

# Desabilitar query cache em MySQL 8.0+
query_cache_type = 0
query_cache_size = 0

Materialized Views funcionam como cache estrutural no PostgreSQL:

CREATE MATERIALIZED VIEW daily_sales AS
SELECT DATE(created_at), SUM(amount)
FROM orders
GROUP BY DATE(created_at);

REFRESH MATERIALIZED VIEW daily_sales;  -- Atualização manual

Índices também são uma forma de cache — mantêm dados ordenados em RAM para buscas rápidas.

6. Consistência entre camadas: o problema do cache stampede

O paradoxo de “dois programadores” (invalidação de cache é um dos dois problemas difíceis da computação) se manifesta no cache stampede: quando um item expira e milhares de requisições simultâneas batem no banco.

Mitigações:

Lock distribuído (Redis Redlock):

function getData(key):
    data = cache.get(key)
    if data is None:
        lock = acquire_lock("lock:" + key, ttl=5)
        if lock:
            data = database.query(...)
            cache.set(key, data, ttl=3600)
            release_lock(lock)
        else:
            sleep(0.1)
            return getData(key)  # retry
    return data

Backoff exponencial:

for attempt in range(3):
    data = cache.get(key)
    if data: break
    sleep(0.1 * (2 ** attempt))
    data = database.query(...)

Cache warming: pré-carregar dados críticos após deploy:

for user in active_users:
    cache.set(f"profile:{user.id}", generate_profile(user), ttl=3600)

7. Monitoramento e métricas de eficiência

Métricas essenciais:

Métrica Fórmula Interpretação
Hit ratio hits / (hits + misses) >90% é excelente
Miss rate misses / total >10% indica problema
Latência média soma tempos / total Comparar com e sem cache
Taxa de expiração expirações / total Ajustar TTL

Ferramentas:

# Redis: monitorar hit ratio
redis-cli INFO stats | grep keyspace_hits
keyspace_hits:1234567
keyspace_misses:12345

# OpenTelemetry: tracing de cache
span.set_attribute("cache.hit", true)
span.set_attribute("cache.key", key)

# Grafana: dashboard com painéis de hit ratio por camada

Depuração: simular carga com wrk ou k6 e monitorar picos de latência. Se o hit ratio cai abruptamente, ajuste TTLs ou verifique invalidação em cascata.

8. Conclusão e boas práticas para projetos reais

Checklist para decisão:

Camada O que cachear O que não cachear
Browser Assets estáticos, imagens, CSS/JS Dados sensíveis, conteúdo personalizado
CDN Conteúdo público, APIs GET POST, dados de usuário
Aplicação Resultados de queries, sessões Dados voláteis, transações
Banco Páginas quentes, índices Tabelas inteiras sem critério

Trade-offs finais:
- Cache em mais camadas aumenta complexidade de invalidação
- Cache em menos camadas aumenta latência para o usuário
- Custo de infraestrutura (RAM, CDN) vs. ganho de performance

A regra de ouro: nunca otimize sem medir. Implemente cache apenas onde o gargalo for confirmado por métricas.

Referências