Cache é a diferença entre uma query de 200ms que roda mil vezes por minuto e uma consulta que roda uma vez a cada cinco minutos. Bem implementado, reduz carga no banco em 90% e latência de resposta em 10x. Mal implementado, serve dados desatualizados, consome memória inutilmente ou gera bugs difíceis de reproduzir.
Neste artigo estudamos as três estratégias principais — cache-aside, write-through e write-behind — com implementações PHP completas. Adicionamos invalidação por tags, TTL adaptativo, e um sistema de bloqueio para evitar o problema do cache stampede quando múltiplas requisições tentam regenerar o mesmo cache simultaneamente.
Interface e contrato
<?php
declare(strict_types=1);
namespace App\Cache;
interface CacheInterface
{
public function get(string $chave): mixed;
public function set(string $chave, mixed $valor, int $ttl = 3600): void;
public function delete(string $chave): void;
public function has(string $chave): bool;
// Busca ou gera — padrão cache-aside em uma linha
public function remember(string $chave, int $ttl, callable $gerador): mixed;
// Invalidação por tag
public function taggar(string $chave, string ...$tags): void;
public function invalidarTag(string $tag): void;
public function invalidarTags(string ...$tags): void;
}
Implementação Redis
<?php
declare(strict_types=1);
namespace App\Cache;
class RedisCache implements CacheInterface
{
private const TAG_PREFIX = 'cache:tag:';
private const LOCK_PREFIX = 'cache:lock:';
private const LOCK_TTL = 10; // segundos
public function __construct(
private readonly \Redis $redis,
private readonly string $prefixo = 'app:',
) {}
public function get(string $chave): mixed
{
$raw = $this->redis->get($this->prefixar($chave));
if ($raw === false) return null;
$dados = json_decode($raw, associative: true);
return $dados['v'] ?? null;
}
public function set(string $chave, mixed $valor, int $ttl = 3600): void
{
$raw = json_encode(['v' => $valor], JSON_UNESCAPED_UNICODE);
$this->redis->setex($this->prefixar($chave), $ttl, $raw);
}
public function delete(string $chave): void
{
$this->redis->del($this->prefixar($chave));
}
public function has(string $chave): bool
{
return (bool) $this->redis->exists($this->prefixar($chave));
}
// Cache-aside em uma linha: busca, e se não existir, gera e armazena
public function remember(string $chave, int $ttl, callable $gerador): mixed
{
$valor = $this->get($chave);
if ($valor !== null) return $valor;
$valor = $gerador();
if ($valor !== null) {
$this->set($chave, $valor, $ttl);
}
return $valor;
}
// Remember com proteção contra cache stampede
public function rememberLock(string $chave, int $ttl, callable $gerador): mixed
{
$valor = $this->get($chave);
if ($valor !== null) return $valor;
$chavelock = self::LOCK_PREFIX . $chave;
// Tenta adquirir o lock (SET NX EX é atômico no Redis)
$adquiriu = $this->redis->set($chavelock, '1', ['NX', 'EX' => self::LOCK_TTL]);
if (!$adquiriu) {
// Outro processo está gerando — espera e tenta o cache novamente
usleep(100_000); // 100ms
return $this->get($chave) ?? $gerador(); // fallback se ainda não estiver pronto
}
try {
$valor = $gerador();
if ($valor !== null) {
$this->set($chave, $valor, $ttl);
}
return $valor;
} finally {
$this->redis->del($chavelock);
}
}
public function taggar(string $chave, string ...$tags): void
{
foreach ($tags as $tag) {
// Cada tag aponta para um conjunto de chaves
$this->redis->sAdd(self::TAG_PREFIX . $tag, $this->prefixar($chave));
}
}
public function invalidarTag(string $tag): void
{
$chaveTag = self::TAG_PREFIX . $tag;
$chaves = $this->redis->sMembers($chaveTag);
if (!empty($chaves)) {
$this->redis->del(...$chaves); // deleta todas as chaves da tag
}
$this->redis->del($chaveTag); // limpa o conjunto de tags
}
public function invalidarTags(string ...$tags): void
{
foreach ($tags as $tag) {
$this->invalidarTag($tag);
}
}
// Incrementa/decrementa contadores sem sobrescrever o cache completo
public function incrementar(string $chave, int $por = 1): int
{
return (int) $this->redis->incrBy($this->prefixar($chave), $por);
}
public function decrementar(string $chave, int $por = 1): int
{
return (int) $this->redis->decrBy($this->prefixar($chave), $por);
}
// TTL restante em segundos (-1 = sem expiração, -2 = não existe)
public function ttlRestante(string $chave): int
{
return (int) $this->redis->ttl($this->prefixar($chave));
}
// Estende o TTL de uma chave existente sem alterar o valor
public function renovar(string $chave, int $novoTtl): bool
{
return (bool) $this->redis->expire($this->prefixar($chave), $novoTtl);
}
private function prefixar(string $chave): string
{
return $this->prefixo . $chave;
}
}
Estratégias de cache
<?php
declare(strict_types=1);
namespace App\Cache;
use App\Domain\Produto\Produto;
// ── Cache-Aside (Lazy Loading) ────────────────────────────────────────
// Lê do cache; se não existir, lê do banco e armazena no cache.
// Padrão mais comum — adequado para leitura pesada.
class ProdutoRepositorioComCache
{
public function __construct(
private readonly ProdutoRepositorioPDO $repositorio,
private readonly RedisCache $cache,
) {}
public function buscarPorId(int $id): ?Produto
{
return $this->cache->remember(
chave: "produto:{$id}",
ttl: 3600,
gerador: fn() => $this->repositorio->buscarPorId($id),
);
}
public function listarAtivos(int $pagina = 1, int $porPagina = 20): array
{
$chave = "produtos:ativos:p{$pagina}:n{$porPagina}";
return $this->cache->remember(
chave: $chave,
ttl: 300, // 5 minutos — lista muda com frequência
gerador: function () use ($pagina, $porPagina) {
$produtos = $this->repositorio->listarAtivos($pagina, $porPagina);
return array_map(fn($p) => $this->serializar($p), $produtos);
},
);
}
public function salvar(Produto $produto): Produto
{
$salvo = $this->repositorio->salvar($produto);
// Invalida cache do produto específico
$this->cache->delete("produto:{$salvo->id}");
// Invalida todas as páginas de listagem usando tags
$this->cache->invalidarTag('produtos:lista');
return $salvo;
}
public function deletar(int $id): void
{
$this->repositorio->deletar($id);
$this->cache->delete("produto:{$id}");
$this->cache->invalidarTag('produtos:lista');
}
private function serializar(Produto $p): array
{
return ['id' => $p->id, 'nome' => $p->nome, 'preco' => $p->preco];
}
}
// ── Write-Through ─────────────────────────────────────────────────────
// Escreve no cache E no banco simultaneamente.
// Garante que o cache nunca está desatualizado para escrita.
class ConfiguracaoService
{
private const TTL = 86400; // 1 dia
public function __construct(
private readonly \PDO $pdo,
private readonly RedisCache $cache,
) {}
public function obter(string $chave, mixed $padrao = null): mixed
{
return $this->cache->remember(
chave: "config:{$chave}",
ttl: self::TTL,
gerador: function () use ($chave, $padrao) {
$stmt = $this->pdo->prepare('SELECT valor FROM configuracoes WHERE chave = ?');
$stmt->execute([$chave]);
$row = $stmt->fetch(\PDO::FETCH_ASSOC);
return $row ? json_decode($row['valor'], true) : $padrao;
}
);
}
public function definir(string $chave, mixed $valor): void
{
// Write-through: grava no banco primeiro, depois atualiza o cache
$stmt = $this->pdo->prepare(
'INSERT INTO configuracoes (chave, valor) VALUES (?, ?)
ON DUPLICATE KEY UPDATE valor = VALUES(valor)'
);
$stmt->execute([$chave, json_encode($valor)]);
// Cache atualizado imediatamente — sem invalidação, sem cache miss
$this->cache->set("config:{$chave}", $valor, self::TTL);
}
}
// ── Cache de fragmentos HTML ──────────────────────────────────────────
// Armazena trechos de HTML renderizados — útil para templates pesados.
class FragmentCache
{
public function __construct(private readonly RedisCache $cache) {}
public function renderizar(string $nome, int $ttl, callable $renderizador): string
{
$html = $this->cache->get("fragment:{$nome}");
if ($html !== null) return $html;
ob_start();
$renderizador();
$html = ob_get_clean();
$this->cache->set("fragment:{$nome}", $html, $ttl);
return $html;
}
}
TTL adaptativo e aquecimento de cache
<?php
declare(strict_types=1);
namespace App\Cache;
// TTL adaptativo — ajusta a expiração com base na frequência de acesso
class CacheAdaptativo
{
public function __construct(
private readonly RedisCache $cache,
private readonly int $ttlBase = 300,
) {}
public function obter(string $chave, callable $gerador): mixed
{
$valor = $this->cache->get($chave);
if ($valor !== null) {
// Registra o acesso e estende o TTL se a chave for popular
$acessos = $this->cache->incrementar("hits:{$chave}");
$ttlAtual = $this->cache->ttlRestante($chave);
if ($acessos > 100 && $ttlAtual < 60) {
// Chave muito acessada e prestes a expirar — renova proativamente
$this->cache->renovar($chave, $this->ttlBase);
}
return $valor;
}
$valor = $gerador();
if ($valor !== null) {
$this->cache->set($chave, $valor, $this->ttlBase);
}
return $valor;
}
}
// Aquecimento de cache — preenche o cache antes do tráfego chegar
class AquecedorDeCache
{
public function __construct(
private readonly RedisCache $cache,
private readonly ProdutoRepositorioPDO $repositorio,
) {}
// Executado via cron antes do horário de pico ou após deploy
public function aquecer(): void
{
error_log('[Cache] Iniciando aquecimento...');
$inicio = microtime(true);
// Carrega os 100 produtos mais acessados
$produtos = $this->repositorio->maisAcessados(100);
foreach ($produtos as $produto) {
$this->cache->set("produto:{$produto->id}", $produto, 7200);
}
// Pré-renderiza as primeiras 5 páginas da listagem
for ($pagina = 1; $pagina <= 5; $pagina++) {
$lista = $this->repositorio->listarAtivos($pagina, 20);
$this->cache->set("produtos:ativos:p{$pagina}:n20", $lista, 3600);
}
$duracao = round(microtime(true) - $inicio, 2);
error_log("[Cache] Aquecimento concluído em {$duracao}s — " . count($produtos) . " produtos carregados.");
}
}
Resumo do artigo
| Estratégia | Quando usar | Trade-off |
|---|---|---|
| Cache-Aside | Leitura pesada, dados raramente alterados | Cache miss na primeira leitura |
| Write-Through | Escrita frequente, zero tolerância a dados velhos | Escrita mais lenta (banco + cache) |
| Write-Behind | Escrita muito frequente (contadores, likes) | Risco de perda em falha |
| Invalidação por tag | Vários caches dependem de uma entidade | Complexidade de mapeamento |
| Cache Stampede Lock | Alto tráfego, geração cara | Pequeno delay para requisições concorrentes |
Exercício da semana
-
Implemente
CacheDecoratorque envolve qualquerRepositorioInterfacee adiciona cache transparente viaremember(). Configure via atributo PHP:#[Cacheable(ttl: 3600, tags: ['produto'])]nos métodos de leitura. -
Crie
MonitorDeCacheque registra métricas com Redis:cache:hits,cache:misses,cache:hit_ratioviaINCReGET. Exponha via endpoint/metrics/cacheretornando JSON com taxa de acerto, total de chaves e memória usada (INFO memory). -
Implemente write-behind para o sistema de visualizações de produto:
registrarVisualizacao(int $produtoId)incrementa um contador no Redis comHINCRBY. Um job (Artigo 29) consolidada os contadores no banco a cada 5 minutos viaHGETALLe reset. -
Adicione
stale-while-revalidateaoRedisCache: ao expirar, sirva o valor antigo imediatamente e agende regeneração assíncrona via fila (Artigo 29). ImplementesetComStale(chave, valor, ttlFresh, ttlStale)que usa dois TTLs. -
Desafio: implemente cache de dois níveis (
L1 + L2): L1 em APCu (processo local, ultra-rápido, curto TTL) e L2 em Redis (compartilhado entre processos, TTL longo).get()tenta L1, depois L2, depois o gerador.set()popula ambos. Escreva testes com mocks para os dois níveis.
Referências
- Redis — String Commands (SET, GET, SETEX): https://redis.io/docs/commands/#string
- Redis — SET with NX/EX (atomic lock): https://redis.io/docs/commands/set/
- Martin Fowler — Cache-Aside Pattern: https://martinfowler.com/bliki/TwoHardThings.html
- PHP Manual — APCu: https://www.php.net/manual/pt_BR/book.apcu.php