Cache Avançado com Redis

Cache Avançado com Redis

As três estratégias com implementação completa em PHP — cache-aside, write-through e write-behind —, além de invalidação por tag, TTL adaptativo e lock contra stampede. Com atenção ao que torna cada um desses mecanismos inútil sem emitir um único erro.
PHP

• • 29 min de leitura

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

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);
    }
}
Comentários

Mais em PHP

Deploy e Ambiente de Produção
Deploy e Ambiente de Produção

NGINX com PHP-FPM, script de deploy, pipeline no GitHub Actions, Docker e logs…

Strings em Profundidade
Strings em Profundidade

Texto em PHP é sequência de bytes, e é dessa decisão que vêm quase todas as…

Clean Architecture com PHP
Clean Architecture com PHP

Um módulo de pedidos montado camada por camada, com a entidade guardando as…