软件搬运工
发布于 2026-05-27 / 5 阅读
0
0

Laravel 性能优化实战:从响应时间 2s 到 200ms,我做了这 8 件事

Laravel 性能优化实战:从响应时间 2s 到 200ms,我做了这 8 件事

摘要(50-80字):
生产环境 Laravel 项目响应 2 秒起步,用户流失率直线上升?本文以一个真实电商 API 为例,分享 8 个立竿见影的优化手段——从配置缓存、ORM 优化、队列解耦,到 Redis 加速、Opcache 调优,最终将 P99 响应时间从 2000ms 压到 200ms 以内,附完整代码与对比数据。


你有没有遇到过这种情况?

线上 Laravel 项目刚上线时,响应飞快。随着数据量增大、业务逻辑增多,慢慢地……

  • /api/products 接口需要 2-3 秒 才返回
  • 高峰期 CPU 飙升,服务器开始报警
  • 运维同学拿着账单找你谈话

你打开代码一看,逻辑也不复杂啊?问题出在哪里?

这篇文章,我把一个真实电商项目从 平均响应 2000ms 优化到 200ms 以内 的过程完整还原,8 个手段,每个都带数据。


环境说明

Laravel 10.x
PHP 8.2
MySQL 8.0 + Redis 7.0
服务器:4核8G(云服务器)
测试工具:wrk + Laravel Telescope + Clockwork

基准数据(优化前):

指标数值
平均响应时间2100ms
P99 响应时间4500ms
QPS(并发10)42
内存占用128MB/请求

手段一:开启配置缓存(5分钟,效果立竿见影)

Laravel 每次请求默认都会加载 config/ 目录下所有配置文件。生产环境中,这是纯粹的浪费。

# 生产部署时必须执行
php artisan config:cache   # 缓存配置文件
php artisan route:cache    # 缓存路由
php artisan view:cache     # 编译视图
php artisan event:cache    # 缓存事件监听器(Laravel 10+)

原理: config:cache 将所有配置合并成一个 bootstrap/cache/config.php 文件,一次加载,省去文件扫描 I/O。

⚠️ 本地开发时不要开,每次修改配置都需要重新 php artisan config:clear

效果:平均响应从 2100ms → 1650ms,降低 21%


手段二:消灭 N+1 查询(最高频的坑)

这是新手最容易踩的坑,也是效果最显著的优化。

反面教材:

// ❌ 这段代码会产生 N+1 问题
$orders = Order::all();

foreach ($orders as $order) {
    // 每次循环都会执行一条 SQL:SELECT * FROM users WHERE id = ?
    echo $order->user->name;
    
    // 再加上商品信息,又多一条
    echo $order->product->title;
}

// 100条订单 = 201条SQL,性能直接崩溃

正确做法:使用 Eager Loading(预加载)

// ✅ 使用 with() 预加载,只执行 3 条 SQL
$orders = Order::with(['user', 'product'])->get();

foreach ($orders as $order) {
    echo $order->user->name;    // 直接从内存读取,不再查库
    echo $order->product->title;
}

// SQL日志:
// SELECT * FROM orders
// SELECT * FROM users WHERE id IN (1,2,3,...,100)
// SELECT * FROM products WHERE id IN (5,8,12,...,100)

条件预加载(更精准):

// 只加载特定条件的关联数据,避免加载不必要数据
$orders = Order::with([
    'user:id,name,email',           // 只取需要的字段
    'product:id,title,price',
    'items' => function ($query) {
        $query->where('status', 'active')
              ->orderBy('created_at', 'desc');
    }
])->whereDate('created_at', today())->get();

用 Laravel Telescope 检测 N+1:

// AppServiceProvider.php
use Illuminate\Database\Eloquent\Model;

public function boot(): void
{
    // 开发环境:N+1 直接抛异常,强制你修复
    if (app()->isLocal()) {
        Model::preventLazyLoading();
    }
    
    // 生产环境:记录日志但不阻断
    if (app()->isProduction()) {
        Model::handleLazyLoadingViolationUsing(function ($model, $relation) {
            logger()->warning("N+1 detected: {$model}::{$relation}");
        });
    }
}

