CatchAdmin PHP 后台管理框架 Logo CatchAdmin

不换框架,PHP 应用响应时间如何从 840ms 降至 250ms

设想接手这样一个 PHP 应用:平均响应时间 840 毫秒。没有崩溃,没有报错,只是慢——那种用户能察觉到的慢,那种会出现在 Core Web Vitals 报告里的慢,那种慢——监控面板已默默记录了好几个月,而团队一直忙于功能开发。

技术栈是 Laravel。数据库是 MySQL。服务器是一台配置尚可的云虚拟机。代码库已存在三年,先后有十七位开发者参与过。没有人做过什么大改动——迟缓是逐步累积起来的,就像技术债一贯的累积方式。

接手这样的项目时,直觉反应往往是归咎于框架。Laravel 太重了。也许是时候考虑更轻量的方案了。也许应该用原生 PHP 重写关键路径。也许该迁移到微服务架构。

这些都是错误的直觉。它们代价高昂、风险巨大,而且几乎从来不是必需的——因为瓶颈几乎从来不在框架本身。

以下便是实际的瓶颈所在、如何发现它们,以及究竟改了什么,才把平均响应时间从 840ms 降到 250ms。没有换框架。没有改基础设施。没有重写。

速览

瓶颈分别为:冗余的数据库查询(一直没人修的 N+1 问题)、两张高流量表上缺失的索引、每个请求都加载但鲜少使用的 session 数据、在启动阶段执行高开销操作的服务提供者(框架默认自动加载),以及未压缩的静态资源额外增加了 400ms 传输时间。

所有改动总计耗时:在两个月的时间里集中投入了三周。

响应时间总体改善:平均从 840ms 降至 250ms。降低了 70%。

一行业务逻辑都没有改。框架不是问题所在。

定位每一个瓶颈的工具:Laravel Debugbar、Telescope、EXPLAIN 和 Blackfire——这些工具在花一分钱搞基础设施之前就已经可用。

本文将涵盖的内容

  • 如何系统地对 PHP 应用进行性能分析,而非凭感觉猜测瓶颈
  • 真实代码库中的 N+1 查询模式及其消除方法
  • PHP 操作码缓存(OPcache)——它是什么,以及为什么总是第一个要检查的项
  • HTTP 响应缓存及其如何为符合条件的请求彻底绕过服务器处理
  • Session 与自动加载服务优化——没人测量的启动成本
  • 静态资源交付——那 400ms 跟 PHP 完全无关
  • 如何正确地测量,从而确认某项改动确实有效

动手之前:先测量

设想花两周时间去优化一个错误的目标。给一条只需 3ms 的查询加上 Redis 缓存。重写一个每天只调用两次的服务类。把切实的工程时间花在对用户感知延迟毫无贡献的事情上。

这就是不测量就优化的后果。每一个性能项目的起点都遵循同一个原则:在动手之前先做性能分析。

本次调查所使用的工具栈:

Laravel Debugbar——在开发环境安装,按请求展示查询数量、查询耗时、内存使用量以及加载了哪些服务提供者。第一天就装上的第一个工具。

Laravel Telescope——在一个可搜索的 UI 中记录每一次请求、每一条查询、每一个任务和异常。用于在数千个真实请求中发现模式,而不仅仅是被主动测试的那些。

MySQL 慢查询日志,开启 log_queries_not_using_indexes = ON——在生产环境运行(在只读副本上,避免给主库增加开销)。捕获每一次全表扫描的查询。

Blackfire——那个能按函数级别展示执行时间的分析器,不仅仅停留在查询级别。在 Debugbar 定位到大致区域之后,Blackfire 能精确定位到具体行。不是免费的,但对于严肃的性能工作来说物有所值。

基线数据:在做任何改动之前,使用 ab -n 100 -c 10 对每个主要端点进行了基准测试,测试环境是与生产环境对等的预发布环境,并以生产规模的数据做种子数据。每次优化都与此基线对比测量。如果数字没有变化,改动就回滚。

原则:改前测量,改后测量,相信数字而非直觉。

问题一:N+1 查询(节省约 180ms)

Debugbar 第一次运行就揭示了每个上线超过一年的 PHP 应用中最常见的性能问题:N+1 查询。

