Introdução
A cache é uma das formas mais eficazes de melhorar o desempenho de uma API e reduzir a carga do servidor. Ao armazenar dados acedidos com frequência mais perto do consumidor, a cache reduz a latência, a carga na base de dados e os custos de largura de banda. Este guia aborda estratégias práticas de cache para APIs web.
Camadas de Cache
Client ──> CDN ──> API Gateway ──> Application ──> Database
↓ ↓ ↓ ↓
Browser Edge In-Memory Query Cache
Storage Cache (Redis)
Cada camada serve propósitos diferentes:
- Do lado do cliente: Reduz totalmente os pedidos
- CDN/Edge: Faz cache em pontos de distribuição geográfica
- Aplicação: Faz cache de resultados calculados
- Base de dados: Faz cache de resultados de queries
Cabeçalhos HTTP de Cache
Cache-Control
// Resposta do Laravel com cabeçalhos de cache
return response()
->json($data)
->header('Cache-Control', 'public, max-age=3600') // Cache durante 1 hora
->header('ETag', md5(json_encode($data)));
// Diretivas do Cache-Control
// public: a CDN e o browser podem fazer cache
// private: apenas o browser pode fazer cache
// no-cache: tem de revalidar antes de usar a cache
// no-store: não fazer cache de todo
// max-age: segundos até ficar stale
// s-maxage: idade máxima para caches partilhadas (CDN)
ETags e Pedidos Condicionais
// Middleware do Laravel para suporte de ETag
class ETagMiddleware
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->isMethod('GET')) {
$etag = md5($response->getContent());
$response->header('ETag', '"' . $etag . '"');
// Verificar o cabeçalho If-None-Match
$requestEtag = $request->header('If-None-Match');
if ($requestEtag && $requestEtag === '"' . $etag . '"') {
return response()->noContent(304);
}
}
return $response;
}
}
Last-Modified
public function show(Product $product)
{
$lastModified = $product->updated_at;
// Verificar o cabeçalho If-Modified-Since
$ifModifiedSince = request()->header('If-Modified-Since');
if ($ifModifiedSince) {
$ifModifiedSince = Carbon::parse($ifModifiedSince);
if ($lastModified <= $ifModifiedSince) {
return response()->noContent(304);
}
}
return response()
->json($product)
->header('Last-Modified', $lastModified->toRfc7231String())
->header('Cache-Control', 'private, max-age=60');
}
Cache ao Nível da Aplicação
Cache Redis com Laravel
// Cache básica
$products = Cache::remember('products.all', 3600, function () {
return Product::with('category')->get();
});
// Cache com tags (para invalidação)
$product = Cache::tags(['products'])->remember(
"products.{$id}",
3600,
function () use ($id) {
return Product::with('reviews')->find($id);
}
);
// Invalidar por tag
Cache::tags(['products'])->flush();
// Cache com lock (evitar stampede)
$value = Cache::lock('processing')->get(function () {
return expensiveOperation();
});
Padrões de Cache
Cache-Aside (Lazy Loading)
class ProductRepository
{
public function find(int $id): ?Product
{
$cacheKey = "product:{$id}";
// Tentar a cache primeiro
$product = Cache::get($cacheKey);
if ($product === null) {
// Cache miss — carregar da base de dados
$product = Product::find($id);
if ($product) {
Cache::put($cacheKey, $product, 3600);
}
}
return $product;
}
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Invalidar a cache
Cache::forget("product:{$id}");
return $product;
}
}
Write-Through
public function update(int $id, array $data): Product
{
$product = Product::findOrFail($id);
$product->update($data);
// Atualizar a cache imediatamente
Cache::put("product:{$id}", $product, 3600);
return $product;
}
Cache-Aside com Stale-While-Revalidate
public function getProducts(): array
{
$cacheKey = 'products.list';
$staleKey = 'products.list.stale';
// Verificar cache fresca
$fresh = Cache::get($cacheKey);
if ($fresh !== null) {
return $fresh;
}
// Verificar cache stale e acionar atualização em background
$stale = Cache::get($staleKey);
if ($stale !== null) {
// Despachar job para atualizar a cache
RefreshProductsCacheJob::dispatch();
return $stale;
}
// Sem cache — obter e fazer cache
$products = $this->fetchProducts();
Cache::put($cacheKey, $products, 60); // Fresco durante 1 minuto
Cache::put($staleKey, $products, 3600); // Stale durante 1 hora
return $products;
}
Cache de Resultados de Queries
Cache de Queries da Base de Dados
// Cache de queries no Laravel
$products = Product::where('active', true)
->orderBy('name')
->cacheFor(now()->addMinutes(30))
->cacheTags(['products'])
->get();
Cache de Valores Calculados
class DashboardService
{
public function getStatistics(): array
{
return Cache::remember('dashboard.stats', 300, function () {
return [
'total_orders' => Order::count(),
'revenue' => Order::sum('total'),
'avg_order_value' => Order::avg('total'),
'top_products' => $this->getTopProducts(),
];
});
}
private function getTopProducts(): Collection
{
return Product::withCount('orderItems')
->orderByDesc('order_items_count')
->limit(10)
->get();
}
}
Invalidação de Cache
Invalidação Baseada em Eventos
// Listener de eventos
class InvalidateProductCache
{
public function handle(ProductUpdated $event): void
{
$product = $event->product;
// Limpar produto específico
Cache::forget("product:{$product->id}");
// Limpar caches relacionadas
Cache::tags(['products', 'categories'])->flush();
// Limpar caches de listagem
Cache::forget('products.list');
Cache::forget("category:{$product->category_id}.products");
}
}
// Observer do model
class ProductObserver
{
public function saved(Product $product): void
{
Cache::forget("product:{$product->id}");
}
public function deleted(Product $product): void
{
Cache::forget("product:{$product->id}");
}
}
Invalidação Baseada no Tempo
// TTL curto para dados que mudam frequentemente
Cache::put('active_users', $count, 60); // 1 minuto
// TTL mais longo para dados estáveis
Cache::put('site_settings', $settings, 86400); // 1 dia
// TTL progressivo com base na idade
$ttl = min(86400, $product->created_at->diffInSeconds(now()) / 10);
Cache::put("product:{$id}", $product, $ttl);
Cache em CDN
Cabeçalhos de Cache do Cloudflare
// Fazer cache na CDN durante 1 hora
return response()
->json($data)
->header('Cache-Control', 'public, s-maxage=3600, max-age=60');
// Variar por cabeçalho Accept (JSON vs. HTML)
return response()
->json($data)
->header('Cache-Control', 'public, max-age=300')
->header('Vary', 'Accept, Accept-Encoding');
Limpeza da Cache da CDN
// API do Cloudflare
use Illuminate\Support\Facades\Http;
class CloudflareService
{
public function purgeUrls(array $urls): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
'Content-Type' => 'application/json',
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['files' => $urls]
);
}
public function purgeByTags(array $tags): void
{
Http::withHeaders([
'Authorization' => 'Bearer ' . config('services.cloudflare.token'),
])->delete(
"https://api.cloudflare.com/client/v4/zones/{$this->zoneId}/purge_cache",
['tags' => $tags]
);
}
}
Compressão de Respostas
// Configuração do Nginx
gzip on;
gzip_types application/json text/plain application/xml;
gzip_min_length 1000;
// Middleware do Laravel para compressão de JSON
class CompressResponse
{
public function handle($request, Closure $next)
{
$response = $next($request);
if ($request->header('Accept-Encoding') &&
str_contains($request->header('Accept-Encoding'), 'gzip')) {
$content = gzencode($response->getContent(), 6);
$response->setContent($content);
$response->header('Content-Encoding', 'gzip');
$response->header('Content-Length', strlen($content));
}
return $response;
}
}
Cache do Lado do Cliente
Controlo de Cache do Browser
// Service Worker para cache offline
self.addEventListener('fetch', (event) => {
if (event.request.url.includes('/api/')) {
event.respondWith(
caches.open('api-cache').then((cache) => {
return fetch(event.request)
.then((response) => {
cache.put(event.request, response.clone());
return response;
})
.catch(() => cache.match(event.request));
})
);
}
});
Cache do React Query
import { useQuery, QueryClient } from '@tanstack/react-query';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutos
cacheTime: 30 * 60 * 1000, // 30 minutos
},
},
});
function useProducts() {
return useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then(r => r.json()),
staleTime: 60000, // Considerar fresco durante 1 minuto
});
}
Monitorização e Métricas
// Acompanhar o desempenho da cache
class CacheMetrics
{
public function get(string $key)
{
$start = microtime(true);
$value = Cache::get($key);
$duration = microtime(true) - $start;
if ($value !== null) {
Metrics::increment('cache.hits', ['key' => $key]);
} else {
Metrics::increment('cache.misses', ['key' => $key]);
}
Metrics::timing('cache.latency', $duration);
return $value;
}
}
O Problema do «Thundering Herd»
Cenário: Uma key popular (por exemplo, homepage_news) expira às 12:00:00. Resultado: 10.000 utilizadores pedem-na às 12:00:01. Todos os 10.000 pedidos atingem a base de dados em simultâneo. A base de dados falha.
Solução 1: Expiração Antecipada Probabilística (Jitter)
Em vez de uma expiração rígida, armazene um valor delta. Se (now + delta) > expiration, recalcule o valor antes de ele expirar efetivamente:
public function getCachedValue(string $key, int $ttl, callable $compute)
{
$cached = Cache::get($key);
if ($cached) {
// Verificar se devemos atualizar proativamente
$expiresAt = Cache::get("{$key}:expires_at");
$delta = rand(0, 60); // Jitter aleatório
if ((time() + $delta) > $expiresAt) {
// Atualização proativa em background
dispatch(new RefreshCacheJob($key, $compute));
}
return $cached;
}
$value = $compute();
Cache::put($key, $value, $ttl);
Cache::put("{$key}:expires_at", time() + $ttl, $ttl + 60);
return $value;
}
Solução 2: Bloqueio de Cache
Quando ocorre um cache miss: 1. Adquirir um Redis Lock (SETNX lock:key 1) 2. Se estiver bloqueado por outro processo, dormir 50 ms e voltar a tentar no Redis 3. Se for adquirido, calcular o valor, atualizar o Redis, libertar o lock
public function getCachedValueWithLock(string $key, callable $compute)
{
$value = Cache::get($key);
if ($value !== null) {
return $value;
}
// Tentar adquirir o lock
$lock = Cache::lock("lock:{$key}", 10);
if ($lock->get()) {
try {
// Verificação dupla após adquirir o lock
$value = Cache::get($key);
if ($value === null) {
$value = $compute();
Cache::put($key, $value, 3600);
}
} finally {
$lock->release();
}
} else {
// Aguardar que outro processo preencha a cache
usleep(50000); // 50 ms
$value = Cache::get($key);
}
return $value;
}
Políticas de Evicção do Redis
Quando o Redis está cheio, o que acontece depende da sua política de evicção:
noeviction: Devolve erro ao escrever. (Mau para caches)allkeys-lru: Remove as keys menos recentemente usadas. (Padrão para caching)volatile-lru: Remove keys LRU com um expire definido. (Bom se usar o Redis tanto para dados persistentes como para cache)
Defina a política adequada na configuração do Redis:
maxmemory-policy allkeys-lru
Estruturas Avançadas do Redis
Para além de uma cache simples de key-value:
- Sorted Sets (ZSET): Ótimo para leaderboards.
ZADD scores 100 "user1" - HyperLogLog: Conta visitantes únicos com 99% de precisão, usando apenas 12 KB de RAM (vs. megabytes para um Set)
Boas Práticas
- Fazer cache ao nível certo - mais perto do cliente é mais rápido
- Usar TTLs apropriados - equilibrar frescura com desempenho
- Implementar invalidação de cache - baseada em eventos é a mais fiável
- Monitorizar taxas de cache hit - apontar para >90%
- Evitar fazer cache de dados específicos do utilizador em camadas partilhadas
- Usar cache tags para invalidação em grupo
- Implementar fallbacks para falhas de cache
- Considerar cache warming para dados críticos
- Adicionar TTL jitter para evitar expirações sincronizadas
- Tratar o Redis como volátil - a sua aplicação tem de funcionar se o Redis for limpo
Conclusão
Uma cache eficaz melhora drasticamente o desempenho de uma API. Aplique cache em camadas de forma adequada — cabeçalhos HTTP para CDN e browser, Redis para dados da aplicação e cache de queries para resultados da base de dados. Implemente estratégias de invalidação corretas, lide com «thundering herds» com locking ou jitter e monitorize as taxas de hit para garantir que a cache é eficaz. Lembre-se: o Redis é uma camada de otimização, não uma fonte de verdade.