DEV Community

Denis Augusto
Denis Augusto

Posted on

O bug não existe: é o cache te enganando

"Mas eu já corrigi isso"

O cliente avisa que o preço do produto está errado no site. Você abre o banco: 89,90. Correto.

Abre a página: 129,90.

Você acha que enlouqueceu. Dá um dd() no controller — vem 89,90. Tira o dd(), recarrega: 129,90 de novo. Você começa a duvidar do navegador, do Nginx, da existência.

Aí você lembra daquele Cache::remember() que você mesmo escreveu três meses atrás. Com TTL de 24 horas.

O bug não existe. O cache está te mostrando um retrato de ontem — e defendendo esse retrato com unhas e dentes.

O remember() que todo mundo escreve

public function destaques()
{
    // resolvido! a home ficou 400ms mais rápida 🎉
    return Cache::remember('produtos.destaques', 3600 * 24, function () {
        return Produto::destaque()->with('categoria')->get();
    });
}
Enter fullscreen mode Exit fullscreen mode

Essa linha é linda. Ela resolve o problema de performance de verdade, na hora, e o gráfico do APM agradece.

Só que ela responde metade da pergunta. Você definiu como guardar. Não definiu quando esquecer.

E o Produto::update() que roda no painel do admin não tem a menor ideia de que essa chave existe. Ninguém apresentou os dois.

Cache é cópia. E cópia envelhece.

Antes da solução, o jeito certo de pensar: cache não é uma otimização mágica, é uma segunda fonte de verdade que você acabou de criar no seu sistema.

Toda vez que você guarda algo em cache, você assume um compromisso: manter duas coisas sincronizadas. O banco e a cópia.

Só existem três jeitos de honrar esse compromisso. Todo cache decente usa um deles.

Estratégia 1: quem escreve, esquece

A mais direta. Quando o dado muda, mata a chave. Um Observer resolve isso sem espalhar Cache::forget() pelo projeto inteiro:

class ProdutoObserver
{
    public function saved(Produto $produto): void
    {
        Cache::forget('produtos.destaques');
        Cache::forget("produto:{$produto->id}");
    }

    public function deleted(Produto $produto): void
    {
        $this->saved($produto);
    }
}
Enter fullscreen mode Exit fullscreen mode

Funciona bem e é fácil de ler. O ponto fraco aparece com o tempo: cada chave nova que você criar precisa entrar nessa lista. Esquecer uma é o bug do começo do post voltando.

Estratégia 2: chave que se aposenta sozinha

Aqui vem o truque que eu mais gosto, porque não precisa invalidar nada: coloque a versão do dado dentro da chave.

public function fichaTecnica(Produto $produto): array
{
    // updated_at muda no save → a chave muda → o valor velho vira órfão
    $chave = "produto:{$produto->id}:ficha:{$produto->updated_at->timestamp}";

    return Cache::remember($chave, 3600 * 24, function () use ($produto) {
        return $produto->montarFichaTecnica(); // aquele cálculo caro
    });
}
Enter fullscreen mode Exit fullscreen mode

Salvou o produto? O updated_at mudou, a chave mudou, o valor antigo simplesmente nunca mais é pedido e expira sozinho.

Não tem forget(), não tem Observer, não tem lista pra manter. E funciona no driver file, no database, em qualquer um.

O custo é lixo acumulado no cache até o TTL vencer. Em troca você ganha algo raro: é impossível esquecer de invalidar.

Estratégia 3: tags, quando é um grupo inteiro

Quando um único evento invalida vários caches de uma vez, tag é o caminho:

Cache::tags(['produtos', "categoria:{$produto->categoria_id}"])
    ->remember($chave, 3600, fn () => $produto->relacionados());

// depois, quando a categoria muda de nome:
Cache::tags(["categoria:{$id}"])->flush();
Enter fullscreen mode Exit fullscreen mode

Detalhe importante: tags só funcionam em Redis e Memcached. Se seu CACHE_STORE é file ou database, isso estoura na sua cara. É provavelmente o motivo número um de "funciona local, quebra em produção" (ou o contrário) quando o assunto é cache.

A pegadinha: o cache que mente no teste

Essa merece atenção porque é traiçoeira.

No phpunit.xml, o CACHE_STORE normalmente é array — um cache que morre no fim de cada teste. Ótimo pra isolamento, péssimo pra confiança: o teste passa mesmo quando sua invalidação está errada, porque o cache nunca sobrevive o suficiente pra servir dado velho.

Se o cache faz parte da regra de negócio, teste a invalidação de propósito:

it('esquece o cache dos destaques quando um produto muda', function () {
    Cache::put('produtos.destaques', collect(['velho']), 3600);

    Produto::factory()->create()->update(['nome' => 'novo']);

    expect(Cache::has('produtos.destaques'))->toBeFalse();
});
Enter fullscreen mode Exit fullscreen mode

E o parente confuso: php artisan config:cache não tem relação nenhuma com Cache:: — é outro assunto, outro arquivo, outra dor (essa eu já contei em outro post 😄).

Bônus: o dado velho que serve pra caramba

Às vezes o problema não é dado desatualizado, é o oposto: a chave expira, chegam 300 requests ao mesmo tempo e todos vão bater no banco pra recalcular a mesma coisa. É o famoso cache stampede.

O Laravel tem uma resposta pronta:

// fresquinho por 5 min; até 30 min serve o valor velho e recalcula em background
Cache::flexible('relatorio.vendas', [300, 1800], fn () => $this->calcular());
Enter fullscreen mode Exit fullscreen mode

Um request só paga o preço do recálculo. Os outros recebem o valor um pouco velho na hora, em vez de ficarem na fila do banco. Pra dashboard e relatório, isso é troca boa demais pra ignorar.

Antes de você fechar a aba

Antes de escrever Cache::remember(), responda uma pergunta: quem apaga isso, e quando?

Se você não sabe, não é cache — é um bug com data marcada. Pode ser Observer, pode ser chave versionada, pode ser TTL curto porque o dado suporta ficar dois minutos velho. Qualquer resposta serve. A ausência de resposta é que custa caro.

E me conta uma coisa: quanto tempo você já perdeu caçando um bug que era só cache? Eu já passei uma tarde inteira num "erro de cálculo" que era um Cache::remember de 24h que eu mesmo tinha escrito na semana anterior. 🙃

Top comments (0)