效果:主接口 SQL 从 187条 → 3条,响应 1650ms → 820ms


手段三:数据库索引优化

查了半天发现,有个订单列表接口的查询条件根本没索引……

// 先用 Telescope 找慢查询
// config/telescope.php 中开启 queries watcher

// 发现的慢查询示例:
// SELECT * FROM orders WHERE user_id = 1001 AND status = 'pending' ORDER BY created_at DESC
// 耗时:380ms(无索引),3ms(有索引)

迁移文件添加复合索引:

// database/migrations/2026_05_17_add_indexes_to_orders.php

public function up(): void
{
    Schema::table('orders', function (Blueprint $table) {
        // 查询条件字段建联合索引(顺序很重要:选择性高的放前面)
        $table->index(['user_id', 'status', 'created_at'], 'idx_user_status_created');
        
        // 商品表:按分类+价格范围查询
        $table->index(['category_id', 'price', 'status'], 'idx_category_price_status');
    });
}

public function down(): void
{
    Schema::table('orders', function (Blueprint $table) {
        $table->dropIndex('idx_user_status_created');
        $table->dropIndex('idx_category_price_status');
    });
}

EXPLAIN 验证索引有效:

// 在 tinker 中验证
$query = Order::where('user_id', 1001)
    ->where('status', 'pending')
    ->orderBy('created_at', 'desc');

// 打印 SQL 和执行计划
dd(DB::select('EXPLAIN ' . $query->toSql(), $query->getBindings()));
// 看 type 字段:ref 或 range 说明索引生效了,ALL 说明全表扫描

效果:慢查询平均从 380ms → 8ms


手段四:Redis 缓存热点数据

有些数据变化频率低,但查询频率极高(如商品分类、配置项、热门商品列表)。每次都查库,是对数据库的犯罪。

// app/Services/ProductService.php

class ProductService
{
    private const CACHE_TTL = 3600; // 1小时
    
    /**
     * 获取热门商品列表(带缓存)
     */
    public function getHotProducts(int $categoryId, int $limit = 20): Collection
    {
        $cacheKey = "hot_products:{$categoryId}:{$limit}";
        
        // remember:有缓存取缓存,没有则执行闭包并缓存结果
        return Cache::remember($cacheKey, self::CACHE_TTL, function () use ($categoryId, $limit) {
            return Product::with('category')
                ->where('category_id', $categoryId)
                ->where('status', 'active')
                ->orderByDesc('sales_count')
                ->limit($limit)
                ->get(['id', 'title', 'price', 'thumbnail', 'sales_count']);
        });
    }
    
    /**
     * 缓存标签(方便批量清除)
     */
    public function getProductDetail(int $productId): Product
    {
        return Cache::tags(['products', "product:{$productId}"])
            ->remember("product_detail:{$productId}", self::CACHE_TTL, function () use ($productId) {
                return Product::with(['images', 'specs', 'category'])
                    ->findOrFail($productId);
            });
    }
    
    /**
     * 商品更新时清除相关缓存
     */
    public function clearProductCache(int $productId, int $categoryId): void
    {
        // 清除该商品所有缓存
        Cache::tags(["product:{$productId}"])->flush();
        
        // 清除分类热门列表缓存
        Cache::forget("hot_products:{$categoryId}:20");
        Cache::forget("hot_products:{$categoryId}:10");
    }
}

Cache 驱动配置(.env):

CACHE_DRIVER=redis
CACHE_PREFIX=myapp_prod_

REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_DB=1              # 和 Session、Queue 分库
REDIS_CACHE_DB=2        # 缓存独立 DB

效果:热点接口响应从 820ms → 45ms(缓存命中时)


手段五:队列解耦耗时操作

用户下单后,需要发短信、推送通知、更新积分、记录日志……这些操作如果全部同步执行,接口响应能慢到让人崩溃。

