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. Esperar uma vez só e cair no gerador
// anularia a proteção: num pico, as mil requisições que não
// pegaram o lock chamariam o gerador juntas 100 ms depois — o mesmo
// stampede que este método existe para evitar, só que atrasado.
// Por isso a espera é em janelas curtas, até o valor aparecer.
for ($tentativa = 0; $tentativa < 20; $tentativa++) {
usleep(50_000); // 50ms
$valor = $this->get($chave);
if ($valor !== null) return $valor;
}
// Um segundo sem valor: quem segurava o lock provavelmente morreu no
// meio. Gerar aqui é o mal menor — e agora são poucas requisições
// chegando a este ponto, não todas de uma vez.
return $gerador();
}
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}";
$lista = $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);
},
);
// Sem esta linha a invalidação por tag não invalida nada: o
// invalidarTag() lê um conjunto que ninguém preencheu, não encontra
// chave alguma e retorna em silêncio. As páginas de listagem
// continuariam velhas até o TTL vencer sozinho.
$this->cache->taggar($chave, 'produtos:lista');
return $lista;
}
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);
}
}
// ── Write-Behind (Write-Back) ─────────────────────────────────────────
// Escreve só no cache e adia a ida ao banco. É a estratégia dos contadores
// — visualizações, likes, cliques —, onde o volume de escrita é alto e
// perder alguns segundos de dados numa queda é aceitável. Para saldo em
// conta ou baixa de estoque, não é: ali o banco precisa saber na hora.
class ContadorWriteBehind
{
private const FILA_SUJOS = 'contadores:sujos';
public function __construct(
private readonly \Redis $redis,
private readonly \PDO $pdo,
private readonly string $prefixo = 'contador:',
) {}
// O caminho quente: um INCR no Redis e mais nada. Sub-milissegundo.
public function incrementar(string $entidade, int $id, int $por = 1): int
{
$chave = "{$this->prefixo}{$entidade}:{$id}";
$novo = (int) $this->redis->incrBy($chave, $por);
// Marca a chave como pendente de gravação. O SADD é idempotente:
// mil incrementos na mesma entidade viram um único item na fila.
$this->redis->sAdd(self::FILA_SUJOS, $chave);
return $novo;
}
public function valor(string $entidade, int $id): int
{
$raw = $this->redis->get("{$this->prefixo}{$entidade}:{$id}");
return $raw === false ? 0 : (int) $raw;
}
// Chamado por cron ou pelo worker, a cada 30s ou a cada minuto.
public function descarregar(): int
{
$gravados = 0;
// sPop remove o item do conjunto de forma atômica: com vários
// processos descarregando ao mesmo tempo, cada chave sai para um só.
while (($chave = $this->redis->sPop(self::FILA_SUJOS)) !== false) {
$valor = $this->redis->get($chave);
if ($valor === false) continue; // expirou entre o pop e a leitura
[, $entidade, $id] = explode(':', $chave);
// Grava o total absoluto, não o incremento. É o que torna o
// descarregar() seguro de repetir: um incremento que chegue entre
// o sPop e o INSERT recoloca a chave na fila e será gravado na
// próxima rodada, com o valor já acumulado.
$this->pdo->prepare(
'INSERT INTO contadores (entidade, entidade_id, total)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE total = VALUES(total)'
)->execute([$entidade, (int) $id, (int) $valor]);
$gravados++;
}
return $gravados;
}
}
// ── 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}");
// O contador precisa de prazo próprio. Sem isto, cada chave de cache
// deixa para trás um "hits:" eterno no Redis, e a contagem nunca
// reinicia — passados os 100 acessos, a chave passa a ser renovada
// sempre e o cache deixa de expirar de verdade.
if ($acessos === 1) {
$this->cache->renovar("hits:{$chave}", $this->ttlBase * 2);
}
$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.");
}
}
A escolha entre as três estratégias é menos sobre performance e mais sobre o que você aceita perder. Cache-aside aceita a primeira leitura lenta; write-through aceita a escrita mais cara em troca de nunca servir dado velho; write-behind aceita perder alguns segundos de contagem se o processo cair. Nenhuma delas é a correta em abstrato — a pergunta certa é qual inconsistência o seu domínio tolera, e a resposta para um contador de visualizações não serve para um saldo em conta.
Duas armadilhas deste artigo aparecem em qualquer camada de cache. A invalidação por tag só funciona se alguém registrar a chave na tag — esquecer o taggar() deixa um invalidarTag() que roda, não encontra nada e devolve sem reclamar. E o lock contra stampede perde o sentido se, ao não consegui-lo, o código chamar o gerador assim mesmo: o tropel volta inteiro, só que atrasado.
Fontes e leituras recomendadas
- 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 — Two Hard Things, sobre invalidação de cache ser um dos dois problemas difíceis: https://martinfowler.com/bliki/TwoHardThings.html
- PHP Manual — APCu: https://www.php.net/manual/pt_BR/book.apcu.php
- Redis — politicas de despejo (
maxmemory-policy), o que decide quem sai quando a memoria acaba: https://redis.io/docs/latest/develop/reference/eviction/ - phpredis — a extensao nativa, mais rapida que o cliente em PHP puro: https://github.com/phpredis/phpredis
Exercícios
Exercício 1
Implemente CacheDecorator que envolve qualquer RepositorioInterface e adiciona cache transparente via remember(). Configure via atributo PHP: #[Cacheable(ttl: 3600, tags: ['produto'])] nos métodos de leitura.
Ver resposta
✓ Resposta: Duas decisões importam aqui: a chave inclui os argumentos (buscarPorId(1) e (2) não podem colidir) e o metadado da Reflection é lido uma vez e guardado. Sem esse cache da reflexão, o "cache" custaria mais caro que a consulta que pretende evitar.
<?php
declare(strict_types=1);
#[Attribute(Attribute::TARGET_METHOD)]
final class Cacheable
{
public function __construct(
public readonly int $ttl = 3600,
/** @var string[] */
public readonly array $tags = [],
) {}
}
#[Attribute(Attribute::TARGET_METHOD)]
final class InvalidaCache
{
/** @param string[] $tags */
public function __construct(public readonly array $tags = []) {}
}
interface CacheInterface
{
public function remember(string $chave, int $ttl, callable $gerador): mixed;
public function invalidarTags(array $tags): void;
}
final class RedisCache implements CacheInterface
{
public function __construct(private readonly \Redis $redis) {}
public function remember(string $chave, int $ttl, callable $gerador): mixed
{
$bruto = $this->redis->get($chave);
if ($bruto !== false) {
return unserialize($bruto, ['allowed_classes' => true]);
}
$valor = $gerador();
$this->redis->setex($chave, $ttl, serialize($valor));
return $valor;
}
public function marcarTags(string $chave, array $tags, int $ttl): void
{
// Cada tag é um SET com as chaves que a carregam. Invalidação por
// tag vira: pega o SET, apaga as chaves, apaga o SET.
foreach ($tags as $tag) {
$this->redis->sAdd("tag:{$tag}", $chave);
$this->redis->expire("tag:{$tag}", $ttl + 60);
}
}
public function invalidarTags(array $tags): void
{
foreach ($tags as $tag) {
$chaves = $this->redis->sMembers("tag:{$tag}");
if ($chaves !== []) {
$this->redis->del(...$chaves);
}
$this->redis->del("tag:{$tag}");
}
}
}
/**
* Decorator genérico via __call: envolve QUALQUER objeto e lê os atributos
* dos métodos para decidir o que cachear. Nenhuma linha do repositório muda.
*/
final class CacheDecorator
{
/** @var array<string, Cacheable|InvalidaCache|null> cache da reflexão */
private array $metadados = [];
public function __construct(
private readonly object $interno,
private readonly RedisCache $cache,
private readonly string $prefixo = 'repo',
) {}
public function __call(string $metodo, array $argumentos): mixed
{
$atributo = $this->metadadoDe($metodo);
if ($atributo instanceof InvalidaCache) {
$resultado = $this->interno->{$metodo}(...$argumentos);
$this->cache->invalidarTags($atributo->tags);
return $resultado;
}
if (!$atributo instanceof Cacheable) {
return $this->interno->{$metodo}(...$argumentos);
}
// A chave precisa incluir os argumentos: buscarPorId(1) e
// buscarPorId(2) não podem compartilhar entrada.
$chave = sprintf('%s:%s:%s:%s',
$this->prefixo,
(new \ReflectionClass($this->interno))->getShortName(),
$metodo,
sha1(serialize($argumentos)),
);
$valor = $this->cache->remember(
$chave,
$atributo->ttl,
fn (): mixed => $this->interno->{$metodo}(...$argumentos),
);
$this->cache->marcarTags($chave, $atributo->tags, $atributo->ttl);
return $valor;
}
private function metadadoDe(string $metodo): Cacheable|InvalidaCache|null
{
// Reflection é cara: lida uma vez por método, guardada no objeto.
if (array_key_exists($metodo, $this->metadados)) {
return $this->metadados[$metodo];
}
$reflexao = new \ReflectionMethod($this->interno, $metodo);
foreach ([Cacheable::class, InvalidaCache::class] as $classe) {
$atributos = $reflexao->getAttributes($classe);
if ($atributos !== []) {
return $this->metadados[$metodo] = $atributos[0]->newInstance();
}
}
return $this->metadados[$metodo] = null;
}
}
// ---------- O repositório não sabe que existe cache ----------------------
final class ProdutoRepositorio
{
public function __construct(private readonly \PDO $pdo) {}
#[Cacheable(ttl: 3600, tags: ['produto'])]
public function buscarPorId(int $id): ?array
{
$stmt = $this->pdo->prepare('SELECT * FROM produtos WHERE id = :id');
$stmt->execute([':id' => $id]);
return $stmt->fetch(\PDO::FETCH_ASSOC) ?: null;
}
#[Cacheable(ttl: 600, tags: ['produto', 'listagem'])]
public function maisVendidos(int $limite = 10): array
{
return $this->pdo->query(
"SELECT * FROM produtos ORDER BY vendidos DESC LIMIT {$limite}"
)->fetchAll(\PDO::FETCH_ASSOC);
}
#[InvalidaCache(tags: ['produto', 'listagem'])]
public function salvar(array $produto): int
{
$stmt = $this->pdo->prepare(
'INSERT INTO produtos (nome, preco) VALUES (:nome, :preco)'
);
$stmt->execute([':nome' => $produto['nome'], ':preco' => $produto['preco']]);
return (int) $this->pdo->lastInsertId();
}
}
$repositorio = new CacheDecorator(new ProdutoRepositorio($pdo), new RedisCache($redis));
$repositorio->buscarPorId(1); // MISS — vai ao banco
$repositorio->buscarPorId(1); // HIT — vem do Redis
$repositorio->salvar(['nome' => 'Teclado 60%', 'preco' => 399.90]);
// ↑ invalida as tags produto e listagem
$repositorio->buscarPorId(1); // MISS de novo
Exercício 2
Crie MonitorDeCache que registra métricas com Redis: cache:hits, cache:misses, cache:hit_ratio via INCR e GET. Exponha via endpoint /metrics/cache retornando JSON com taxa de acerto, total de chaves e memória usada (INFO memory).
Ver resposta
✓ Resposta: evicted_keys acima de zero é o número que exige ação: significa que o Redis está expulsando chaves por falta de memória, e o hit ratio vai cair sem que ninguém tenha mudado nada. E use SCAN, nunca KEYS — em produção, KEYS * bloqueia o servidor inteiro.
<?php
declare(strict_types=1);
final class MonitorDeCache
{
public function __construct(private readonly \Redis $redis) {}
public function registrarHit(?string $grupo = null): void
{
// INCR é atômico: vários processos incrementando não se perdem.
$this->redis->incr('cache:hits');
if ($grupo !== null) {
$this->redis->incr("cache:hits:{$grupo}");
}
$this->redis->incr('cache:hits:hora:' . date('YmdH'));
$this->redis->expire('cache:hits:hora:' . date('YmdH'), 172800);
}
public function registrarMiss(?string $grupo = null): void
{
$this->redis->incr('cache:misses');
if ($grupo !== null) {
$this->redis->incr("cache:misses:{$grupo}");
}
}
public function relatorio(): array
{
[$hits, $misses] = array_map(
'intval',
$this->redis->mGet(['cache:hits', 'cache:misses']) ?: [0, 0],
);
$total = $hits + $misses;
$info = $this->redis->info();
// O Redis também conta acertos por conta própria — útil para
// comparar com a contagem da aplicação.
$hitsRedis = (int) ($info['keyspace_hits'] ?? 0);
$missesRedis = (int) ($info['keyspace_misses'] ?? 0);
$totalRedis = $hitsRedis + $missesRedis;
return [
'aplicacao' => [
'hits' => $hits,
'misses' => $misses,
'total' => $total,
'hit_ratio' => $total > 0 ? round($hits / $total * 100, 2) : 0.0,
],
'redis' => [
'keyspace_hits' => $hitsRedis,
'keyspace_misses' => $missesRedis,
'hit_ratio' => $totalRedis > 0
? round($hitsRedis / $totalRedis * 100, 2) : 0.0,
],
'chaves' => [
'total' => $this->redis->dbSize(),
'expirando'=> (int) ($info['expired_keys'] ?? 0),
'evictadas'=> (int) ($info['evicted_keys'] ?? 0),
],
'memoria' => [
'usada' => $info['used_memory_human'] ?? '?',
'pico' => $info['used_memory_peak_human'] ?? '?',
'limite' => $info['maxmemory_human'] ?? 'sem limite',
'politica' => $info['maxmemory_policy'] ?? '?',
'fragmentacao' => $info['mem_fragmentation_ratio'] ?? '?',
],
'por_grupo' => $this->porGrupo(),
'ultimas_24h' => $this->ultimas24h(),
];
}
private function porGrupo(): array
{
$grupos = [];
// SCAN, nunca KEYS: KEYS trava o Redis inteiro enquanto varre.
$iterador = null;
while (($chaves = $this->redis->scan($iterador, 'cache:hits:*', 100)) !== false) {
foreach ($chaves as $chave) {
$nome = substr($chave, strlen('cache:hits:'));
if (str_starts_with($nome, 'hora:')) {
continue;
}
$h = (int) $this->redis->get($chave);
$m = (int) $this->redis->get("cache:misses:{$nome}");
$grupos[$nome] = [
'hits' => $h,
'misses' => $m,
'hit_ratio' => ($h + $m) > 0 ? round($h / ($h + $m) * 100, 2) : 0.0,
];
}
if ($iterador === 0) {
break;
}
}
return $grupos;
}
private function ultimas24h(): array
{
$serie = [];
for ($h = 23; $h >= 0; $h--) {
$momento = strtotime("-{$h} hours");
$serie[date('H:00', $momento)] =
(int) ($this->redis->get('cache:hits:hora:' . date('YmdH', $momento)) ?: 0);
}
return $serie;
}
}
// ---------- Endpoint /metrics/cache --------------------------------------
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
echo json_encode(
(new MonitorDeCache($redis))->relatorio(),
JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE,
);
// {
// "aplicacao": { "hits": 184220, "misses": 9411, "hit_ratio": 95.14 },
// "chaves": { "total": 4821, "evictadas": 0 },
// "memoria": { "usada": "48.2M", "politica": "allkeys-lru" }
// }
Exercício 3
Implemente write-behind para o sistema de visualizações de produto: registrarVisualizacao(int $produtoId) incrementa um contador no Redis com HINCRBY. Um job (Artigo 29) consolidada os contadores no banco a cada 5 minutos via HGETALL e reset.
Ver resposta
✓ Resposta: O RENAME atômico é o que torna a consolidação segura: sem ele, toda visualização que chegasse entre o HGETALL e o reset seria perdida. E note o rollback devolvendo os contadores ao hash — falha no banco não pode zerar o que ainda não foi gravado.
<?php
declare(strict_types=1);
/**
* Write-behind (ou write-back): grava no cache e devolve na hora; o banco é
* atualizado depois, em lote. Para contador de visualização é ideal — um
* UPDATE por pageview derruba o banco em qualquer tráfego sério, e perder
* alguns cliques num crash não é problema de negócio.
*/
final class ContadorDeVisualizacoes
{
private const HASH = 'produto:visualizacoes:pendentes';
public function __construct(private readonly \Redis $redis) {}
public function registrarVisualizacao(int $produtoId): int
{
// HINCRBY é atômico e O(1). Um hash só para todos os produtos:
// muito mais barato que uma chave por produto.
$total = $this->redis->hIncrBy(self::HASH, (string) $produtoId, 1);
// Contador "quente" para exibir na página, com TTL curto.
$this->redis->incr("produto:{$produtoId}:views:hoje");
$this->redis->expireAt("produto:{$produtoId}:views:hoje", strtotime('tomorrow'));
return $total;
}
public function pendentes(): array
{
return array_map('intval', $this->redis->hGetAll(self::HASH) ?: []);
}
/**
* O truque da consolidação: RENAME é atômico. Renomeamos o hash e
* trabalhamos sobre a cópia — assim as visualizações que chegarem durante
* o processamento vão para um hash novo e não se perdem.
*/
public function consolidar(\PDO $pdo): array
{
$temporario = self::HASH . ':processando:' . bin2hex(random_bytes(4));
if (!$this->redis->exists(self::HASH)) {
return ['produtos' => 0, 'visualizacoes' => 0];
}
$this->redis->rename(self::HASH, $temporario);
$contadores = $this->redis->hGetAll($temporario) ?: [];
if ($contadores === []) {
$this->redis->del($temporario);
return ['produtos' => 0, 'visualizacoes' => 0];
}
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'UPDATE produtos SET visualizacoes = visualizacoes + :n WHERE id = :id'
);
$totalViews = 0;
foreach ($contadores as $produtoId => $quantidade) {
$stmt->execute([':n' => (int) $quantidade, ':id' => (int) $produtoId]);
$totalViews += (int) $quantidade;
}
$pdo->commit();
$this->redis->del($temporario);
} catch (\Throwable $e) {
$pdo->rollBack();
// Devolve os contadores ao hash principal: nada se perde.
foreach ($contadores as $produtoId => $quantidade) {
$this->redis->hIncrBy(self::HASH, (string) $produtoId, (int) $quantidade);
}
$this->redis->del($temporario);
throw $e;
}
return ['produtos' => count($contadores), 'visualizacoes' => $totalViews];
}
}
// ---------- O job que roda a cada 5 minutos ------------------------------
final class ConsolidarVisualizacoesJob implements JobInterface
{
public function __construct(
private readonly ContadorDeVisualizacoes $contador,
private readonly \PDO $pdo,
) {}
public function handle(): void
{
$resultado = $this->contador->consolidar($this->pdo);
error_log(sprintf('Consolidadas %d visualizações de %d produto(s).',
$resultado['visualizacoes'], $resultado['produtos']));
}
public function tentativasMaximas(): int { return 3; }
public function backoff(int $tentativa): int { return 60 * $tentativa; }
public function naoRetentar(): array { return []; }
}
// */5 * * * * php artisan queue:job ConsolidarVisualizacoesJob
// ou, no laço do worker, com um agendador simples.
// ---------- Uso na página do produto -------------------------------------
$contador->registrarVisualizacao($produtoId); // ~0,1 ms, sem tocar no banco
// Total exibido = o que está no banco + o que ainda não foi consolidado.
$pendente = (int) ($redis->hGet('produto:visualizacoes:pendentes', (string) $produtoId) ?: 0);
echo number_format($produto['visualizacoes'] + $pendente, 0, ',', '.'), ' visualizações';
Exercício 4
Adicione stale-while-revalidate ao RedisCache: ao expirar, sirva o valor antigo imediatamente e agende regeneração assíncrona via fila (Artigo 29). Implemente setComStale(chave, valor, ttlFresh, ttlStale) que usa dois TTLs.
Ver resposta
✓ Resposta: A troca que você aceita ao usar SWR: durante a regeneração, o usuário vê dado com até ttlFresh de atraso. Para catálogo, ranking e listagem isso é irrelevante — para saldo ou estoque, não. O finally soltando o lock é obrigatório: sem ele, um gerador que falha trava a chave até o TTL.
<?php
declare(strict_types=1);
/**
* O problema: quando uma chave popular expira, todas as requisições
* simultâneas vão ao banco de uma vez — é a "debandada de cache"
* (cache stampede). Uma consulta de 2 s vira 500 consultas de 2 s.
*
* A saída: guardar o valor com DOIS prazos. Passado o "fresco", o valor
* ainda é servido (stale) enquanto UM processo regenera em segundo plano.
* Ninguém espera, e o banco recebe uma consulta em vez de quinhentas.
*/
final class RedisCacheComStale
{
public function __construct(
private readonly \Redis $redis,
private readonly QueueDispatcher $fila,
private readonly MonitorDeCache $monitor,
) {}
public function setComStale(string $chave, mixed $valor, int $ttlFresh, int $ttlStale): void
{
$envelope = [
'valor' => $valor,
'fresco_ate'=> time() + $ttlFresh,
'gravado_em'=> time(),
];
// O TTL real do Redis é o LONGO. O prazo curto é lógico, guardado
// dentro do envelope — é isso que permite servir o valor vencido.
$this->redis->setex($chave, $ttlFresh + $ttlStale, serialize($envelope));
}
public function get(string $chave, callable $gerador, int $ttlFresh = 300,
int $ttlStale = 3600): mixed
{
$bruto = $this->redis->get($chave);
if ($bruto === false) {
// MISS de verdade: não há nem valor velho. Aqui alguém espera.
$this->monitor->registrarMiss();
$valor = $gerador();
$this->setComStale($chave, $valor, $ttlFresh, $ttlStale);
return $valor;
}
$envelope = unserialize($bruto, ['allowed_classes' => true]);
$this->monitor->registrarHit();
if (time() < $envelope['fresco_ate']) {
return $envelope['valor']; // fresco: nada a fazer
}
// VENCIDO mas utilizável: devolve já e manda regenerar.
// O lock garante que só o primeiro a chegar agenda o job.
if ($this->travar($chave, $ttlFresh)) {
$this->fila->despachar(
new RegenerarCacheJob($chave, $ttlFresh, $ttlStale),
prioridade: 'high',
);
}
return $envelope['valor'];
}
/**
* SET NX EX: cria a chave só se ela não existir, com expiração. É a
* forma canônica de lock distribuído simples no Redis — atômico,
* e se o processo morrer o lock some sozinho pelo TTL.
*/
private function travar(string $chave, int $segundos): bool
{
return (bool) $this->redis->set(
"lock:regenerar:{$chave}",
(string) getmypid(),
['NX', 'EX' => max(10, $segundos)],
);
}
public function destravar(string $chave): void
{
$this->redis->del("lock:regenerar:{$chave}");
}
}
final class RegenerarCacheJob implements JobInterface
{
/** @var array<string, callable> registro de geradores por padrão de chave */
private static array $geradores = [];
public function __construct(
public readonly string $chave,
public readonly int $ttlFresh,
public readonly int $ttlStale,
) {}
public static function registrarGerador(string $padrao, callable $gerador): void
{
self::$geradores[$padrao] = $gerador;
}
public function handle(): void
{
$gerador = null;
foreach (self::$geradores as $padrao => $fn) {
if (fnmatch($padrao, $this->chave)) {
$gerador = $fn;
break;
}
}
if ($gerador === null) {
throw new \RuntimeException("Sem gerador para a chave {$this->chave}.");
}
$cache = app(RedisCacheComStale::class);
try {
$cache->setComStale($this->chave, $gerador($this->chave),
$this->ttlFresh, $this->ttlStale);
} finally {
// Libera o lock mesmo se o gerador falhar — senão a chave fica
// travada até o TTL e nunca é regenerada.
$cache->destravar($this->chave);
}
}
public function tentativasMaximas(): int { return 2; }
public function backoff(int $tentativa): int { return 30; }
public function naoRetentar(): array { return []; }
}
// ---------- Uso -----------------------------------------------------------
RegenerarCacheJob::registrarGerador('produto:*:detalhe', static function (string $chave): array {
preg_match('/produto:(\d+):detalhe/', $chave, $m);
return $repositorio->detalheCompleto((int) $m[1]); // consulta cara
});
$detalhe = $cache->get(
"produto:{$id}:detalhe",
fn (): array => $repositorio->detalheCompleto($id),
ttlFresh: 300, // 5 min fresco
ttlStale: 3600, // + 1 h servindo velho enquanto regenera
);
Exercício 5
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.
Ver resposta
✓ Resposta: O custo escondido do L1: cada processo PHP tem o seu, então delete() só limpa o do processo atual — os outros continuam servindo o valor antigo até o TTL curto vencer. Por isso o L1 usa segundos, não horas. Invalidação imediata em todos os processos exige pub/sub, e aí já é outro problema.
<?php
declare(strict_types=1);
interface NivelDeCacheInterface
{
public function get(string $chave): mixed;
public function set(string $chave, mixed $valor, int $ttl): void;
public function delete(string $chave): void;
public function nome(): string;
}
/** L1: memória do próprio processo. Nanossegundos, mas não é compartilhada. */
final class NivelApcu implements NivelDeCacheInterface
{
public function get(string $chave): mixed
{
$sucesso = false;
$valor = apcu_fetch($chave, $sucesso);
// apcu_fetch devolve false tanto para "não achei" quanto para o
// valor false. O flag por referência é quem desempata.
return $sucesso ? $valor : null;
}
public function set(string $chave, mixed $valor, int $ttl): void
{
apcu_store($chave, $valor, $ttl);
}
public function delete(string $chave): void { apcu_delete($chave); }
public function nome(): string { return 'L1/APCu'; }
}
/** L2: compartilhado entre processos e máquinas. Milissegundos. */
final class NivelRedis implements NivelDeCacheInterface
{
public function __construct(private readonly \Redis $redis) {}
public function get(string $chave): mixed
{
$bruto = $this->redis->get($chave);
return $bruto === false ? null : unserialize($bruto, ['allowed_classes' => true]);
}
public function set(string $chave, mixed $valor, int $ttl): void
{
$this->redis->setex($chave, $ttl, serialize($valor));
}
public function delete(string $chave): void { $this->redis->del($chave); }
public function nome(): string { return 'L2/Redis'; }
}
final class CacheDoisNiveis
{
/** @var array<string, int> de onde cada resposta veio */
private array $origens = ['L1' => 0, 'L2' => 0, 'gerador' => 0];
public function __construct(
private readonly NivelDeCacheInterface $l1,
private readonly NivelDeCacheInterface $l2,
private readonly int $ttlL1 = 60, // curto: limita a divergência
private readonly int $ttlL2 = 3600,
) {}
public function get(string $chave, ?callable $gerador = null): mixed
{
// 1) L1 — o mais rápido
$valor = $this->l1->get($chave);
if ($valor !== null) {
$this->origens['L1']++;
return $valor;
}
// 2) L2 — compartilhado
$valor = $this->l2->get($chave);
if ($valor !== null) {
$this->origens['L2']++;
// Promove para L1: a próxima leitura deste processo é instantânea.
$this->l1->set($chave, $valor, $this->ttlL1);
return $valor;
}
// 3) Gerador — a fonte da verdade
if ($gerador === null) {
return null;
}
$this->origens['gerador']++;
$valor = $gerador();
$this->set($chave, $valor);
return $valor;
}
public function set(string $chave, mixed $valor): void
{
// Ordem: L2 primeiro. Se gravar L1 antes e a gravação no L2 falhar,
// este processo passa a servir um valor que ninguém mais enxerga.
$this->l2->set($chave, $valor, $this->ttlL2);
$this->l1->set($chave, $valor, $this->ttlL1);
}
public function delete(string $chave): void
{
$this->l2->delete($chave);
$this->l1->delete($chave);
// ⚠️ O L1 dos OUTROS processos continua com o valor antigo até o TTL
// curto vencer. É o preço do L1 — e o motivo de o TTL ser curto.
// Invalidação imediata exige pub/sub avisando todos os processos.
}
public function estatisticas(): array
{
$total = array_sum($this->origens);
if ($total === 0) {
return $this->origens + ['hit_ratio' => 0.0];
}
return $this->origens + [
'hit_ratio' => round(($this->origens['L1'] + $this->origens['L2']) / $total * 100, 2),
'l1_ratio' => round($this->origens['L1'] / $total * 100, 2),
];
}
}
// ---------------------------------------- Tests/Unit/CacheDoisNiveisTest.php
declare(strict_types=1);
namespace Tests\Unit;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class CacheDoisNiveisTest extends TestCase
{
#[Test]
public function achandoNoL1NemConsultaOL2(): void
{
$l1 = $this->createMock(NivelDeCacheInterface::class);
$l2 = $this->createMock(NivelDeCacheInterface::class);
$l1->method('get')->willReturn('do-l1');
$l2->expects(self::never())->method('get'); // a asserção central
self::assertSame('do-l1', (new CacheDoisNiveis($l1, $l2))->get('k'));
}
#[Test]
public function achandoNoL2PromoveParaOL1(): void
{
$l1 = $this->createMock(NivelDeCacheInterface::class);
$l2 = $this->createMock(NivelDeCacheInterface::class);
$l1->method('get')->willReturn(null);
$l2->method('get')->willReturn('do-l2');
// A promoção é o que faz a 2ª leitura ser instantânea.
$l1->expects(self::once())->method('set')->with('k', 'do-l2', 60);
self::assertSame('do-l2', (new CacheDoisNiveis($l1, $l2))->get('k'));
}
#[Test]
public function faltandoNosDoisChamaOGeradorEPopulaAmbos(): void
{
$l1 = $this->createMock(NivelDeCacheInterface::class);
$l2 = $this->createMock(NivelDeCacheInterface::class);
$l1->method('get')->willReturn(null);
$l2->method('get')->willReturn(null);
$l1->expects(self::once())->method('set');
$l2->expects(self::once())->method('set');
$chamou = false;
$valor = (new CacheDoisNiveis($l1, $l2))->get('k', function () use (&$chamou): string {
$chamou = true;
return 'gerado';
});
self::assertSame('gerado', $valor);
self::assertTrue($chamou);
}
#[Test]
public function deleteLimpaOsDoisNiveis(): void
{
$l1 = $this->createMock(NivelDeCacheInterface::class);
$l2 = $this->createMock(NivelDeCacheInterface::class);
$l1->expects(self::once())->method('delete')->with('k');
$l2->expects(self::once())->method('delete')->with('k');
(new CacheDoisNiveis($l1, $l2))->delete('k');
}
#[Test]
public function estatisticasContamAOrigemDeCadaResposta(): void
{
$l1 = $this->createMock(NivelDeCacheInterface::class);
$l2 = $this->createMock(NivelDeCacheInterface::class);
$l1->method('get')->willReturnOnConsecutiveCalls('a', null, null);
$l2->method('get')->willReturnOnConsecutiveCalls('b', null);
$cache = new CacheDoisNiveis($l1, $l2);
$cache->get('k1');
$cache->get('k2');
$cache->get('k3', static fn (): string => 'c');
$stats = $cache->estatisticas();
self::assertSame(1, $stats['L1']);
self::assertSame(1, $stats['L2']);
self::assertSame(1, $stats['gerador']);
self::assertEqualsWithDelta(66.67, $stats['hit_ratio'], 0.01);
}
}