Otimização Prática de Performance no Laravel
Performance é a linha vital de um SaaS — usuários vão embora se precisarem esperar 3 segundos, e consultas lentas podem queimar centenas de dólares em taxas de banco de dados em um único dia.
1. O Que Você Vai Aprender
- Otimização de Consultas Eloquent: Eager Loading / Chunk / Cursor / Lazy
- Estratégia de Cache: Cache de Route/Config/View + cache de dados Redis
- Otimização de Banco de Dados: Estratégias de Indexação / Análise EXPLAIN / Separação Leitura-Escrita
- OPcache e Ajuste de PHP-FPM
- Teste de Carga: k6 / Apache Bench e Análise de Gargalos
2. A História Real de uma Empresa SaaS em Rápido Crescimento
(1) Dor: O tempo de resposta do ShopMetrics disparou de 200 ms para 5 s
A base de usuários do ShopMetrics cresceu de 1.000 para 50.000, e os dados de pedidos aumentaram de 100.000 para 5 milhões. Alice notou que o dashboard levava 5 segundos para carregar, e Bob viu que a taxa mensal do banco de dados havia saltado de 50 USD para 500 USD — tudo devido a consultas lentas queimando o orçamento. A análise de Charlie revelou que consultas N+1, índices ausentes e falta de cache eram os três principais culpados.
(2) Métodos Sistemáticos de Otimização
Através de quatro camadas de otimização — consultas, cache, índices e OPcache — cada camada reduziu o tempo de resposta pela metade, ultimately reduzindo de 5 segundos para 200 milissegundos.
Antes: 5000ms (consultas N+1, sem cache, sem índice)
Passo 1: 2500ms (eager loading, corrigir N+1)
Passo 2: 500ms (adicionar índices + otimização de consulta)
Passo 3: 200ms (cache Redis + OPcache)
Passo 4: 100ms (CDN + réplica de leitura)
(3) Resultado
Após a otimização de Bob, os custos do banco de dados caíram de 500 USD/mês para 150 USD/mês, e as taxas de retenção de usuários subiram de 70% para 90%.
3. Otimizando Consultas Eloquent
(1) O Problema de Consulta N+1
// ❌ N+1: 1 consulta para tenants + N consultas para shops
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // 1 consulta extra por tenant!
}
// Total: 1 + N consultas
// ✅ Eager loading: 2 consultas total
$tenants = Tenant::with('shops')->get();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // Sem consulta extra
}
// Total: 2 consultas
(2) Comparação de Métodos de Otimização de Consultas
| Método | Caso de Uso | Uso de Memória | Descrição |
|---|---|---|---|
all() |
Pequena quantidade de dados | Alto | Carregar tudo na memória de uma vez |
chunk() |
Iteração de Big Data | Baixo | Processar em blocos de N registros cada |
chunkById() |
Iteração de Big Data (Mais Estável) | Baixo | Fragmentado por ID, não afetado por alterações de dados |
cursor() |
Leitura baseada em stream | Muito baixo | Leitura linha por linha baseada em Generator |
lazy() |
Big Data, mas requer operações de agregação | Médio | Carregar chunks sob demanda |
each() |
Iteração + Processamento | Baixo | chunk + callback |
(1) ▶ Exemplo: Correção N+1 na Análise de Pedidos do ShopMetrics
// ❌ Antes: Dashboard carrega em 5 segundos
// Controller: 1 + 3*N consultas por tenant
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // Consulta 1
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop_id'); // Lazy loads shops: N consultas
$topProducts = $orders->flatMap->items; // Lazy loads items: N consultas
$customers = $orders->pluck('customer'); // Lazy loads customer: N consultas
}
// ✅ Depois: Dashboard carrega em 500ms
// Controller: 4 consultas total
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->with(['shop:id,name', 'items.product:id,name,price', 'customer:id,name'])
->whereMonth('created_at', now()->month)
->select(['id', 'shop_id', 'customer_id', 'total', 'created_at'])
->get();
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop.name');
$topProducts = $orders->flatMap->items->take(10);
$customers = $orders->pluck('customer.name');
}
Saída:
// Execução bem-sucedida
(2) ▶ Exemplo: Usando chunks e cursores com grandes conjuntos de dados
// Processar 5 milhões de pedidos sem estouro de memória
// Opção 1: chunk (processa 500 por vez)
Order::where('tenant_id', $tenant->id)
->chunk(500, function ($orders) {
foreach ($orders as $order) {
$this->calculateOrderMetrics($order);
}
});
// Opção 2: chunkById (mais estável com escritas concorrentes)
Order::where('tenant_id', $tenant->id)
->select(['id', 'total', 'status'])
->chunkById(500, function ($orders) {
$metrics = $orders->groupBy('status')->map->sum('total');
Cache::put("metrics:{$tenant->id}", $metrics, 3600);
});
// Opção 3: cursor (streaming, menor memória)
$total = 0;
foreach (Order::where('tenant_id', $tenant->id)->cursor() as $order) {
$total += $order->total;
}
// Opção 4: lazy (métodos de collection disponíveis)
$highValue = Order::where('tenant_id', $tenant->id)
->lazy(500)
->filter(fn ($o) => $o->total > 1000)
->count();
Saída:
// Execução bem-sucedida
4. Estratégia de Cache
(1) Hierarquia de Cache do Laravel
flowchart TD
A[Requisição] --> B{Cache de Rotas?}
B -->|Hit| C[Rotas em Cache]
B -->|Miss| D[Analisar Rotas]
D --> E{Cache de Config?}
C --> E
E -->|Hit| F[Config em Cache]
E -->|Miss| G[Carregar .env + Arquivos Config]
G --> H{Cache de Dados?}
F --> H
H -->|Hit| I[Retornar Dados em Cache]
H -->|Miss| J[Consultar BD + Armazenar em Cache]
I --> K[Resposta]
J --> K
(2) Tipos de Cache e Casos de Uso
| Tipo de Cache | Comando/Método | Caso de Uso | Persistência |
|---|---|---|---|
| Cache de Config | config:cache |
Configuração de Ambiente de Produção | Longo prazo (até limpo) |
| Cache de Rotas | route:cache |
Muitas rotas, poucas closures | Longo prazo (até limpo) |
| Cache de Views | view:cache |
Compilação de Views Blade | Longo prazo (até limpo) |
| Cache de Dados | Cache::put() |
Resultados de Consulta/Dados de Relatório | TTL Expirado |
| Cache de Consulta | remember() |
Consultas Frequentes | TTL Expirado |
| OPcache | php.ini | Bytecode PHP | Válido até restart |
(1) ▶ Exemplo: Estratégia de Cache Redis do ShopMetrics
// app/Services/DashboardService.php
class DashboardService
{
public function getOverview(Tenant $tenant): array
{
return Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->calculateOverview($tenant)
);
}
public function getTopProducts(Tenant $tenant, string $period = '7d'): Collection
{
return Cache::remember(
"dashboard:{$tenant->id}:top-products:{$period}",
now()->addHours(1),
fn () => $this->calculateTopProducts($tenant, $period)
);
}
public function getRevenueChart(Tenant $tenant, string $granularity = 'daily'): array
{
return Cache::remember(
"dashboard:{$tenant->id}:revenue:{$granularity}",
now()->addMinutes(30),
fn () => $this->calculateRevenueChart($tenant, $granularity)
);
}
public function invalidateTenantCache(Tenant $tenant): void
{
$prefix = "dashboard:{$tenant->id}";
// Redis KEYS é lento em grandes conjuntos de dados; use tags ou prefix scan
Cache::getStore()->getConnection()->del(
...Cache::getStore()->getConnection()->keys("{$prefix}:*")
);
}
}
// Controller
class DashboardController extends Controller
{
public function __construct(
private DashboardService $dashboard
) {}
public function index(Tenant $tenant)
{
return response()->json([
'overview' => $this->dashboard->getOverview($tenant),
'top_products' => $this->dashboard->getTopProducts($tenant),
'revenue_chart' => $this->dashboard->getRevenueChart($tenant),
]);
}
}
Saída:
// Execução bem-sucedida
(3) Tags de Cache e Expiração em Lote
// Cache com tags (apenas drivers Redis/database)
Cache::tags(['dashboard', "tenant:{$tenant->id}"])->put(
'overview',
$data,
3600
);
// Invalidar todo cache de dashboard para um tenant
Cache::tags(["tenant:{$tenant->id}"])->flush();
// Ou usar convenção de chaves de cache com prefix scan
Cache::forget("dashboard:{$tenant->id}:overview");
Cache::forget("dashboard:{$tenant->id}:top-products:7d");
Cache::forget("dashboard:{$tenant->id}:revenue:daily");
5. Otimização de Banco de Dados
(1) Estratégia de Indexação
// Migration: Adicionar índices para consultas do ShopMetrics
Schema::table('orders', function (Blueprint $table) {
// Índice de coluna única
$table->index('tenant_id', 'idx_orders_tenant');
$table->index('created_at', 'idx_orders_date');
$table->index('status', 'idx_orders_status');
// Índice composto (mais útil para consultas multi-condição)
$table->index(['tenant_id', 'created_at'], 'idx_orders_tenant_date');
$table->index(['tenant_id', 'status', 'created_at'], 'idx_orders_tenant_status_date');
// Índice único
$table->unique(['tenant_id', 'external_id'], 'uniq_orders_tenant_external');
});
(2) Comparação de Tipos de Índice
| Tipo de Índice | Sintaxe | Casos de Uso | Descrição |
|---|---|---|---|
| Index | $table->index(col) |
WHERE/ORDER BY | B-tree padrão |
| Unique | $table->unique(col) |
Restrição de Unicidade | B-tree + Remoção de Duplicatas |
| Composto | $table->index([c1,c2]) |
Consulta multi-condição | Segue o prefixo mais à esquerda |
| FullText | $table->fullText(col) |
Busca Textual | MySQL 8.0+ |
| Spatial | $table->spatialIndex(col) |
Consulta Geográfica | POINT/LINESTRING |
(3) EXPLAIN: Analisando Consultas
-- Analisar consulta lenta
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 1 AND status = 'completed'
ORDER BY created_at DESC LIMIT 20;
-- Procure por:
-- type: ALL (full scan) → precisa de índice
-- key: NULL (nenhum índice usado) → precisa de índice
-- rows: número grande → precisa de otimização
-- Extra: Using filesort → precisa de índice composto
(1) ▶ Exemplo: Diagnóstico e Otimização de Consulta Lenta do ShopMetrics
-- ❌ Antes: Full table scan em 5M linhas (3000ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ALL, rows: 5,000,000, key: NULL
-- Adicionar índice
ALTER TABLE orders ADD INDEX idx_orders_tenant (tenant_id);
-- ✅ Depois: Scan de índice (5ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ref, rows: 50,000, key: idx_orders_tenant
-- ❌ Ainda lento: Ordenação sem índice (500ms)
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Extra: Using filesort
-- ✅ Índice composto (3ms)
ALTER TABLE orders ADD INDEX idx_orders_tenant_date (tenant_id, created_at DESC);
Saída:
CREATE TABLE
(4) Separação Leitura-Escrita
// config/database.php
'mysql' => [
'read' => [
'host' => env('DB_READ_HOST', 'read-replica.shopmetrics.internal'),
],
'write' => [
'host' => env('DB_WRITE_HOST', 'primary.shopmetrics.internal'),
],
'sticky' => true, // Ler do host de escrita após escrita na mesma requisição
'driver' => 'mysql',
'database' => env('DB_DATABASE', 'shopmetrics'),
// ...
],
6. Otimizando OPcache e PHP-FPM
(1) Configuração OPcache
; php.ini ou php.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256 ; 256MB para cache de bytecode
opcache.interned_strings_buffer=32 ; 32MB para strings
opcache.max_accelerated_files=40000 ; Laravel tem ~12000 arquivos
opcache.validate_timestamps=0 ; OFF em produção (reset manual)
opcache.save_comments=1 ; Necessário para anotações do Laravel
opcache.jit=1255 ; PHP 8.2 JIT: modo tracing
opcache.jit_buffer_size=128M ; 128MB buffer JIT
(2) Ajuste de PHP-FPM
; php-fpm.d/www.conf
pm = dynamic
pm.max_children = 50 ; = (RAM - SO) / avg_por_processo
pm.start_servers = 10 ; = min_spare + (max - min) / 2
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000 ; Prevenir vazamentos de memória
(3) Resetando OPcache
# Após deploy, resetar OPcache
php artisan optimize:clear
# Ou via rota
Route::get('/opcache-clear', function () {
if (app()->environment('production')) {
opcache_reset();
}
return 'OPcache limpo';
})->middleware('auth:api');
(1) ▶ Exemplo: Script One-Click de Cache de Produção do ShopMetrics
#!/bin/bash
# deploy-optimize.sh — Executar após cada deploy
set -e
echo "→ Limpando todos os caches..."
php artisan optimize:clear
echo "→ Armazenando config em cache..."
php artisan config:cache
echo "→ Armazenando rotas em cache..."
php artisan route:cache
echo "→ Armazenando views em cache..."
php artisan view:cache
echo "→ Executando migrations..."
php artisan migrate --force
echo "→ Reiniciando workers da fila..."
php artisan queue:restart
echo "→ Limpando OPcache..."
php artisan optimize
echo "✓ Otimização concluída!"
Saída:
# Comando executado com sucesso
7. Teste de Carga
(1) Comparação de Ferramentas
| Ferramentas | Linguagens | Vantagens | Casos de Uso |
|---|---|---|---|
| Apache Bench (ab) | C | Simples, sem dependências | Stress test rápido de um único endpoint API |
| k6 | JS | Scripts flexíveis, compatível com CI | Cenários complexos + CI |
| wrk | C | Alta Concorrência, Baixos Recursos | Teste de Concorrência Extrema |
| JMeter | Java | Rica seleção de GUIs e plugins | Testes empresariais complexos |
(2) Script de Teste de Carga k6
// k6-scripts/dashboard-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // Rampa até 20 usuários
{ duration: '2m', target: 20 }, // Manter em 20
{ duration: '30s', target: 50 }, // Rampa até 50
{ duration: '2m', target: 50 }, // Manter em 50
{ duration: '30s', target: 0 }, // Rampa para baixo
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% requisições < 500ms
http_req_failed: ['rate<0.01'], // Taxa de erro < 1%
},
};
const BASE_URL = 'https://api.shopmetrics.io';
const TOKEN = __ENV.API_TOKEN;
export default function () {
const params = {
headers: { Authorization: `Bearer ${TOKEN}` },
};
// Testar API de dashboard
const res = http.get(`${BASE_URL}/api/v1/dashboard`, params);
check(res, {
'status é 200': (r) => r.status === 200,
'tempo de resposta < 500ms': (r) => r.timings.duration < 500,
'tem dados de overview': (r) => JSON.parse(r.body).overview !== null,
});
sleep(1);
}
(3) Comandos Rápidos de Teste de Carga
# Apache Bench: 100 usuários concorrentes, 1000 requisições total
ab -n 1000 -c 100 -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# wrk: 50 conexões por 30 segundos
wrk -t4 -c50 -d30s -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# k6: Executar teste de carga com verificação de limiares
k6 run --env API_TOKEN=$TOKEN k6-scripts/dashboard-load.js
8. Exemplo Compreensivo: Comparação Antes e Depois da Otimização do ShopMetrics
// ============================================
// Compreensivo: Otimização da API de Dashboard
// Antes vs Depois com todas as técnicas aplicadas
// ============================================
// ❌ ANTES: Dashboard lento (5 segundos, 200+ consultas)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // Carrega TODAS as colunas, sem eager loading
return response()->json([
'total_revenue' => $orders->sum('total'),
'order_count' => $orders->count(),
'top_shops' => $orders->groupBy('shop_id') // N+1 em shop
->map->sum('total')
->sortDesc()
->take(5),
'recent_orders' => $orders->sortByDesc('created_at')
->take(10)
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name, // N+1 em customer
'total' => $o->total,
]),
]);
}
}
// ✅ DEPOIS: Dashboard rápido (100ms, 3 consultas, com cache)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$data = Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->buildDashboardData($tenant)
);
return response()->json($data);
}
private function buildDashboardData(Tenant $tenant): array
{
// Consulta única otimizada com agregações
$stats = DB::table('orders')
->where('tenant_id', $tenant->id)
->whereMonth('created_at', now()->month)
->select([
DB::raw('SUM(total) as total_revenue'),
DB::raw('COUNT(*) as order_count'),
])
->first();
// Top lojas: 1 consulta
$topShops = DB::table('orders')
->join('shops', 'orders.shop_id', '=', 'shops.id')
->where('orders.tenant_id', $tenant->id)
->whereMonth('orders.created_at', now()->month)
->groupBy('shops.id', 'shops.name')
->orderByDesc('revenue')
->limit(5)
->select('shops.name', DB::raw('SUM(orders.total) as revenue'))
->get();
// Pedidos recentes com eager loading: 1 consulta
$recentOrders = Order::with('customer:id,name')
->where('tenant_id', $tenant->id)
->select(['id', 'customer_id', 'total', 'created_at'])
->latest()
->limit(10)
->get()
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name,
'total' => $o->total,
]);
return [
'total_revenue' => (float) $stats->total_revenue,
'order_count' => (int) $stats->order_count,
'top_shops' => $topShops,
'recent_orders' => $recentOrders,
];
}
}
flowchart LR
subgraph "Antes: 5000ms"
A1[200+ Consultas SQL] --> A2[Sem Cache]
A2 --> A3[Sem Índice]
A3 --> A4[Loading N+1]
end
subgraph "Depois: 100ms"
B1[3 Consultas SQL] --> B2[Cache Redis 15min]
B2 --> B3[Índice Composto]
B3 --> B4[Eager Loading]
end
A4 -->|Otimizar| B1
❓ Perguntas Frequentes
with()), e apenas use lazy loading quando tiver certeza de que não acessará os dados associados. Use Laravel Debugbar para monitorar o número de consultas — problemas N+1 serão imediatamente aparentes.cursor() usa menos memória mas só pode processar linha por linha; não pode pular linhas e não suporta segurança de escrita concorrente. chunkById() é mais estável e adequado para processamento em lote em ambientes de produção. chunkById() é usado na maioria dos cenários.EXPLAIN para verificar se o índice está realmente sendo usado (coluna key); Verifique se a ordem das colunas indexadas segue o princípio do prefixo mais à esquerda; verifique se uma ordenação está ocorrendo em um grande conjunto de resultados (filesort); considere usar um índice de cobertura para reduzir scans de tabela.opcache.validate_timestamps=1. Caso contrário, alterações de código não terão efeito, o que pode ser confuso durante a depuração. Certifique-se de habilitar em ambiente de produção e defina validate_timestamps=0.📖 Resumo
- Consultas N+1 são um assassino de performance; eager loading resolve o problema com uma linha de código
- Use
chunkByIdoucursorcom grandes conjuntos de dados para prevenir estouro de memória - Redis armazena em cache resultados de consultas, usando TTL e expiração manual como dupla salvaguarda
- Índices compostos seguem o princípio do prefixo mais à esquerda; verificados usando EXPLAIN
- Otimizar OPcache + PHP-FPM para fazer seu Ambiente de Produção Voar
- Valide a eficácia da otimização através de testes de carga, com um alvo de p95 < 500 ms
📝 Exercícios
-
Exercício Básico (⭐): Identifique qualquer consulta N+1 no ShopMetrics, corrija usando
with()e compare o número de consultas antes e depois da otimização (registrado usandoDB::enableQueryLog()). -
Exercício Avançado (⭐⭐): Adicione cache Redis (TTL de 15 minutos) à API de dashboard do ShopMetrics e implemente limpeza automática de cache quando os dados mudam. Escreva um script k6 para testar a taxa de acerto do cache.
-
Desafio (⭐⭐⭐): Otimize completamente a API de lista de pedidos do ShopMetrics — adicione um índice composto (
tenant_id + status + created_at), substitua operações Collection por consultas agregadas DB::table, implemente cache e useEXPLAINpara verificar a performance, com alvo p95 < 200 ms.