// ❌ 同步执行,用户等待所有操作完成
public function placeOrder(Request $request): JsonResponse
{
    $order = $this->orderService->create($request->validated());
    
    // 这三步可能要 1-2 秒
    $this->smsService->sendOrderConfirmation($order);      // 200ms
    $this->pushService->notifyUser($order);                 // 300ms
    $this->pointsService->addPurchasePoints($order);        // 400ms
    $this->logService->recordPurchase($order);              // 100ms
    
    return response()->json(['order_id' => $order->id]);
    // 总耗时:1000ms+ 就是在这里
}
// ✅ 异步队列,用户立即得到响应
public function placeOrder(Request $request): JsonResponse
{
    $order = $this->orderService->create($request->validated());
    
    // 推入队列,立即返回
    OrderConfirmationJob::dispatch($order)->onQueue('notifications');
    UserPointsJob::dispatch($order)->onQueue('points')->delay(now()->addSeconds(5));
    
    return response()->json(['order_id' => $order->id]);
    // 总耗时:< 50ms
}

Job 实现示例:

// app/Jobs/OrderConfirmationJob.php

class OrderConfirmationJob implements ShouldQueue
{
    use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
    
    public int $tries = 3;           // 失败重试3次
    public int $timeout = 30;        // 30秒超时
    public int $backoff = 60;        // 重试间隔60秒
    
    public function __construct(private readonly Order $order) {}
    
    public function handle(SmsService $smsService): void
    {
        // 检查订单状态,防止重复发送
        if ($this->order->notification_sent) {
            return;
        }
        
        $smsService->sendOrderConfirmation($this->order);
        
        $this->order->update(['notification_sent' => true]);
    }
    
    /**
     * 失败处理
     */
    public function failed(\Throwable $exception): void
    {
        logger()->error('订单通知发送失败', [
            'order_id' => $this->order->id,
            'error' => $exception->getMessage(),
        ]);
        
        // 通知管理员
        Notification::route('mail', config('app.admin_email'))
            ->notify(new JobFailedNotification($this, $exception));
    }
}

Queue 配置(.env):

QUEUE_CONNECTION=redis
REDIS_QUEUE_DB=3       # 队列独立 DB

效果:下单接口从 1200ms → 48ms


手段六:开启 OPcache

PHP 默认每次请求都要重新解析 PHP 文件。OPcache 将编译结果缓存在内存中,跳过重复解析。

; /etc/php/8.2/fpm/conf.d/10-opcache.ini

[opcache]
opcache.enable=1
opcache.enable_cli=0

; 内存大小(根据项目规模调整,Laravel 项目建议 256MB)
opcache.memory_consumption=256

; 最大缓存文件数(统计一下你的项目文件数,设为2倍)
opcache.max_accelerated_files=20000

; 时间戳验证:生产环境设为0(不检查文件修改,部署后重启PHP-FPM)
opcache.validate_timestamps=0

; 字符串驻留(减少内存占用)
opcache.interned_strings_buffer=64

; JIT(PHP 8.x 专属)
opcache.jit_buffer_size=128M
opcache.jit=tracing

验证是否生效:

// 在 PHP 文件中输出 OPcache 状态
$status = opcache_get_status();
echo "缓存命中率:" . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%";
// 正常运行一段时间后,命中率应该在 99% 以上

效果:平均响应降低约 15%,CPU 使用率下降 20%


手段七:数据库连接池(PHP-FPM + PgBouncer/ProxySQL)

PHP-FPM 每个 Worker 都持有独立的数据库连接,进程多了连接数暴增。

; /etc/php-fpm.d/www.conf
; 根据服务器内存计算:(总内存 - 系统保留) / 单个Worker内存
pm = dynamic
pm.max_children = 50        ; 最大50个Worker
pm.start_servers = 10
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 500       ; 每个Worker处理500次请求后重启(防内存泄漏)

Laravel 数据库连接配置优化:

// config/database.php

'mysql' => [
    'driver' => 'mysql',
    'host' => env('DB_HOST', '127.0.0.1'),
    // ...
    'options' => [
        // 持久连接(谨慎使用,需配合连接池)
        PDO::ATTR_PERSISTENT => env('DB_PERSISTENT', false),
        
        // 超时设置
        PDO::ATTR_TIMEOUT => 5,
    ],
    
    // 读写分离(主写从读,QPS 直接翻倍)
    'read' => [
        'host' => [env('DB_READ_HOST', '127.0.0.1')],
    ],
    'write' => [
        'host' => [env('DB_HOST', '127.0.0.1')],
    ],
    'sticky' => true,   // 写后立即从主库读(避免主从延迟问题)
],