受影响最严重的端点是订单面板——一个展示最近五十条订单及其客户名称、商品数量和状态的页面。Debugbar 显示该页面执行了 54 条查询,而这个页面本来只需要一条。

代码:

php
// Controller — 看上去人畜无害
$orders = Order::latest()->take(50)->get();
return view('dashboard.orders', compact('orders'));
blade
{{-- 模板 — N+1 藏身之处 --}}
@foreach ($orders as $order)
    <tr>
        <td>{{ $order->customer->name }}</td>      {{-- 每条订单一条查询 --}}
        <td>{{ $order->items->count() }}</td>       {{-- 每条订单一条查询 --}}
        <td>{{ $order->latestStatus->label }}</td>  {{-- 每条订单一条查询 --}}
    </tr>
@endforeach

五十条订单。每条订单触发三次关联访问。一次初始查询。总计 151 条查询。

修复方案——预加载视图中访问的每一个关联:

php
$orders = Order::with(['customer', 'items', 'latestStatus'])
    ->latest()
    ->take(50)
    ->get();

查询数量:4(一次查订单,一次查客户,一次查商品,一次查状态)。从 151 降到 4。

该端点的页面加载时间:720ms → 190ms。

但订单面板并非唯一的问题所在。Debugbar 在每个主要端点上运行。Telescope 被用来搜索查询数量超过 20 的请求。到审查结束时,在整个代码库中共发现并修复了十四处独立的 N+1 模式。所有端点累计节省约 180ms。

根本原因并不是粗心大意,而是 Eloquent 的懒加载——这是默认行为,在访问时才加载关联,悄无声息地发生在模板里。每一次关联访问在 Blade 中看起来就像一次属性读取,没有任何迹象表明这是一次数据库查询。

Laravel 8+ 引入了 Model::preventLazyLoading()——一个在未预加载而访问关联时抛出异常的设置:

php
// 在 AppServiceProvider::boot() 中
Model::preventLazyLoading(!app()->isProduction());

这能在开发阶段就捕获 N+1 问题,在它们进入生产环境之前。该设置已被立即启用,并且自此之后防止了每一次 N+1。

问题二:两个缺失的索引(节省约 210ms)

慢查询日志(在只读副本上启用,long_query_time = 0.5)显示有两条查询占据了日志的绝大部分:

sql
-- 查询 1:24 小时内出现 847 次,平均耗时 380ms
SELECT * FROM products WHERE category_id = ? AND is_active = 1 ORDER BY sort_order ASC;

-- 查询 2:24 小时内出现 1203 次,平均耗时 290ms
SELECT * FROM orders WHERE customer_id = ? AND status IN ('pending', 'processing');

对两者执行 EXPLAIN

sql
EXPLAIN SELECT * FROM products
WHERE category_id = ? AND is_active = 1
ORDER BY sort_order ASC;

-- 结果:type = ALL,key = NULL,rows = 284,000

一张 284,000 行的表,每天被全表扫描 847 次。category_id 列没有索引。is_active 没有索引。sort_order 没有索引。

sql
-- 通过 migration 添加:
ALTER TABLE products
    ADD INDEX idx_category_active_sort (category_id, is_active, sort_order);

ALTER TABLE orders
    ADD INDEX idx_customer_status (customer_id, status);

添加两个索引之后:

sql
EXPLAIN SELECT * FROM products
WHERE category_id = ? AND is_active = 1
ORDER BY sort_order ASC;

-- 结果:type = ref,key = idx_category_active_sort,rows = 43
-- Extra: Using index(覆盖索引——完全不需要读取表行)

从检查 284,000 行到仅检查 43 行。ORDER BY 被索引覆盖——无需 filesort。

查询 1:380ms → 2ms。查询 2:290ms → 4ms。

打到这些查询的端点平均响应时间节省约 210ms。

这两个列从应用上线之初就存在于 schema 中,却一直没有索引。没有人注意到,因为在数据量小的时候它们足够快。当 284,000 条商品和 120 万条订单的数据规模形成时,"足够快"已经不复存在。

问题三:OPcache 未正确配置(节省约 95ms)

这是一项容易被忽视的配置。

