1

Cache impecavel e o app continuava lento

Trocar de página no meu SaaS estava arrastando. Não era lentidão de deixar o usuário esperando dez segundos, era aquela demora curta e constante que faz o app parecer barato. Sentei pra resolver com a hipótese pronta na cabeça: falta cache em algum lugar.

Estava errado, e a hipótese errada era exatamente o que me impedia de achar o problema. O gargalo não estava em nada que eu pudesse cachear. Estava numa camada que eu tinha parado de enxergar faz tempo, porque ela funciona bem demais pra chamar atenção.

O que eu já tinha, e por que não adiantava nada

Vale começar por aqui, porque é a parte contraintuitiva. Cache no meu projeto não é improviso. Tem duas fachadas finas, TenantCache e GlobalCache, que embrulham o cache do Laravel e garantem três coisas: toda chave nasce com escopo de time (t{teamId}:{domínio}:{sufixo}), toda entrada nasce com TTL vindo de um enum (não existe "cachear pra sempre") e toda entrada nasce com tag pra invalidação em bloco. Tem flexible pro stale-while-revalidate nos lugares quentes, tem observer invalidando por domínio quando o dado muda, e tem teste de arquitetura proibindo Cache:: e cache() direto no código de domínio, pra ninguém (nem a IA, nem eu às 2 da manhã) furar a fila.

É a parte da infra que eu tenho mais orgulho. E ela não tinha nada a ver com o problema.

A lição, que eu só entendi depois de perder um tempo bom procurando no lugar errado: cache resolve computação cara que se repete. Ele não resolve payload que é remontado do zero em toda navegação. Se o custo é "vinte queries baratinhas que rodam sempre", cache não te salva, porque não tem uma resposta cara pra guardar. Só tem muita coisa pequena acontecendo demais.

Onde estava, de verdade

No Inertia, cada navegação é um request completo pro servidor. E existe um lugar central, o HandleInertiaRequests, que monta os props compartilhados que toda página recebe: usuário logado, times do usuário, time atual, permissões, features do plano, contadores da navegação. Isso remonta em toda navegação. É o código mais executado do sistema inteiro, e o que menos aparece quando você vai investigar performance, porque ninguém abre o middleware de shared props procurando query lenta.

Liguei o log de queries e contei. Um usuário com 10 times pagava 27 queries por navegação. Duas causas, e as duas são clássicas com fantasia nova.

A primeira é um N+1 escondido atrás de um método de conveniência. O toUserTeams() carrega os times numa query só, bonito, e depois mapeia cada time pra montar o payload. Só que dentro desse map ele chama teamRole($team) pra saber o papel do usuário naquele time, e o teamRole() era uma query. Uma por time. O eager loading estava lá, na relação errada: eu carregava os times e continuava buscando as memberships uma a uma. Cada time novo que o usuário entrasse adicionava uma query a cada clique dele no app pra sempre.

A segunda foi a que mais me irritou, porque é uma pegadinha específica de Inertia. Os props são closures, e closures avaliam de forma independente:

// app/Http/Middleware/HandleInertiaRequests.php
'currentTeam'     => fn () => $this->resolveCurrentUserTeam($user),
'teamPermissions' => fn () => $user->toTeamPermissions($this->resolveActiveTeam($user)),
'teamFeatures'    => fn () => $this->teamFeatures($this->resolveActiveTeam($user)),
'teamTrial'       => fn () => $this->teamTrial($this->resolveActiveTeam($user)),

Quatro props diferentes precisam do time ativo, e o resolveActiveTeam resolvia tudo do zero nas quatro vezes: lê a sessão, busca o time, confere se o usuário pertence a ele. Quatro vezes o mesmo trabalho, todo request. Não tem nada de errado com o código de cada linha isolada, e é justamente por isso que passa despercebido em code review: o problema não está em nenhuma delas, está no fato de serem quatro.

O conserto das duas cabe em pouca coisa. Carrega a relação uma vez, no ponto onde o payload é montado:

// app/Http/Middleware/HandleInertiaRequests.php
// Uma leitura de membership pro payload inteiro: teams, currentTeam e
// teamPermissions todos resolvem papel, e toUserTeams() faz isso uma vez por time.
$user?->load('teamMemberships');

E o teamRole() passa a preferir a relação quando ela já está em memória:

// app/Concerns/HasTeams.php
public function teamRole(Team $team): ?TeamRole
{
    if ($this->relationLoaded('teamMemberships')) {
        return $this->teamMemberships->firstWhere('team_id', $team->id)?->role;
    }

    return $this->teamMemberships()->where('team_id', $team->id)->first()?->role;
}

