Voltar para o blog
PHP

PHP puro com Redis na prática: cacheando dados e melhorando o desempenho de uma aplicação

Depois de um teste técnico onde percebi que uso Redis há anos sem entender o que acontece por baixo dos panos, decidi reconstruir tudo com PHP puro — sem Laravel, sem mágica escondida. Neste post, mostro como implementar o padrão Cache-Aside do zero, os bugs reais que encontrei no caminho (incluindo um limite de infraestrutura escondido que mascarava meus próprios benchmarks), e como resolvi invalidação de cache e cache stampede — com números reais, não achismo.

PHP puro com Redis na prática: cacheando dados e melhorando o desempenho de uma aplicação
PHP Backend Carreira

Por que escrevi isso

Há um tempo fiz um teste técnico pra uma vaga. A empresa foi bem transparente: eles estavam tendo "vários problemas de cache" em produção. Não vou entrar em detalhes de quem é a empresa nem do que exatamente estava quebrando, mas aquilo me deixou pensando por dias.

Porque a verdade é essa: eu uso Redis há anos e nunca tinha parado pra entender de verdade o que acontece por baixo dos panos. Sempre que precisei de cache, era Cache::remember('chave', 3600, function () { ... }) no Laravel e pronto — o framework resolve tudo numa linha, esconde a conexão, esconde a serialização, esconde o TTL, esconde a decisão de quando invalidar. Funciona bem, aliás — o problema não é o Laravel. O problema é que, quando você só usa a abstração, você aprende a invocar o cache, mas não aprende a pensar em cache. E são coisas bem diferentes.

Esse teste técnico foi o empurrão que faltava pra eu parar, tirar o framework do caminho, e reconstruir a coisa toda com PHP puro — só pra entender, na marra, cada decisão que normalmente é tomada por mim sem eu perceber. Esse post (e o repositório no GitHub que ele documenta) é o resultado disso.

Se você é um dev júnior, ou alguém que usa Redis no dia a dia mas nunca construiu a integração do zero, minha esperança é que, ao final da leitura, você entenda o que o Redis é, o que ele resolve de verdade, e como implementar isso em PHP — sem depender de nenhuma mágica de framework.

Preciso ser sincero sobre uma coisa: como eu usei IA nesse processo

Isso aqui não é um post sobre "eu escrevi tudo sozinho, do zero, sem ajuda". Usei o Claude Code (um assistente de IA da Anthropic) como parceiro de trabalho durante o projeto inteiro, e acho importante ser transparente sobre isso — tanto porque é a realidade de como eu trabalho hoje, quanto porque acredito que usar IA bem é, em si, uma habilidade de carreira que vale a pena desenvolver conscientemente, e não é a mesma coisa que "deixar a IA fazer o trabalho por você".

Na prática, o processo foi mais ou menos assim: eu trazia a intenção e as decisões ("quero entender Cache-Aside", "vamos medir de verdade, não confiar no achismo", "isso aqui parece bom demais, vamos desconfiar"), e usava a IA pra acelerar a implementação, gerar as explicações comentadas linha a linha, e — o mais importante — para testar tudo de verdade, rodando dentro de containers Docker reais, com números reais, em vez de só descrever como as coisas deveriam funcionar.

E foi exatamente isso que revelou os dois momentos mais valiosos do projeto inteiro, que vou contar em detalhe mais pra frente:

  1. Um benchmark que, na primeira tentativa, não mostrava nenhum ganho de cache — porque a metodologia de teste estava errada (requisições sequenciais, não simultâneas).
  2. Depois de corrigir isso e ver um ganho bonito de "1,5x mais rápido", uma investigação mais profunda (motivada por outro assunto, cache stampede) revelou que o próprio ambiente Docker estava mentindo pra gente: o PHP só conseguia processar 5 requisições ao mesmo tempo, então boa parte da "concorrência" que a gente achava estar testando era, na real, fila de espera. Corrigimos, testamos de novo, e o resultado mudou.

