Laravel: Laravel性能优化实战

最后更新:2026-08-26

性能是 SaaS 的生命线——用户等 3 秒就离开,慢查询一天烧掉几百 USD 的数据库费。

1. 你将学到


2. 一个快速增长 SaaS 的真实故事

(1) 痛点:ShopMetrics 响应时间从 200ms 飙到 5s

ShopMetrics 用户从 1 千增长到 50 千,订单数据从 10 万涨到 5 百万。Alice 发现仪表盘加载要 5 秒,Bob 看到数据库月费从 50 USD 涨到 500 USD——全是慢查询在烧钱。Charlie 分析发现:N+1 查询、没索引、没用缓存是三大元凶。

(2) 系统化优化的解法

从查询→缓存→索引→OPcache 四层优化,每层把响应时间砍掉一半,最终从 5s 降到 200ms。

TEXT 📖 仅展示
Before:  5000ms  (N+1 queries, no cache, no index)
Step 1:  2500ms  (eager loading, fix N+1)
Step 2:   500ms  (add indexes + query optimization)
Step 3:   200ms  (Redis cache + OPcache)
Step 4:   100ms  (CDN + read replica)

(3) 收益

Bob 优化后,数据库费用从 500 USD/月降到 150 USD/月,用户留存率从 70% 提升到 90%。


3. Eloquent 查询优化

(1) N+1 查询问题

PHP
// ❌ N+1: 1 query for tenants + N queries for shops
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
    echo $tenant->shops->count(); // 1 extra query per tenant!
}
// Total: 1 + N queries

// ✅ Eager loading: 2 queries total
$tenants = Tenant::with('shops')->get();
foreach ($tenants as $tenant) {
    echo $tenant->shops->count(); // No extra query
}
// Total: 2 queries

(2) 查询优化方法对比

方法 适用场景 内存占用 说明
all() 少量数据 一次加载全部到内存
chunk() 大数据遍历 分块处理,每块 N 条
chunkById() 大数据遍历(更稳) 按 ID 分块,不怕数据变动
cursor() 流式读取 极低 生成器逐行读取
lazy() 大数据但需集合操作 按需加载 chunk
each() 遍历+处理 chunk + callback

▶ 示例:ShopMetrics 订单分析 N+1 修复

PHP
// ❌ Before: Dashboard loads in 5 seconds
// Controller: 1 + 3*N queries per tenant
public function dashboard(Tenant $tenant)
{
    $orders = $tenant->orders()
        ->whereMonth('created_at', now()->month)
        ->get();                          // Query 1

    $total = $orders->sum('total');
    $byShop = $orders->groupBy('shop_id');  // Lazy loads shops: N queries
    $topProducts = $orders->flatMap->items; // Lazy loads items: N queries
    $customers = $orders->pluck('customer'); // Lazy loads customer: N queries
}

// ✅ After: Dashboard loads in 500ms
// Controller: 4 queries 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');
}

输出:

TEXT 📖 仅展示
// 执行成功

▶ 示例:大数据集用 chunk 与 cursor

PHP
// Process 5 million orders without memory overflow
// Option 1: chunk (processes 500 at a time)
Order::where('tenant_id', $tenant->id)
    ->chunk(500, function ($orders) {
        foreach ($orders as $order) {
            $this->calculateOrderMetrics($order);
        }
    });

// Option 2: chunkById (more stable with concurrent writes)
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);
    });

// Option 3: cursor (streaming, lowest memory)
$total = 0;
foreach (Order::where('tenant_id', $tenant->id)->cursor() as $order) {
    $total += $order->total;
}

// Option 4: lazy (collection methods available)
$highValue = Order::where('tenant_id', $tenant->id)
    ->lazy(500)
    ->filter(fn ($o) => $o->total > 1000)
    ->count();

输出:

TEXT 📖 仅展示
// 执行成功

4. 缓存策略

(1) Laravel 缓存层级

100%
flowchart TD
    A[Request] --> B{Route Cache?}
    B -->|Hit| C[Cached Routes]
    B -->|Miss| D[Parse Routes]
    D --> E{Config Cache?}
    C --> E
    E -->|Hit| F[Cached Config]
    E -->|Miss| G[Load .env + Config Files]
    G --> H{Data Cache?}
    F --> H
    H -->|Hit| I[Return Cached Data]
    H -->|Miss| J[Query DB + Cache Result]
    I --> K[Response]
    J --> K

(2) 缓存类型与适用场景

缓存类型 命令/方法 适用场景 持久性
Config Cache config:cache 生产环境配置 长期(直到 clear)
Route Cache route:cache 路由多、闭包少 长期(直到 clear)
View Cache view:cache Blade 视图编译 长期(直到 clear)
Data Cache Cache::put() 查询结果/报表数据 TTL 过期
Query Cache remember() 频繁查询 TTL 过期
OPcache php.ini PHP 字节码 重启前有效

▶ 示例:ShopMetrics Redis 缓存策略

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 is slow on large datasets; use tags or 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),
        ]);
    }
}

输出:

TEXT 📖 仅展示
// 执行成功

(3) 缓存标签与批量失效