Mais a memoização do time ativo, e são 15 queries em vez de 27. O número menor é bom, mas não é o ponto. O ponto é que agora é constante. Antes, o custo de cada navegação crescia junto com a quantidade de times do usuário, o que significa que o app ficava mais lento exatamente pros clientes mais engajados. Esse é o tipo de curva que você não quer descobrir em produção.

A correção óbvia quebrou 208 testes, e ela estava certa em quebrar

Agora a parte que interessa mais que o ganho.

Se teamRole() fica mais rápido lendo da relação carregada, o belongsToTeam() também deveria, certo? É a mesma tabela, a mesma pergunta, e ele é chamado várias vezes por request. Fiz a mudança óbvia. 208 testes ficaram vermelhos.

O primeiro impulso quando 208 testes quebram de uma vez é achar que os testes estão errados, ou que é setup de teste, alguma coisa de fixture. Não era. Era um bug de verdade, e o cenário é este:

O usuário aceita um convite pra um time. No meio desse request, a membership é criada no banco. Logo depois, no mesmo request, o switchTeam() chama belongsToTeam() pra confirmar que ele pode entrar naquele time antes de trocar. Com a relação carregada em memória, essa leitura devolve o estado de antes da escrita que acabou de acontecer. A membership existe no banco, e o objeto na memória não sabe. O belongsToTeam() responde "não pertence", o switchTeam() desiste, e o usuário aceita o convite e continua no time anterior.

Sem erro. Sem exceção. Sem log. A tela recarrega e ele está no lugar errado.

Esse é o mesmo inimigo dos dois posts anteriores com outra roupa. Lá foi um teste verde guardando a porta errada, e uma cláusula falsa escrita com confiança porque era plausível. Aqui é uma otimização legítima que troca correção por velocidade num caminho que ninguém percorre com frequência suficiente pra notar. Otimização não avisa quando quebra alguma coisa. Ela fica rápida e errada, que é bem pior que devagar e certa.

Então belongsToTeam() continua sendo query de propósito, e isso virou comentário no código, porque uma decisão dessas sem o porquê escrito ao lado dura até a próxima pessoa (ou o próximo prompt) achar que está otimizando:

// app/Concerns/HasTeams.php
/**
 * Deliberadamente sempre query, diferente do teamRole(). O switchTeam() gateia
 * nisso logo depois de um convite criar a membership, e uma relação pré-carregada
 * ainda estaria mostrando o estado de antes dessa escrita: a troca falharia calada
 * e deixaria o usuário no time anterior.
 */

O critério geral que eu tirei disso, e que eu vou levar pros próximos: relação em cache é segura pra leitura de exibição, e perigosa pra leitura de decisão. Montar o payload que a tela vai renderizar pode ler de memória à vontade, o pior caso é mostrar um papel desatualizado por um request. Autorizar, gatear, decidir se o usuário pode entrar, isso precisa da verdade do banco naquele instante. E é exatamente por isso que a relação é carregada no middleware, na hora de montar a resposta, e não dentro do model. Se eu carregasse dentro do model, ela ficaria grudada ali pelo resto do request e envenenaria toda decisão que viesse depois.

O teste que trava a regressão sem me atrapalhar depois

Otimização sem teste volta atrás sozinha na terceira feature. Mas eu não queria um teste que trava número absoluto de queries, porque esse tipo de teste vira inimigo: qualquer feature nova que adicione uma query legítima deixa o build vermelho, alguém sobe o número, e em três meses ele não guarda mais nada.

Então o teste principal é de escala, não de contagem:

// tests/Feature/SharedPropsQueryBudgetTest.php
test('props compartilhados custam o mesmo, independente de quantos times o usuário tem', function (): void {
    $user = User::factory()->create();
    $team = $user->currentTeam;

    $comUmTime = queriesForVisit($this, $user->id, $team);

    Team::factory()->count(9)->create()->each(
        fn (Team $extra) => $extra->members()->attach($user, ['role' => TeamRole::Member->value])
    );

    // Se teamRole() virar query por chamada de novo, cada time extra soma
    // uma query a toda navegação do app. Aqui isso fica vermelho na hora.
    expect(queriesForVisit($this, $user->id, $team))->toBe($comUmTime);
});

Ele não diz "custe 15 queries", diz "custe o mesmo com 1 time e com 10". Ou seja, ele não me atrapalha quando eu adicionar uma query honesta, e só abre a boca quando alguém reintroduzir a curva.