PHP 在执行每个 .php 文件之前都会将其编译为字节码。没有 OPcache 的情况下,每次请求都要进行这种编译——包括自动加载器引入的每一个文件、Laravel 启动流程加载的每一个类、执行时编译的每一个 Blade 模板。

OPcache 将编译后的字节码存储在内存中。后续请求完全跳过编译步骤——直接加载预编译的字节码。对于一个每次请求要加载数百个类文件的 Laravel 应用来说,这带来的影响是实质性的。

OPcache 已经安装(PHP 7.0+ 自带),但运行在默认设置下,严重限制了它的有效性:

ini
; 默认设置——对 Laravel 来说远远不够
opcache.memory_consumption = 128      ; 对完整的 Laravel 应用来说太小
opcache.max_accelerated_files = 2000  ; Laravel 需要 10,000+
opcache.validate_timestamps = 1       ; 每次请求都检查文件是否有变更

问题所在:opcache.max_accelerated_files = 2000 意味着当达到上限时,OPcache 会静默地驱逐文件,迫使被驱逐的文件重新编译。一个包含 vendor 目录的 Laravel 应用轻松超过 10,000 个文件。

修复方案:

ini
; php.ini — 生产环境 OPcache 配置
opcache.enable                 = 1
opcache.memory_consumption     = 256      ; 足够容纳所有文件
opcache.max_accelerated_files  = 20000    ; 远超 Laravel + vendor 所需
opcache.validate_timestamps    = 0        ; 生产环境禁用——由部署脚本负责清缓存
opcache.revalidate_freq        = 0        ; 当 validate_timestamps = 0 时无意义
opcache.interned_strings_buffer = 16
opcache.fast_shutdown          = 1

在生产环境中设置 opcache.validate_timestamps = 0 意味着 PHP 永远不会检查文件是否发生变更——始终使用缓存的字节码。只要部署脚本在每次部署时清除 OPcache,这就是安全的:

bash
# 在部署脚本中——文件更新后执行
php artisan opcache:clear
# 或直接执行:
php -r "opcache_reset();"

配置正确的 OPcache 后,平均响应时间下降约 95ms。这是最简单的改动——一个配置文件的修改——却带来了第三大的收益。

快速验证 OPcache 是否按预期工作的方法:

php
// 临时诊断端点(确认后移除)
$status = opcache_get_status();
echo "Cached scripts: " . $status['opcache_statistics']['num_cached_scripts'];
echo "Memory used: "    . round($status['memory_usage']['used_memory'] / 1024 / 1024, 1) . "MB";
echo "Hit rate: "       . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%";

命中率低于 95% 表示 OPcache 正在驱逐文件——需要增加 opcache.memory_consumptionopcache.max_accelerated_files

问题四:每次请求都加载 Session(节省约 45ms)

Laravel 的 session 中间件在每一次请求时都加载 session——包括 API 端点、webhook 处理器和健康检查 URL,而这些场景根本没有 session 的概念。

Session 存储在数据库中(保存在 sessions 表中)。每个通过 session 中间件的请求都会执行:

sql
SELECT * FROM sessions WHERE id = ?  -- 读取 session
UPDATE sessions SET ... WHERE id = ? -- 写入 session(即使是只读请求)

每个请求两条查询,针对每一个端点,包括那些从不调用 session() 的端点。在流量最高的 API 端点上,这完全是额外开销。

修复方案分为两部分。

第一部分:将高流量 API 路由移出 session 中间件组。

php
// routes/api.php — 不使用 session 的 API 路由
Route::withoutMiddleware([StartSession::class, ShareErrorsFromSession::class])
    ->group(function () {
        Route::get('/products',        [ProductController::class, 'index']);
        Route::get('/products/{id}',   [ProductController::class, 'show']);
        Route::post('/webhooks/stripe',[StripeWebhookController::class, 'handle']);
    });

第二部分:将剩余的 session 存储从数据库迁移到 Redis。

数据库 session 需要 SQL 查询。Redis session 只是一次内存键查找——速度快几个数量级:

php
// config/session.php
'driver' => env('SESSION_DRIVER', 'redis'),

对于确实需要 session 数据的端点,Redis 查找耗时约 1ms,而替代的数据库查询约 12ms。

所有端点合计节省约 45ms 平均响应时间。

问题五:服务提供者自动加载(节省约 55ms)