PHP
// Cache with tags (Redis/database drivers only)
Cache::tags(['dashboard', "tenant:{$tenant->id}"])->put(
    'overview',
    $data,
    3600
);

// Invalidate all dashboard cache for a tenant
Cache::tags(["tenant:{$tenant->id}"])->flush();

// Or use cache key conventions with prefix scan
Cache::forget("dashboard:{$tenant->id}:overview");
Cache::forget("dashboard:{$tenant->id}:top-products:7d");
Cache::forget("dashboard:{$tenant->id}:revenue:daily");

5. 数据库优化

(1) 索引策略

PHP
// Migration: Add indexes for ShopMetrics queries
Schema::table('orders', function (Blueprint $table) {
    // Single column index
    $table->index('tenant_id', 'idx_orders_tenant');
    $table->index('created_at', 'idx_orders_date');
    $table->index('status', 'idx_orders_status');

    // Composite index (most useful for multi-condition queries)
    $table->index(['tenant_id', 'created_at'], 'idx_orders_tenant_date');
    $table->index(['tenant_id', 'status', 'created_at'], 'idx_orders_tenant_status_date');

    // Unique index
    $table->unique(['tenant_id', 'external_id'], 'uniq_orders_tenant_external');
});

(2) 索引类型对比

索引类型 语法 适用场景 说明
Index $table->index(col) WHERE/ORDER BY 普通 B-tree
Unique $table->unique(col) 唯一约束 B-tree + 去重
Composite $table->index([c1,c2]) 多条件查询 遵循最左前缀
FullText $table->fullText(col) 文本搜索 MySQL 8.0+
Spatial $table->spatialIndex(col) 地理查询 POINT/LINESTRING

(3) EXPLAIN 分析查询

SQL
-- Analyze slow query
EXPLAIN SELECT * FROM orders 
WHERE tenant_id = 1 AND status = 'completed' 
ORDER BY created_at DESC LIMIT 20;

-- Look for:
-- type: ALL (full scan) → needs index
-- key: NULL (no index used) → needs index
-- rows: large number → needs optimization
-- Extra: Using filesort → needs composite index

▶ 示例:ShopMetrics 慢查询诊断与优化

SQL
-- ❌ Before: Full table scan on 5M rows (3000ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ALL, rows: 5,000,000, key: NULL

-- Add index
ALTER TABLE orders ADD INDEX idx_orders_tenant (tenant_id);

-- ✅ After: Index scan (5ms)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ref, rows: 50,000, key: idx_orders_tenant

-- ❌ Still slow: Sorting without index (500ms)
EXPLAIN SELECT * FROM orders 
WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Extra: Using filesort

-- ✅ Composite index (3ms)
ALTER TABLE orders ADD INDEX idx_orders_tenant_date (tenant_id, created_at DESC);

输出:

TEXT 📖 仅展示
CREATE TABLE

(4) 读写分离

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,  // Read from write host after write in same request
    'driver' => 'mysql',
    'database' => env('DB_DATABASE', 'shopmetrics'),
    // ...
],

6. OPcache 与 PHP-FPM 调优

(1) OPcache 配置

INI
; php.ini or php.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256        ; 256MB for bytecode cache
opcache.interned_strings_buffer=32    ; 32MB for strings
opcache.max_accelerated_files=40000   ; Laravel has ~12000 files
opcache.validate_timestamps=0         ; OFF in production (manual reset)
opcache.save_comments=1               ; Required by Laravel annotations
opcache.jit=1255                      ; PHP 8.2 JIT: tracing mode
opcache.jit_buffer_size=128M          ; 128MB JIT buffer

(2) PHP-FPM 调优

INI
; php-fpm.d/www.conf
pm = dynamic
pm.max_children = 50          ; = (RAM - OS) / avg_per_process
pm.start_servers = 10         ; = min_spare + (max - min) / 2
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000        ; Prevent memory leaks

(3) OPcache 重置

BASH
# After deployment, reset OPcache
php artisan optimize:clear
# Or via route
Route::get('/opcache-clear', function () {
    if (app()->environment('production')) {
        opcache_reset();
    }
    return 'OPcache cleared';
})->middleware('auth:api');

▶ 示例:ShopMetrics 生产缓存一键脚本

BASH
#!/bin/bash
# deploy-optimize.sh — Run after each deployment

set -e

echo "→ Clearing all caches..."
php artisan optimize:clear

echo "→ Caching config..."
php artisan config:cache

echo "→ Caching routes..."
php artisan route:cache

echo "→ Caching views..."
php artisan view:cache

echo "→ Running migrations..."
php artisan migrate --force

echo "→ Restarting queue workers..."
php artisan queue:restart

echo "→ Clearing OPcache..."
php artisan optimize

echo "✓ Optimization complete!"

输出:

TEXT 📖 仅展示
# 命令执行成功

7. 负载测试

(1) 工具对比

工具 语言 优势 适用场景
Apache Bench (ab) C 简单、零依赖 快速压测单接口
k6 JS 脚本灵活、CI 友好 复杂场景 + CI
wrk C 高并发、低资源 极限并发测试
JMeter Java GUI、插件丰富 企业级复杂测试