Duas armadilhas na hora de escrever isso, e as duas fariam o teste passar mentindo. A primeira: o usuário precisa ser rebuscado do banco a cada visita, senão a segunda visita começa com a relação já carregada da primeira e esconde justamente a regressão que o teste existe pra pegar. A segunda: precisa dar Cache::flush() entre as medições, porque o store de array usado em teste sobrevive entre requests, e a segunda visita sairia "mais barata" por servir contador de navegação do cache, um motivo que não tem nada a ver com o que está sendo medido.

E a parte que faltava: eu não media nada

O achado mais constrangedor da sessão não foi o N+1. Foi perceber que eu descobri o N+1 na mão, contando query com log ligado, porque não existia nenhuma medição de performance no projeto. Um SaaS em produção, com fila, com cache tenant-aware, com multi-tenancy, e zero visibilidade de quanto custa um request.

Entraram os dois oficiais do Laravel, com papéis bem separados. Telescope pra local, que é onde você quer ver a lista crua de queries de um request específico. Pulse pra produção, que é onde você quer tendência ao longo do tempo. Telescope é dependência de dev, excluído do package discovery e registrado à mão só quando APP_ENV=local, porque Telescope ligado em produção é ele próprio um problema de performance.

O Pulse tem um detalhe que quase ninguém conta e que vale o post inteiro: no default, ele grava direto no banco, em todo request. Ou seja, se você instalar e sair usando, as tabelas pulse_* viram o gargalo que o Pulse existe pra medir. As travas que eu coloquei:

TravaPor quê
PULSE_INGEST_DRIVER=redisRequest grava num stream Redis e um daemon (pulse:work) drena em lote. É a trava principal.
Redis DB 2, separadoExigência do próprio Pulse: ele nunca deve dividir conexão com a fila que o Horizon drena.
PULSE_STORAGE_KEEP="7 days"Retenção com poda automática. Sem isso as tabelas crescem pra sempre.
sample_rate 0.1 nos recorders de volumeGrava ~10% e escala o número no dashboard.
Recorder de cache desligadoCom TenantCache rodando em todo request, registrar cada hit/miss é a via mais rápida de inchar tudo. Ligo só pra investigar taxa de acerto, e desligo depois.

E aí, no meio disso, mais uma falha silenciosa, que já virou o tema da minha vida. O PULSE_STORAGE_KEEP espera uma string de intervalo, "7 days". Se você escrever 7, o CarbonInterval estoura com Invalid part 7 in definition 7. Só que o Pulse engole exceções por design, pra nunca derrubar a aplicação por causa de telemetria. Resultado: a poda falha calada, você não vê nada de errado no app, e descobre daqui a uns meses quando o banco estiver gordo sem explicação. Está anotado no runbook de deploy junto com o resto.

As 3 regras que eu tiro disso

  1. Antes de cachear, conte. Cache é a primeira hipótese de todo mundo e resolve um problema específico: computação cara que se repete. Se o custo é muita coisa pequena rodando sempre, você não precisa de cache, precisa de menos queries. Ligue o log e conte antes de decidir.
  2. Olhe primeiro o código que roda em toda navegação. No Inertia são os shared props, no seu stack vai ser outro nome. É o código mais executado do sistema e o menos revisado, porque ele não pertence a nenhuma feature. Uma query desnecessária ali custa mais que uma query lenta numa página que ninguém abre.
  3. Relação em cache é pra exibir, nunca pra decidir. Ler de memória pra montar a tela, tudo bem. Ler de memória pra autorizar, gatear ou trocar de contexto, não: se alguma coisa escreveu no meio do request, você decide com dado velho e falha sem barulho.

Pra fechar, com honestidade

O ganho real aqui não foi de 27 pra 15 queries, foi trocar uma curva por uma constante. E eu só achei isso porque a hipótese fácil (cache) não colou e eu fui obrigado a medir de verdade.

Ficou dívida à vista, e eu deixei escrita no próprio teste em vez de fingir que não existe: uma página com escopo de time ainda faz quatro checagens de membership por request, duas delas em middlewares que rodam colados e poderiam dividir uma leitura só. O teste trava em quatro com uma regra clara, esse número pode diminuir, nunca aumentar. Se um dia virar cinco, é porque alguém enfiou mais uma checagem no pipeline sem perceber, e eu quero saber no mesmo dia.

Se o seu app tem "aquela lentidão que ninguém explica", meu palpite é que ela não está na página que você está olhando. Vai no seu middleware de props compartilhados, liga o log de query e conta. Me conta o que você achou.

Carregando publicação patrocinada...