Laravel 在每次请求时启动每一个已注册的服务提供者。config/app.phpproviders 数组中有 34 个条目——其中一些来自仅在特定场景下使用的包。

Blackfire 揭示了两项消耗了不成比例启动时间的元凶:

一个 PDF 生成包,注册了一个在启动时连接外部字体许可 API 的服务提供者——即使在从不生成 PDF 的请求中也是如此。启动时间成本:每次请求约 30ms,源自服务提供者构造函数中的一次外部 HTTP 调用。

一个遗留的报表包,每次启动时加载并编译一个 400KB 的配置文件。

修复方案:

使用延迟提供者对 PDF 服务进行懒加载:

php
// PdfServiceProvider.php
class PdfServiceProvider extends ServiceProvider
{
    // 延迟提供者仅在实际请求该服务时才启动
    protected $defer = true;

    public function provides(): array
    {
        return [PdfGenerator::class];
    }

    public function register(): void
    {
        $this->app->singleton(PdfGenerator::class, function () {
            // 高开销的初始化仅在首次解析 PdfGenerator 时运行
            return new PdfGenerator(config('pdf'));
        });
    }
}

缓存报表包的配置:

php
// ReportingServiceProvider.php
public function boot(): void
{
    $config = Cache::remember('reporting_config', 3600, function () {
        return $this->loadExpensiveConfiguration();
    });

    $this->app->singleton(ReportingEngine::class, fn () => new ReportingEngine($config));
}

合计节省:在原本需要急切加载这些提供者的请求中,每次约 55ms。

更广泛的教训:config/app.php 中的每一个服务提供者都会在每次请求时运行。运行 php artisan route:list 并按使用情况过滤,对每个提供者追问:应用中的每个端点是否真的需要该服务在启动时可用?如果答案是否定的,就延迟它。

问题六:HTTP 响应缓存(符合条件的请求节省约 120ms)

并非每个请求都需要经过 PHP。

商品列表页——流量最高的端点——对所有未登录用户返回完全相同的内容。相同的 HTML,由相同的数据库查询构建,使用相同的静态资源,每分钟数百次。

HTTP 响应缓存配合反向代理(本例中使用 Nginx)可以从内存中直接提供这些响应,完全绕过 PHP、MySQL 和整个应用栈:

nginx
# nginx.conf
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m max_size=1g
                 inactive=60m use_temp_path=off;

server {
    location / {
        proxy_cache            app_cache;
        proxy_cache_valid      200 60s;  # 缓存成功响应 60 秒
        proxy_cache_key        "$scheme$request_method$host$request_uri";
        proxy_cache_bypass     $http_authorization; # 绝不缓存已认证请求
        proxy_cache_bypass     $cookie_session;     # 绝不缓存已登录用户请求
        add_header X-Cache-Status $upstream_cache_status;
        proxy_pass             http://php_upstream;
    }
}

在 PHP 侧,响应需要发送适当的缓存头:

php
// 在可缓存端点的控制器中
public function index(): Response
{
    $products = Cache::remember('products:listing', 60, fn () =>
        Product::active()->with('category')->get()
    );

    return response()
        ->view('products.index', compact('products'))
        ->header('Cache-Control', 'public, max-age=60')
        ->header('Vary', 'Accept-Encoding');
}

Vary: Accept-Encoding 头确保 Nginx 为 gzip 和非 gzip 客户端分别缓存不同版本——这对压缩响应的处理很重要。

以商品列表端点为例:

  • 改前:每次请求 → PHP → MySQL → 220ms
  • 改后(缓存未命中):首次请求 → PHP → MySQL → 220ms,响应存入 Nginx 缓存
  • 改后(缓存命中):后续请求 → Nginx 从内存直接提供 → 约 8ms

商品页 24 小时后的缓存命中率:94%。该端点的平均响应时间从 220ms 降至约 21ms(计入 6% 的缓存未命中)。

在所有符合条件的端点中,这一项在最关键的指标——流量最高的端点——上贡献了约 120ms 的平均节省。

问题七:未压缩的静态资源(首次加载节省约 400ms)

这一项跟 PHP 完全无关。

应用通过 HTTP/1.1 提供未经压缩的 JavaScript 和 CSS 文件。主 bundle 的未压缩总大小为 2.4MB。在典型的宽带连接下,这意味着浏览器在开始渲染之前要花费 400–600ms 的传输时间。