Nenhuma dessas duas descobertas veio de "perguntar pra IA se o Redis é rápido". Vieram de rodar o código de verdade, medir de verdade, e desconfiar quando um número parecia bom (ou ruim) demais pra ser verdade. A IA foi útil pra eu chegar lá mais rápido — investigar hipóteses, reescrever o benchmark, organizar a explicação — mas quem decidiu o que testar, e quem teve que entender por que o número mudou, fui eu. Se eu tivesse só aceitado o primeiro resultado (ou pedido pra IA "me dar um benchmark que mostre o Redis ganhando"), teria saído desse projeto sabendo menos do que sabia antes de começar.

É esse o uso de IA que acho que vale a pena levar pra carreira: não como um substituto de entendimento, mas como uma ferramenta que barateia o custo de testar suas próprias hipóteses — o que só funciona se você continuar sendo a pessoa que decide quais hipóteses testar, e que sabe reconhecer quando um resultado não faz sentido.

O que é o Redis, de verdade

Redis é um banco de dados em memória, do tipo chave-valor. "Em memória" é a palavra-chave: enquanto o MySQL guarda os dados em disco (mesmo com todo o cache interno dele, ainda existe uma camada de disco por trás), o Redis guarda tudo na memória RAM do servidor. Ler da RAM é ordens de magnitude mais rápido que ler do disco — é por isso que o Redis consegue responder em frações de milissegundo.

"Chave-valor" quer dizer que a forma mais básica de usar o Redis é: você dá um nome pra uma informação (a chave, tipo "produto:42") e guarda um valor nela (SET produto:42 "..."). Depois, você pede de volta usando o mesmo nome (GET produto:42). Sem SQL, sem tabelas, sem WHERE — só "me dá o que tá guardado com esse nome".

Isso tem um preço: o Redis não substitui o MySQL. Ele não tem relacionamentos entre tabelas, não tem JOIN, e (dependendo de como você configura) pode perder os dados se o servidor reiniciar. Por isso o Redis quase nunca é a "fonte da verdade" dos seus dados — ele é uma cópia temporária e mais rápida de um dado que já existe em algum outro lugar (no nosso caso, o MySQL). Essa relação — MySQL manda, Redis acelera — é o coração de tudo que vem a seguir.

O padrão que resolve isso: Cache-Aside

Existem vários jeitos de usar cache, mas o mais comum (e o mais fácil de explicar numa entrevista de emprego, aliás) é o Cache-Aside — às vezes chamado de "lazy loading". A ideia é simples:

Requisição chega
      ↓
Busca no Redis
      ↓
   Encontrou?
 ┌───────────────┐
 │ Sim           │ Não
 ↓               ↓
Devolve         Consulta o MySQL
o que achou            ↓
                 Guarda no Redis
                 (com um TTL — tempo de expiração)
                        ↓
                 Devolve o resultado

Repare em três decisões que esse fluxo obriga você a tomar — e que um Cache::remember() de framework toma por você, silenciosamente:

  • Qual é a chave? Precisa ser única o suficiente pra não colidir com outro dado, mas previsível o suficiente pra você conseguir "adivinhar" ela de novo na próxima consulta.
  • Qual é o TTL (tempo de vida)? Curto demais, você não aproveita o cache direito. Longo demais, o dado fica velho (stale) por mais tempo se algo mudar no banco.
  • O que fazer quando o dado muda? Só o TTL não resolve todo caso (mais sobre isso mais adiante).

Construindo isso em PHP puro

O projeto inteiro roda em Docker (Nginx + PHP-FPM + MySQL + Redis — os detalhes de por quê cada peça está lá estão no README do repositório). Inclusive, o próprio repositório vem com uma página de diagnóstico que confirma se tudo está no ar antes de você mexer em qualquer coisa:

Página de diagnóstico do ambiente, mostrando as extensões do PHP, a conexão com o MySQL e com o Redis, todas com checagem verde


Mas o coração da história é este método, dentro de src/ProdutoRepository.php:

