PHP

Cache Avançado com Redis Já leu

9 min de leitura

Cache Avançado com Redis
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 implementa

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

  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.

  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).

  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.

  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.

  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.


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
Comentários

Mais em PHP

SQLite com PHP
SQLite com PHP

SQLite é o banco de dados mais instalado do mundo — está presente em todos os...

Exceções Avançadas
Exceções Avançadas

Tratamento de erros &eacute; onde c&oacute;digo bom se separa de c&oacute;dig...

Autenticação JWT
Autenticação JWT

Autenticação é o processo de verificar quem é o cliente. Em APIs REST statele...