修复方案包含三部分:

Gzip 压缩,在 Web 服务器层面:

nginx
# nginx.conf
gzip              on;
gzip_types        text/plain text/css application/javascript application/json;
gzip_min_length   1000;
gzip_comp_level   6;
gzip_vary         on;

JavaScript 的压缩率约为 70%。2.4MB 的 bundle 变为 720KB。

使用 Vite 预压缩静态资源(适用于 Laravel Mix / Vite 用户):

js
// vite.config.js
import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import viteCompression from 'vite-plugin-compression';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/css/app.css', 'resources/js/app.js'] }),
        viteCompression({ algorithm: 'gzip' }),    // 生成 .gz 文件
        viteCompression({ algorithm: 'brotli' }),  // 生成 .br 文件
    ],
});

为静态资源设置 Cache-Control 头

nginx
location ~* \.(js|css|png|jpg|svg|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, immutable";
}

使用内容哈希文件名的资源(Vite 的默认行为)可以缓存一年——同一 URL 下的内容永远不会改变。

主 bundle 首次加载的传输时间:480ms → 95ms。

这一项节省不会体现在服务器端响应时间指标中——它会体现在 Core Web Vitals、LCP 和 Time to Interactive 上。所有这些指标都显著改善。真实用户体验显著改善。而服务器本身没有做任何改动。

最终数据

将每一项优化与基线进行对比追踪:

优化项平均节省测量维度
N+1 查询消除(14 处)~180ms服务器响应时间
缺失索引添加(2 张表)~210ms服务器响应时间
OPcache 配置修复~95ms服务器响应时间
Session 中间件 + Redis 迁移~45ms服务器响应时间
服务提供者延迟加载~55ms服务器响应时间
HTTP 响应缓存(符合条件的 URL)~120ms端到端响应时间
资源压缩 + 缓存~400ms首次加载 / Core Web Vitals

服务器端平均响应时间:840ms → 235ms(降低 72%)。包含资源传输的首次加载体验:显著改善——具体数值因连接速度而异。

并非每一项节省都适用于每一次请求。Session 节省仅适用于 API 端点。缓存节省仅适用于符合条件的可缓存端点。N+1 节省适用范围较广。索引节省作用于流量最高的端点。

复合平均值——跨所有端点,按流量加权——落在 250ms。与「降低 70%」的标题足够接近,在真正有意义的精度上是准确的。

框架从来不是问题

关于框架性能指责,有一点需要明确:它几乎总是错误的,而且几乎总是对慢应用所能做出的最昂贵回应。

在一个配置得当的服务器上,与完成同样工作的原生 PHP 脚本相比,Laravel 增加的开销大约是 10–15ms。这个开销是真实存在的。但它在本应用 840ms 的问题中占比不到 5%。

其余 95%——N+1 查询、缺失的索引、OPcache 配置错误、session 开销、服务提供者浪费、未压缩的静态资源——这些都不是 Laravel 的问题。它们是应用层面的问题。它们同样会存在于 Symfony、CodeIgniter 或原生 PHP 中。框架没有导致它们。框架也无法阻止它们,因为它们从根本上来说是测量和工程纪律的问题。

设想另一条路:耗时六个月的框架重写。新的代码库。重写的测试。迁移风险。团队上下文切换。六个月的功能开发被搁置。而到头来,同样的 N+1 查询在新框架中依然存在,因为没有人去寻找它们。同样的索引依然缺失,因为没有人运行过 EXPLAIN。同样的 OPcache 依然配置错误,因为之前就错了而没有人检查。

换框架并不能解决应用层的性能问题。它只是把这些问题搬到了一个新的代码库里。

需要避免的陷阱

不测量就优化。 本项目中的每一项变更都经过了改前和改后的测量。有两项改动因为数字不支持理论而被回滚了。要相信分析器,而非直觉。

在修复查询之前缓存一切。 在慢查询上加一层缓存掩盖了问题而没有解决它。缓存的响应很快——直到缓存过期。先修复查询,然后在适当的情况下再添加缓存作为额外的优化层。