public function buscarPorId(int $id): ?array
{
    $chave = "produto:{$id}";

    // Passo 1: tenta achar no Redis primeiro
    $produtoEmCache = $this->redis->get($chave);

    if ($produtoEmCache !== false) {
        return json_decode($produtoEmCache, true);
    }

    // Passo 2: não tava no cache, busca no MySQL
    $consulta = $this->pdo->prepare(
        'SELECT id, nome, descricao, categoria, preco, estoque
         FROM produtos WHERE id = :id'
    );
    $consulta->execute(['id' => $id]);
    $produto = $consulta->fetch();

    if ($produto === false) {
        return null;
    }

    // Passo 3: guarda no Redis pra próxima vez vir do cache
    $this->redis->setex($chave, 300, json_encode($produto));

    return $produto;
}

Repare no que cada linha resolve, das três decisões que citei acima:

  • A chave é "produto:{$id}" — por exemplo produto:42. Prefixar com produto: é uma convenção (chamada de namespacing) que evita colisão com outras chaves do mesmo Redis — no projeto, depois criamos chaves de listagem tipo listagem:produtos:pagina:1:categoria:livros, e o prefixo evita que uma pise na outra.
  • O TTL é 300 segundos (5 minutos), escolhido como meio-termo: tempo suficiente pra aliviar a carga se um produto virar popular, mas não tanto a ponto do preço/estoque ficar perigosamente desatualizado.
  • O Redis guarda só strings — por isso o json_encode()/json_decode(): transformamos o array PHP numa string antes de guardar, e desfazemos isso ao ler de volta.

Repare também no que não está aqui: nenhuma concatenação de variável dentro do SQL (a consulta usa prepared statements, com :id como placeholder — isso é o que evita SQL Injection). E nenhuma dependência de framework — é só PDO (a extensão nativa do PHP pra bancos de dados) e a extensão redis (instalada via PECL, ver docker/php/Dockerfile).

Medindo de verdade (e errando duas vezes no caminho)

Eu podia parar por aqui e dizer "pronto, o Redis deixa tudo mais rápido, confia". Mas essa é exatamente a postura que esse projeto existe pra evitar. Então vamos com os números — e com os dois erros de metodologia que cometi ao tentar medir isso, porque acho que eles ensinam mais do que o resultado final.

Erro #1: medir uma requisição de cada vez

Minha primeira versão do benchmark disparava uma requisição, esperava a resposta, disparava a próxima. Resultado: quase nenhuma diferença entre "com cache" e "sem cache" — às vezes o cache até saía com uma média pior!

Por quê? Porque nossa tabela tem 10 mil produtos e a busca é por chave primária indexada — pro MySQL, isso é uma consulta trivialmente rápida (menos de 1 milissegundo), mesmo sem cache nenhum. O problema de cache que motivou esse projeto nunca foi "uma consulta isolada é lenta" — é muitas requisições batendo no banco ao mesmo tempo, disputando a mesma conexão. Testando uma de cada vez, esse gargalo simplesmente não existe pra ser resolvido.

A correção: reescrevi o benchmark pra disparar lotes de requisições verdadeiramente simultâneas (usando curl_multi do PHP, sem depender de ferramentas externas tipo ab/wrk).

Erro #2: o próprio ambiente escondia a concorrência

Com o benchmark concorrente, os primeiros números pareciam ótimos: cache 1,5x mais rápido. Fiquei satisfeito — até chegar na parte de cache stampede (mais sobre isso já já) e precisar entender exatamente quantos processos PHP conseguiam rodar ao mesmo tempo dentro do container.

Descobri que o PHP-FPM (o "motor" que processa PHP por trás do Nginx) estava configurado, por padrão, com pm.max_children = 5 — ou seja, só 5 requisições PHP rodavam de verdade em paralelo, não importa quantas eu disparasse "ao mesmo tempo" de fora. As outras ficavam enfileiradas, esperando um processo livre. Meu benchmark de "20 ou 50 requisições simultâneas" era, na real, um teste de fila, não de concorrência de verdade.

Corrigi isso (subindo o limite pra 30 processos) e refiz os testes. E o resultado mudou de figura: dentro da capacidade real do servidor (até ~30 requisições simultâneas de verdade), o cache não fez diferença perceptível — porque, de novo, uma consulta indexada numa tabela pequena é barata demais pro MySQL sentir, mesmo com 30 pessoas pedindo ao mesmo tempo. O ganho real (algo entre 1,1x e 1,5x, com variação notável entre execuções) só apareceu quando a concorrência ultrapassou a capacidade do servidor — aí as requisições passam a esperar em fila, e cada requisição "sem cache" ocupa um processo por mais tempo, então a fila anda mais devagar.

