Laravel: Laravel性能优化实战
最后更新:2026-08-26
性能是 SaaS 的生命线——用户等 3 秒就离开,慢查询一天烧掉几百 USD 的数据库费。
1. 你将学到
- Eloquent 查询优化:eager loading / chunk / cursor / lazy
- 缓存策略:Route/Config/View 缓存 + Redis 数据缓存
- 数据库优化:索引策略 / EXPLAIN 分析 / 读写分离
- OPcache 与 PHP-FPM 调优
- 负载测试:k6 / Apache Bench 压测与瓶颈分析
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 缓存层级
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,
];
}
}
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 加了索引还是很慢怎么办?
A 用
EXPLAIN 检查是否真的用了索引(key 列);检查索引列顺序是否符合最左前缀原则;检查是否在大结果集上排序(filesort);考虑覆盖索引减少回表。Q OPcache 在开发环境要开吗?
A 开发环境不要开,或设置
opcache.validate_timestamps=1。否则改了代码不生效,调试时会困惑。生产环境务必开启且设 validate_timestamps=0。Q 负载测试要多少并发?
A 从预期峰值的 2-5 倍开始测试。例如日活 1 千用户,峰值约 100 并发,测试 200-500 并发。关注 p95/p99 延迟,不是平均值。
📖 小节
- N+1 查询是性能杀手,eager loading 一行代码解决
- 大数据集用 chunkById/cursor 避免内存溢出
- Redis 缓存查询结果,TTL + 主动失效双保险
- 复合索引遵循最左前缀原则,EXPLAIN 验证
- OPcache + PHP-FPM 调优让生产环境飞起来
- 负载测试验证优化效果,p95 < 500ms 为目标
📝 作业
-
基础题(⭐):找出 ShopMetrics 中任意一个 N+1 查询,用
with()修复,对比优化前后的查询数量(用DB::enableQueryLog()记录)。 -
进阶题(⭐⭐):为 ShopMetrics 仪表盘 API 添加 Redis 缓存(15 分钟 TTL),实现数据变更时自动清除缓存。编写 k6 脚本测试缓存命中率。
-
挑战题(⭐⭐⭐):完整优化 ShopMetrics 订单列表 API——添加复合索引(tenant_id + status + created_at)、改用 DB::table 聚合查询替代 Collection 操作、加缓存、写 EXPLAIN 验证,目标 p95 < 200ms。