(2) 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 },   // Ramp up to 20 users
    { duration: '2m', target: 20 },    // Stay at 20
    { duration: '30s', target: 50 },   // Ramp up to 50
    { duration: '2m', target: 50 },    // Stay at 50
    { duration: '30s', target: 0 },    // Ramp down
  ],
  thresholds: {
    http_req_duration: ['p(95)<500'],  // 95% requests < 500ms
    http_req_failed: ['rate<0.01'],    // Error rate < 1%
  },
};

const BASE_URL = 'https://api.shopmetrics.io';
const TOKEN = __ENV.API_TOKEN;

export default function () {
  const params = {
    headers: { Authorization: `Bearer ${TOKEN}` },
  };

  // Test dashboard API
  const res = http.get(`${BASE_URL}/api/v1/dashboard`, params);

  check(res, {
    'status is 200': (r) => r.status === 200,
    'response time < 500ms': (r) => r.timings.duration < 500,
    'has overview data': (r) => JSON.parse(r.body).overview !== null,
  });

  sleep(1);
}

(3) 快速压测命令

BASH
# Apache Bench: 100 concurrent users, 1000 total requests
ab -n 1000 -c 100 -H "Authorization: Bearer $TOKEN" \
   https://api.shopmetrics.io/api/v1/dashboard

# wrk: 50 connections for 30 seconds
wrk -t4 -c50 -d30s -H "Authorization: Bearer $TOKEN" \
    https://api.shopmetrics.io/api/v1/dashboard

# k6: Run load test with threshold checks
k6 run --env API_TOKEN=$TOKEN k6-scripts/dashboard-load.js

8. 综合示例:ShopMetrics 优化前后对比

PHP
// ============================================
// Comprehensive: Dashboard API optimization
// Before vs After with all techniques applied
// ============================================

// ❌ BEFORE: Slow dashboard (5 seconds, 200+ queries)
class DashboardController extends Controller
{
    public function index(Request $request)
    {
        $tenant = $request->user()->tenant;

        $orders = $tenant->orders()
            ->whereMonth('created_at', now()->month)
            ->get();  // Loads ALL columns, no eager loading

        return response()->json([
            'total_revenue' => $orders->sum('total'),
            'order_count' => $orders->count(),
            'top_shops' => $orders->groupBy('shop_id')  // N+1 on 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 on customer
                    'total' => $o->total,
                ]),
        ]);
    }
}

// ✅ AFTER: Fast dashboard (100ms, 3 queries, cached)
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
    {
        // Single optimized query with aggregations
        $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 shops: 1 query
        $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();

        // Recent orders with eager loading: 1 query
        $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 "Before: 5000ms"
        A1[200+ SQL Queries] --> A2[No Cache]
        A2 --> A3[No Index]
        A3 --> A4[N+1 Loading]
    end
    subgraph "After: 100ms"
        B1[3 SQL Queries] --> B2[Redis Cache 15min]
        B2 --> B3[Composite Index]
        B3 --> B4[Eager Loading]
    end
    A4 -->|Optimize| B1

❓ 常见问题

Q 什么时候该用缓存?
A 读多写少的数据才缓存。报表/仪表盘/统计数据适合缓存(15-60 分钟 TTL);实时数据不要缓存。缓存必须有失效策略——数据变更时主动清除。
Q eager loading 和 lazy loading 怎么选?
A 默认用 eager loading(with()),只有确定不访问关联时才 lazy。用 Laravel Debugbar 观察查询数,N+1 立刻可见。
Q chunk 和 cursor 哪个好?
A cursor() 内存最低但只能逐行处理、不能跳过、不能并发写入安全;chunkById() 更稳定,适合生产环境批量处理。大多数场景用 chunkById()
Q 加了索引还是很慢怎么办?
AEXPLAIN 检查是否真的用了索引(key 列);检查索引列顺序是否符合最左前缀原则;检查是否在大结果集上排序(filesort);考虑覆盖索引减少回表。
Q OPcache 在开发环境要开吗?
A 开发环境不要开,或设置 opcache.validate_timestamps=1。否则改了代码不生效,调试时会困惑。生产环境务必开启且设 validate_timestamps=0
Q 负载测试要多少并发?
A 从预期峰值的 2-5 倍开始测试。例如日活 1 千用户,峰值约 100 并发,测试 200-500 并发。关注 p95/p99 延迟,不是平均值。

📖 小节


📝 作业

  1. 基础题(⭐):找出 ShopMetrics 中任意一个 N+1 查询,用 with() 修复,对比优化前后的查询数量(用 DB::enableQueryLog() 记录)。

  2. 进阶题(⭐⭐):为 ShopMetrics 仪表盘 API 添加 Redis 缓存(15 分钟 TTL),实现数据变更时自动清除缓存。编写 k6 脚本测试缓存命中率。

  3. 挑战题(⭐⭐⭐):完整优化 ShopMetrics 订单列表 API——添加复合索引(tenant_id + status + created_at)、改用 DB::table 聚合查询替代 Collection 操作、加缓存、写 EXPLAIN 验证,目标 p95 < 200ms。

Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