Isso é, honestamente, o achado mais valioso do projeto inteiro pra mim: um limite de infraestrutura escondido pode inflar ou esconder o benefício de uma otimização, e o único jeito de descobrir isso é desconfiar de um número bom demais e ir investigar de onde ele vem — em vez de simplesmente publicar o primeiro resultado bonito que aparece. Se quiser ver a metodologia completa e os números de cada rodada, está tudo documentado (com a devida transparência sobre a variância observada) no ANALISE.md do repositório.

O projeto tem um dashboard que mostra esses números visualmente, além de um "testador ao vivo" onde dá pra digitar um id de produto e ver a origem trocar de mysql pra redis na segunda busca:

Dashboard de performance mostrando o gráfico comparando tempo médio com e sem cache, a tabela de métricas, e o testador ao vivo


Os problemas que ninguém mostra no tutorial "caminho feliz"


Depois de ter o Cache-Aside básico funcionando, fui atrás dos três problemas que — aposto — são exatamente os que causaram a dor de cabeça da empresa do meu teste técnico.

1. Cache de item único não é a mesma coisa que cache de listagem

Cachear "o produto 42" é simples: uma chave, um valor. Mas cachear "a página 2 da categoria Livros" já é outra história — o resultado depende de vários parâmetros ao mesmo tempo (página, tamanho de página, categoria), e cada combinação diferente precisa da sua própria chave:

private function chaveDeCacheDaListagem(int $pagina, int $porPagina, ?string $categoria): string
{
    $categoriaNaChave = $categoria ?? 'todas';
    return "listagem:produtos:pagina:{$pagina}:por_pagina:{$porPagina}:categoria:{$categoriaNaChave}";
}

E o TTL também muda: uma listagem fica desatualizada por muito mais motivos que um item único (um produto novo, um produto removido, ou o preço de qualquer produto que apareça naquela página) — por isso usamos 60 segundos pra listagem, contra 300 segundos pro item único.

No repositório, isso vira duas páginas vivendo lado a lado, de propósito: produtos.php (sem cache, pra sempre) e produtos_cache.php (com cache) — dá pra comparar os tempos a qualquer momento, sem depender do cache estar "quente" ou "frio" na hora que você olhar.

Sem cache, toda visita consulta o MySQL:

Listagem de produtos sem cache, mostrando o badge "MySQL (banco)" e o tempo da consulta


Com cache, na segunda visita com o mesmo filtro, a origem muda pra Redis e o tempo despenca (nesse caso, de poucos milissegundos pra menos de meio milissegundo):

A mesma listagem, agora com o badge "Redis (cache)" e tempo de resposta de 0,35 ms


2. Invalidação: o problema que ninguém percebe até ser tarde demais

Aqui está o cenário que, eu diria, é o mais comum de todos: alguém atualiza um produto no banco (mudou o preço, por exemplo), mas esquece que aquele produto ainda está guardado no Redis com o preço antigo. Resultado: o site continua mostrando o preço velho por até o TTL expirar — no nosso caso, até 5 minutos.


Construí um "laboratório" pra ver isso acontecer de propósito (editar_produto.php): visito um produto (populando o cache), edito o preço dele sem invalidar o cache, e confirmo que o endpoint continua devolvendo o valor velho. Depois edito de novo, dessa vez invalidando o cache corretamente:

public function atualizarComInvalidacaoDeCache(int $id, string $nome, float $preco, int $estoque): bool
{
    $linhasAfetadas = $this->executarAtualizacaoNoMysql($id, $nome, $preco, $estoque);

    if ($linhasAfetadas > 0) {
        $this->redis->del("produto:{$id}");          // invalidação precisa: uma chave
        $this->invalidarCacheDeListagens();            // invalidação "grosseira": todas as listagens
    }

    return $linhasAfetadas > 0;
}

