404 Not Found

404 Not Found


nginx

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


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.

TEXT
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

PHP
// ❌ 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

PHP
// ❌ 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:

TEXT
// Execução bem-sucedida

(2) ▶ Exemplo: Usando chunks e cursores com grandes conjuntos de dados

PHP
// 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:

TEXT
// Execução bem-sucedida

4. Estratégia de Cache

(1) Hierarquia de Cache do Laravel

100%
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

PHP
// 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:

TEXT
// Execução bem-sucedida

(3) Tags de Cache e Expiração em Lote

PHP
// 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

PHP
// 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

SQL
-- 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

SQL
-- ❌ 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:

TEXT
CREATE TABLE

(4) Separação Leitura-Escrita

PHP
// 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

INI
; 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

INI
; 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

BASH
# 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

BASH
#!/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:

TEXT
# 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

JAVASCRIPT
// 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

BASH
# 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

PHP
// ============================================
// 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,
        ];
    }
}
100%
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

P Quando devo usar cache?
R Apenas armazene em cache dados que são lidos com frequência mas escritos raramente. Relatórios, dashboards e dados estatísticos são adequados para cache (TTL de 15-60 minutos); dados em tempo real não devem ser cacheados. O cache deve incluir uma política de expiração — os dados devem ser limpos proativamente quando mudam.
P Como escolher entre eager loading e lazy loading?
R Use eager loading por padrão (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.
P Qual é melhor, chunk ou cursor?
R 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.
P O que devo fazer se a consulta ainda estiver lenta mesmo após adicionar um índice?
R Use 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.
P Devo habilitar OPcache em um ambiente de desenvolvimento?
R Não habilite em ambiente de desenvolvimento, ou defina como 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.
P Quantos usuários concorrentes devem ser usados para teste de carga?
R Comece testando com 2 a 5 vezes o pico esperado. Por exemplo, se você tem 1.000 usuários ativos diários e um pico de cerca de 100 usuários concorrentes, teste com 200-500 usuários concorrentes. Concentre-se nos valores de latência p95 e p99, não na média.

📖 Resumo


📝 Exercícios

  1. 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 usando DB::enableQueryLog()).

  2. 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.

  3. 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 use EXPLAIN para verificar a performance, com alvo p95 < 200 ms.

Web-Tutorial.com

Equipe Técnica Web-Tutorial

Uma plataforma de tutoriais mantida por diversos desenvolvedores. Cada tutorial é escrito e revisado por profissionais da área correspondente. Trabalhamos para manter nosso conteúdo preciso e confiável — se encontrar algum problema, avise-nos.

100%