Artigo 49 — Performance e Otimização
Módulo 10 · Semana 49 · Nível: Avançado
Introdução
Performance não é premature optimization — é entender onde estão os gargalos reais e eliminá-los sistematicamente. A regra 80/20 se aplica com precisão: 80% dos problemas de performance vêm de 20% do código, e esses 20% quase sempre são consultas ao banco de dados mal escritas ou ausência de cache.
OPcache — a otimização mais simples e impactante
O PHP, por padrão, parseia e compila cada arquivo .php em cada requisição. OPcache armazena o bytecode compilado em memória, eliminando essa etapa:
; /etc/php/8.3/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 256 ; MB
opcache.interned_strings_buffer = 16
opcache.max_accelerated_files = 20000
opcache.revalidate_freq = 0 ; 0 = nunca revalida em produção
opcache.validate_timestamps = 0 ; 0 = não verifica timestamps (produção)
opcache.save_comments = 1 ; necessário para Doctrine/annotations
opcache.jit_buffer_size = 100M ; JIT — PHP 8+
opcache.jit = 1255 ; habilita JIT
O impacto é imediato e significativo: aplicações PHP típicas ficam entre 2x e 5x mais rápidas apenas com OPcache bem configurado.
Cache de queries com Redis
<?php
declare(strict_types=1);
class ProdutoRepositorioComCache implements ProdutoRepositorioInterface
{
private const TTL_LISTA = 300; // 5 minutos
private const TTL_PRODUTO = 3600; // 1 hora
public function __construct(
private readonly ProdutoRepositorioInterface $repositorio,
private readonly \Redis $redis,
) {}
public function listarAtivos(int $pagina = 1): array
{
$chave = "produtos:lista:pagina:{$pagina}";
$cached = $this->redis->get($chave);
if ($cached !== false) {
return json_decode($cached, associative: true);
}
$resultado = $this->repositorio->listarAtivos($pagina);
$this->redis->setex($chave, self::TTL_LISTA, json_encode($resultado));
return $resultado;
}
public function buscarPorId(int $id): ?array
{
$chave = "produtos:id:{$id}";
$cached = $this->redis->get($chave);
if ($cached !== false) {
return json_decode($cached, associative: true);
}
$produto = $this->repositorio->buscarPorId($id);
if ($produto !== null) {
$this->redis->setex($chave, self::TTL_PRODUTO, json_encode($produto));
}
return $produto;
}
public function invalidarCache(int $id): void
{
$this->redis->del("produtos:id:{$id}");
// Invalida todas as páginas da listagem
$chaves = $this->redis->keys('produtos:lista:*');
if (!empty($chaves)) {
$this->redis->del(...$chaves);
}
}
}
Detectando e corrigindo o problema N+1
O N+1 é o assassino de performance mais comum em ORMs. Acontece quando você faz 1 query para buscar N registros e depois N queries para buscar dados relacionados:
<?php
// PROBLEMA: 1 query para listar 50 pedidos + 50 queries para buscar o cliente de cada um
$pedidos = Pedido::where('status', 'pendente')->get(); // 1 query
foreach ($pedidos as $pedido) {
echo $pedido->usuario->nome; // 1 query por iteração = 50 queries!
}
// SOLUÇÃO: eager loading — traz tudo em 2 queries
$pedidos = Pedido::with('usuario')
->where('status', 'pendente')
->get(); // 2 queries: uma para pedidos, uma para todos os usuários relacionados
foreach ($pedidos as $pedido) {
echo $pedido->usuario->nome; // sem query adicional
}
// Para detectar N+1 automaticamente no Laravel:
// composer require --dev barryvdh/laravel-debugbar
// Ou no código:
\DB::listen(function ($query) {
if ($query->time > 100) {
\Log::warning("Query lenta ({$query->time}ms): {$query->sql}");
}
});
Índices de banco de dados
-- Analisar uma query lenta com EXPLAIN ANALYZE (PostgreSQL) ou EXPLAIN (MySQL)
EXPLAIN ANALYZE
SELECT p.*, c.nome AS categoria
FROM produtos p
JOIN categorias c ON c.id = p.categoria_id
WHERE p.ativo = true AND p.estoque > 0
ORDER BY p.criado_em DESC
LIMIT 15;
-- Se aparecer "Seq Scan" numa tabela grande, adicione índice:
CREATE INDEX idx_produtos_ativo_estoque ON produtos(ativo, estoque)
WHERE ativo = true AND estoque > 0; -- índice parcial (PostgreSQL)
CREATE INDEX idx_produtos_criado_em ON produtos(criado_em DESC);
-- Índice para busca de texto (MySQL)
ALTER TABLE produtos ADD FULLTEXT INDEX idx_fulltext_nome_descricao(nome, descricao);
-- Verificar uso de índices no MySQL
SHOW INDEX FROM produtos;
SELECT * FROM sys.schema_unused_indexes; -- índices que nunca são usados
Filas para tarefas pesadas
Nada degrada a experiência do usuário mais do que uma requisição HTTP esperando um email ser enviado, um PDF ser gerado, ou uma imagem ser processada. Mova tudo que pode esperar para fila:
<?php
// No Laravel
namespace App\Jobs;
use App\Models\Pedido;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class ProcessarPedido implements ShouldQueue
{
use Dispatchable, Queueable;
public int $tries = 3;
public int $timeout = 120;
public function __construct(
private readonly int $pedidoId
) {}
public function handle(): void
{
$pedido = Pedido::findOrFail($this->pedidoId);
// Operações pesadas aqui — não bloqueiam a resposta HTTP
$this->processarPagamento($pedido);
$this->notificarVendedor($pedido);
$this->atualizarEstoque($pedido);
$this->enviarEmailConfirmacao($pedido);
}
public function backoff(): array
{
return [30, 60, 120]; // segundos entre retentativas
}
}
// Disparar — retorna imediatamente para o usuário
ProcessarPedido::dispatch($pedido->id);
// Com delay
ProcessarPedido::dispatch($pedido->id)->delay(now()->addMinutes(5));
Profiling com Xdebug e Blackfire
# Instalar Blackfire (profiler profissional)
curl -sS https://packages.blackfire.io/gpg.key | sudo apt-key add -
echo "deb http://packages.blackfire.io/debian any main" | sudo tee /etc/apt/sources.list.d/blackfire.list
sudo apt update && sudo apt install blackfire
# Perfilar uma requisição
blackfire curl http://localhost:8000/produtos
# Saída mostra:
# - Funções mais lentas
# - Queries mais lentas
# - Memória consumida
# - Chamadas de função e tempo em cada uma
Resumo
| Técnica | Impacto típico |
|---|---|
| OPcache + JIT | 2–5x de melhoria geral |
| Cache de queries (Redis) | Elimina 80–99% das queries repetidas |
| Eager loading | Reduz N queries para 2 |
| Índices corretos | 10–1000x em queries específicas |
| Filas para tarefas pesadas | Resposta HTTP < 200ms |
| Blackfire | Identifica exatamente onde está o gargalo |
Exercício da semana
-
Configure OPcache e JIT no PHP. Use
apache bench(ab -n 1000 -c 10 http://localhost/) para medir o impacto antes e depois. -
Identifique um N+1 em um projeto seu usando
\DB::listen(). Corrija comwith()e meça a diferença no número de queries. -
Adicione cache Redis ao repositório de produtos: lista com TTL de 5 minutos, produto individual com 1 hora. Implemente invalidação ao criar/atualizar/deletar.
-
Mova o envio de email de confirmação de pedido para uma job. Garanta que o endpoint
/pedidosretorna em menos de 200ms mesmo que o email demore 2 segundos. -
Desafio: implemente cache HTTP com
ETageLast-Modified: na rota de listagem de produtos, calcule o hash do resultado, envie comoETag. Se o cliente enviarIf-None-Matchcom o mesmo hash, retorne304 Not Modifiedsem corpo — economizando banda e processamento.
Referências
- PHP OPcache: https://www.php.net/manual/pt_BR/book.opcache.php
- Redis para PHP: https://github.com/phpredis/phpredis
- Blackfire Profiler: https://www.blackfire.io/
- Laravel Queues: https://laravel.com/docs/queues
- Use The Index, Luke (guia de índices SQL): https://use-the-index-luke.com/
Progresso: 49/52 artigos — faltam apenas 3.
Artigos restantes: - 50 — Projeto Final Parte 1: Planejamento e Arquitetura - 51 — Projeto Final Parte 2: Implementação - 52 — Projeto Final Parte 3: Testes, Deploy e Encerramento
Continuo com o projeto final?
you asked
Sim.