Repare na assimetria: invalidar o item único é fácil (a gente sabe exatamente qual chave apagar). Invalidar a listagem é mais difícil — o mesmo produto pode aparecer em várias páginas e filtros diferentes, então a saída pragmática é apagar todas as listagens cacheadas de uma vez (usando SCAN, nunca KEYS, que bloquearia o Redis inteiro numa base grande). É uma invalidação "grosseira", mas simples, correta, e barata — já que o TTL de listagem é curto mesmo.

O laboratório mostra as duas fontes lado a lado, e destaca com um aviso vermelho quando elas divergem — nessa captura, o MySQL já tinha o preço novo (R$ 199,90) mas o Redis ainda respondia com o preço antigo (R$ 299,90), porque salvei sem invalidar de propósito:


Página de edição mostrando o MySQL com preço R$ 199,90 e o Redis ainda com R$ 299,90, com o aviso "O Redis está desatualizado em relação ao MySQL!


3. Cache stampede: quando o cache vira o problema


Esse é o mais sutil dos três, e provavelmente o mais próximo do que a empresa do meu teste técnico estava vivendo. Imagina um produto popular, cujo cache acabou de expirar. Se muitas requisições pedirem esse mesmo produto bem nesse instante, todas vão dar cache miss e cair no MySQL ao mesmo tempo — pra responder exatamente a mesma pergunta, repetida N vezes. Isso é o "cache stampede" (ou "dog-piling"): o cache, que deveria proteger o banco, momentaneamente vira uma fonte concentrada de carga.

A solução clássica é um lock (trava) no Redis: antes de ir no MySQL, o processo tenta "trancar a porta" pra aquele produto específico:

$conseguiuOLock = $this->redis->set($chaveDoLock, '1', ['NX', 'EX' => 5]);

if ($conseguiuOLock) {
    // fui eu que tranquei — vou no MySQL e reponho o cache
} else {
    // outro processo já está buscando — espero um pouco e leio o cache que ele vai preencher
}

NX (not exists) garante que só o primeiro processo consegue criar essa chave — como o Redis processa comandos um de cada vez, isso vira uma garantia real de exclusividade. Todos os outros processos, em vez de irem no MySQL também, esperam um pouquinho (menos de um segundo) e reaproveitam o cache que o primeiro processo já preencheu.

Medi isso com um contador de verdade no Redis, disparando 30 requisições simultâneas pro mesmo produto: sem proteção, entre 3 e 6 consultas redundantes ao MySQL pra responder a mesma pergunta feita 30 vezes ao mesmo tempo; com proteção, sempre exatamente 1. Numa tabela maior, com uma consulta mais cara, ou com centenas de requisições em vez de 30, essa multiplicação sem proteção cresceria proporcionalmente — é esse tipo de amplificação que transforma "um produto ficou popular" em "o banco caiu".


O que eu levo disso daqui pra frente


Voltando ao motivo de tudo isso: o teste técnico que não passei (ou passei, não vem ao caso) me mostrou uma coisa que hoje eu enxergo em muito mais lugares do que só cache — a diferença entre saber usar uma ferramenta e entender o problema que ela resolve. Dá pra usar Redis (ou qualquer tecnologia) por anos sabendo só a primeira parte. Só que aí, no dia que a ferramenta falhar de um jeito que a documentação não previu, você não vai ter as ferramentas mentais pra investigar — porque nunca precisou construir o entendimento de baixo pra cima.

Esse projeto foi minha tentativa de construir esse entendimento de propósito, com ajuda de IA pra acelerar a parte mecânica, mas sem terceirizar pra ela a parte que importa: decidir o que testar, desconfiar de números bons demais, e continuar perguntando "por quê" até a resposta fazer sentido de verdade.

Se você quiser rodar isso na sua máquina, testar os benchmarks você mesmo, ou só ler o código comentado linha a linha, o projeto inteiro está aberto:

👉 github.com/jmarciosilva/php-redis-exemplo


É só clonar, rodar `docker compose up -d --build`, e seguir o [README](https://github.com/jmarciosilva/php-redis-exemplo/blob/main/README.md). Se achar algum erro, tiver sugestão, ou quiser trocar uma ideia sobre cache (ou sobre usar IA pra aprender de verdade), me chama.