设置 opcache.validate_timestamps = 0 但未在部署时清除缓存。 如果部署流程不清除 OPcache,用户在部署后将看到旧版本代码的缓存字节码。每个部署脚本都必须包含 opcache_reset()

缓存已认证用户的响应。 任何包含用户特定数据的响应——账户详情、个性化内容、CSRF 令牌——绝不能被反向代理缓存。proxy_cache_bypass $cookie_session 指令不是可选项。搞错这一点意味着将用户 A 的响应提供给用户 B。

延迟存在相互依赖的服务提供者。 只有在启动过程中没有其他东西依赖延迟提供者时,延迟它才是安全的。在将某个提供者标记为延迟之前,检查其他提供者中每一个 app()->make()app()->bind() 调用。

在开发环境测量。 开发环境有不同的 OPcache 设置、不同的数据量、不同的并发水平,并且通常启用了 Xdebug——所有这些都会显著影响耗时。只在与生产环境对等的预发布环境中进行测量。

快速问答

问:如何知道 OPcache 是否真的在工作?opcache_get_status()['opcache_statistics']['opcache_hit_rate'] 检查命中率。低于 95% 表明发生了驱逐——增加内存和文件限制。命中率为 0% 通常意味着 OPcache 已禁用,或者 CLI 与 FPM 之间的配置不匹配(它们有各自独立的 OPcache 池)。

问:应该在应用层还是 HTTP 层做缓存? 两者都要做,但用途不同。应用层缓存(Redis、Memcached)适用于高开销计算、数据库结果和用户特定数据。HTTP 层缓存(Nginx、CDN)适用于完全公开、响应内容相同的场景。HTTP 缓存更快,因为它完全绕过了 PHP——只要内容公开且能容忍短暂的陈旧窗口,就使用它。

问:在 Laravel 代码库中找出 N+1 查询的最快方法是什么? 在非生产环境中启用 Model::preventLazyLoading()。每一次懒加载的关联访问都会抛出异常——修复它们变得不可避免。对于生产环境,Laravel Telescope 的查询面板可以按高查询数过滤请求,从而在真实流量中发现现有的 N+1 模式。

问:Blackfire 值得花钱吗? 对于严肃的性能工作来说,值得——它按函数和方法级别展示执行时间,而不仅仅是查询级别。Blackfire 的免费层级覆盖了大多数优化工作流。付费层级增加了持续性能分析和 CI 中的性能测试。对于本文所述类型的调查,免费层级就足够了。

问:如何处理 HTTP 缓存响应的缓存失效? 使用较短的 TTL(对于大多数目录页来说 60 秒已经相当激进),并在可能时使用基于内容哈希的缓存键。对于不经常变化但变更后必须立即保持一致的数据,使用缓存标签(Nginx 商业模块或 Varnish 支持)在内容变更时立即使特定缓存条目失效,而不是等待 TTL 过期。

七日行动计划

  • 第 1 天:安装 Laravel Debugbar。在五个流量最高的端点上运行。记录每个端点的查询数量和总查询时间。
  • 第 2 天:修复第一天发现的每一个 N+1 查询。在非生产环境中启用 Model::preventLazyLoading()
  • 第 3 天:在副本或预发布服务器上启用 MySQL 慢查询日志,设置 log_queries_not_using_indexes = ON。对前五条最慢的查询运行 EXPLAIN。
  • 第 4 天:添加缺失的索引。重新运行 EXPLAIN 确认索引被使用。测量改前和改后的响应时间。
  • 第 5 天:检查 OPcache 配置。验证命中率高于 95%。根据需要调整内存和文件限制。确保部署脚本清除 OPcache。
  • 第 6 天:审查 session 中间件。从不使用 session 的 API 路由和 webhook 处理器中移除它。如果仍在使用数据库存储,迁移到 Redis session。
  • 第 7 天:在 Web 服务器上启用 Gzip 压缩。测量改前和改后的首字节时间和首次内容绘制。

关键追踪指标:P95 响应时间,而非平均值。平均值会掩盖尾部延迟——最慢的 5% 请求通常比中位数慢十倍,代表了最差的用户体验。修复驱动 P95 的因素,平均值自然会随之改善。

需要避免的常见错误:只在开发环境或启用了 Xdebug 的情况下测量响应时间。Xdebug 可能使 PHP 执行开销增加 2–10 倍——在启用它的条件下获得的任何测量结果对性能优化都没有意义。应在与生产环境对等且禁用 Xdebug 的环境中进行分析。