手段八:API 响应数据优化

返回的数据量直接影响序列化时间和网络传输时间。

// ❌ 返回所有字段,包括密码哈希、内部字段
$orders = Order::with('user')->paginate(20);
return response()->json($orders);

// ✅ 使用 API Resource,精确控制返回字段
// app/Http/Resources/OrderResource.php

class OrderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'status'     => $this->status,
            'total'      => number_format($this->total_amount, 2),
            'created_at' => $this->created_at->format('Y-m-d H:i'),
            
            // 条件加载:只有请求中需要时才包含
            'user'    => new UserBriefResource($this->whenLoaded('user')),
            'items'   => OrderItemResource::collection($this->whenLoaded('items')),
        ];
    }
}

开启 HTTP 压缩(Nginx):

# /etc/nginx/conf.d/compression.conf

gzip on;
gzip_comp_level 5;          # 压缩级别(1-9,5是性能与压缩率的平衡点)
gzip_min_length 256;        # 小于256字节不压缩
gzip_types
    application/json
    application/javascript
    text/css
    text/plain;

# 开启缓存头(静态资源)
location ~* \.(js|css|png|jpg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

优化结果汇总

优化手段优化前(ms)优化后(ms)效果
配置缓存21001650-21%
消灭N+11650820-50%
数据库索引820480-41%
Redis缓存48045(命中)-91%
队列异步1200(下单)48-96%
OPcache全局-15%全局生效
读写分离QPS×1QPS×2+并发翻倍
数据压缩响应体100%响应体30%-70%

最终对比数据:

指标优化前优化后提升
平均响应时间2100ms180ms-91%
P99 响应时间4500ms420ms-91%
QPS(并发10)42380+800%
内存占用128MB/请求35MB/请求-73%

质量检查清单 ✅

在发布这些优化时,请确认以下 6 项:

  • 1. 配置缓存 — 部署脚本中包含 php artisan config:cache && php artisan route:cache
  • 2. N+1 检测 — 开发环境开启 Model::preventLazyLoading(),上线前用 Telescope 确认无 N+1
  • 3. 缓存键命名 — 使用环境前缀(myapp_prod_),避免多环境缓存污染
  • 4. 队列监控 — Horizon 或 Supervisor 保证队列进程存活,失败 Job 有告警
  • 5. 索引验证 — 新增索引后用 EXPLAIN 验证 SQL 走了索引
  • 6. 压测验证 — 发布前用 wrk 或 ab 进行基准对比测试
# 简单压测命令
wrk -t4 -c50 -d30s --latency "https://yourapi.com/api/products"

# 输出示例
Latency Distribution
   50% 85.23ms
   75% 120.45ms
   90% 180.12ms
   99% 420.88ms   ← 这个是 P99,优化目标

彩蛋:性能监控代码片段

// app/Providers/AppServiceProvider.php

public function boot(): void
{
    if (app()->isProduction()) {
        // 慢查询记录(超过100ms记录日志)
        DB::listen(function ($query) {
            if ($query->time > 100) {
                logger()->warning('Slow Query Detected', [
                    'sql'      => $query->sql,
                    'bindings' => $query->bindings,
                    'time_ms'  => $query->time,
                    'url'      => request()->fullUrl(),
                ]);
            }
        });
    }
}

总结

Laravel 性能优化,本质上是一个"找瓶颈→解瓶颈"的循环。

优化顺序建议:

  1. 先量化(Telescope/Clockwork 找慢点)
  2. 先摘果子(N+1、配置缓存、索引,成本最低、收益最高)
  3. 再架构(队列、缓存分层、读写分离)
  4. 最后调参(OPcache、FPM 进程数、压缩)

别一上来就换框架、换语言。大多数情况下,问题都出在自己写的代码里。


评论