写在最后

设想六个月后打开监控面板,看到一条平稳的绿色 P95 曲线,稳定在 250ms。不是因为重写了任何东西,不是因为换了框架,不是因为迁移到了新的数据库引擎。

而是因为查看了时间真正花在了哪里,修复了真正慢的东西,并且测量了改动是否真正有效。

这就是性能优化的全部。不是靠直觉。不是靠换框架。不是在应用本身变得高效之前就盲目扩展基础设施。

测量。修复。再次测量。

那 70% 的优化空间一直都在那里。


基准说明:文中的响应时间数据(840ms 基线、各项优化的独立节省)代表了真实 PHP 优化项目中的典型情况,但为了叙述清晰而进行了综合处理。实际结果会因服务器硬件、数据量、流量模式和既有配置而产生显著差异。务必在自己的环境中进行基准测试——方法论比具体数字更重要。

附:八问八答

1. 如何在不换框架的情况下改善 PHP 应用的响应时间?

先用 Laravel Debugbar、Telescope 和 MySQL 慢查询日志做分析。最常见的瓶颈是:N+1 查询(通过预加载修复)、缺失的数据库索引(运行 EXPLAIN 后添加)、OPcache 配置不当(检查命中率和文件限制)、非 session 路由上的 session 开销(移除中间件)以及未压缩的静态资源(在 Web 服务器上启用 Gzip/Brotli)。

2. 什么是 OPcache,它如何提升 PHP 性能?

OPcache 将编译后的 PHP 字节码存储在内存中,无需在每次请求时解析和编译 PHP 文件。没有它,每次请求都会从头编译每一个被引入的文件。对于每次请求加载大量文件的框架类应用,配置正确的 OPcache 可将 PHP 执行时间降低 50%–80%。

3. 如何在 Laravel 应用中找出 N+1 查询?

AppServiceProvider 中启用 Model::preventLazyLoading()——它在未经预加载就访问关联时抛出异常,迫使开发者在开发阶段修复每一处 N+1。对于已有的生产代码,使用 Laravel Debugbar 查看每个请求的查询数,并用 Laravel Telescope 过滤查询数高的请求。

4. Laravel 应用在生产环境应使用什么 OPcache 设置?

设置 opcache.memory_consumption = 256opcache.max_accelerated_files = 20000opcache.validate_timestamps = 0(并在部署脚本中清除 OPcache)。默认设置在 Laravel 下是不够的——2000 的文件限制会在包含 vendor 依赖的完整 Laravel 安装中导致持续的文件驱逐。

5. PHP 中应该使用 Redis 还是数据库 session?

生产环境用 Redis。数据库 session 每次请求要执行两条 SQL 查询(读 + 写)。Redis session 是内存键查找,约 1ms。性能差异在规模化后会叠加放大。此外,应完全从 API 路由、webhook 处理器和其他从不使用 session 数据的端点中移除 session 中间件。

6. 如何在 Nginx 中缓存 PHP HTTP 响应?

nginx.conf 中配置 proxy_cache_path,并在 server 块中添加 proxy_cacheproxy_cache_validproxy_cache_bypass 指令。设置 proxy_cache_bypass $cookie_session 以绝不缓存已认证响应。在 PHP 控制器中为符合条件的端点添加 Cache-Control: public, max-age=N 响应头。

7. PHP 应用的框架开销占响应时间的比例是多少?

对于配置得当的 Laravel 或 Symfony 应用,框架开销约为 10–15ms。这在慢应用的总响应时间中通常不到 5%。其余 95% 以上几乎总是应用层的问题:不必要的查询、缺失的索引、缓存配置错误或资源交付未优化。

8. PHP 性能优化最好的分析器是什么?

Blackfire 是最精细的 PHP 分析器——它按函数和方法级别展示执行时间,并与 CI 集成实现持续性能测试。对于大多数 Laravel 优化工作,Laravel Debugbar 和 Telescope 已免费提供足够的可见性。当 Debugbar 确定了慢的区域但无法定位到具体行时,再使用 Blackfire。

本作品采用《CC 协议》,转载必须注明作者和本文链接