CatchAdmin PHP 后台管理框架 Logo CatchAdmin

PHP 性能优化的正确顺序,大部分人都搞反了

设想这样一个场景:部署了一个新功能,一切看起来正常,流量也平稳。三周后,基础设施团队发来一条 Slack 消息:数据库服务器的 CPU 每周攀升 5%,没人能解释原因。

检查代码,看不出明显问题。功能运行正常,测试全部通过。

问题不是 bug,而是一个缓慢、隐蔽累积的性能问题,直到成本大到被发现时才引起注意。而没人能提前发现的原因是,PHP 应用有一种特定的失效模式:在出问题之前一切正常,等出问题的时候,根因已经被三周的流量掩埋。

本文涵盖的优化策略,正是为了防止这类对话的发生——不是那些随处可见的表面技巧,而是在真实生产代码库中产生可衡量效果的策略。每一条都附带了正确应用所需的上下文,而不是孤立地罗列技术。

优化之前:先测量

最常见的性能错误是优化"感觉慢"的东西,而不是"实际慢"的东西。

想象花了两天时间优化一个每次调用耗时 8ms 的 PHP 函数。而在此期间,被优化的那个端点每次请求都在一张没有索引的表上执行 40 次数据库查询——这才是真正的瓶颈。在 2,300ms 的查询面前,8ms 的函数不过是背景噪音。

在触碰任何代码之前,先建立基线。最慢端点的 P95 响应时间是多少?每个请求执行多少次数据库查询?Redis 命中率如何?请求实际把时间花在了哪里?

以下工具可以回答这些问题:

  • Laravel Debugbar(开发环境)——显示每次请求的查询数量、查询耗时、内存使用和缓存命中情况。是发现 N+1 查询和缺失缓存的最快途径。
  • Blackfire——在函数级别分析 PHP 执行过程,而不仅仅是请求级别。精确显示哪些函数消耗时间以及调用频率。相比 Xdebug,其开销更低,更适合生产等效性能分析。
  • MySQL 慢查询日志,开启 log_queries_not_using_indexes = ON——捕获每一次全表扫描的查询,包括那些当前还算快、但随着表增长将变得灾难性缓慢的查询。
  • New Relic 或 Datadog APM——适用于需要在部署之间持续可视化的生产系统,而非仅仅是抽查。

优化工作流:先分析,识别真正的瓶颈,修复它,再次测量。而不是:假设瓶颈在哪里,修复"感觉慢"的东西,假设有效果。

1. 查询形态优先于一切

大多数 PHP 应用最大的性能收益来自修复数据获取方式,而非 PHP 层面的优化。一条走索引、2ms 返回的查询,胜过一周的 PHP 微优化。

N+1 问题——代价最高的隐藏循环

假设有一个页面显示 50 个订单及每个客户的姓名。代码看起来像这样:

php
$orders = Order::latest()->take(50)->get();
// 在 Blade 模板中:
// {{ $order->customer->name }}  ← 每个订单触发一次查询

这是 51 次查询:一次获取订单,每个订单一次获取客户。在 50 个订单时不可见。在每天 50,000 次请求时,这就是数据库服务器能否扛住负载的分水岭。

修复方案是预加载——一次性提前获取所有关联记录:

php
// ✅ 总共 2 次查询,无论多少订单
$orders = Order::with(['customer', 'items', 'latestStatus'])
    ->latest()
    ->take(50)
    ->get();

在纯 PHP + PDO 中,等价模式是先执行 IN 查询,然后在 PHP 侧建立索引映射:

php
$orders = $pdo->query(
    "SELECT id, customer_id, total FROM orders ORDER BY created_at DESC LIMIT 50"
)->fetchAll(PDO::FETCH_ASSOC);
$customerIds  = array_unique(array_column($orders, 'customer_id'));
$placeholders = implode(',', array_fill(0, count($customerIds), '?'));
$stmt = $pdo->prepare("SELECT id, name FROM customers WHERE id IN ({$placeholders})");
$stmt->execute($customerIds);
$customersById = array_column($stmt->fetchAll(PDO::FETCH_ASSOC), 'name', 'id');
foreach ($orders as &$order) {
    $order['customer_name'] = $customersById[$order['customer_id']] ?? null;
}

两次查询。永远是两次查询,与订单数量无关。

主动捕获 N+1 问题:在 Laravel 中,在非生产环境启用 Model::preventLazyLoading()。当关联被访问但未经预加载时,它会抛出异常——让问题在开发阶段就无处遁形,而不是等到生产环境才暴露。

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

只取所需字段

php
// ❌ 获取每一列,包括大型 JSON 字段
$products = Product::where('is_active', true)->get();
// ✅ 仅获取响应实际用到的列
$products = Product::where('is_active', true)
    ->select(['id', 'name', 'slug', 'price', 'stock_count'])
    ->get();

SELECT * 是一种看似无害、在规模化时不断累积代价的习惯。每一个不必要的列都会产生内存、网络传输和序列化开销——乘以命中该端点的每一次请求。

为查询实际过滤的列建索引

sql
-- 在 10,000 行时这个查询看起来很快。在 4,000,000 行时是灾难。
SELECT id, total, created_at
FROM orders
WHERE status = 'pending'
ORDER BY created_at DESC
LIMIT 50;

对其执行 EXPLAIN

sql
EXPLAIN SELECT id, total, created_at FROM orders
WHERE status = 'pending' ORDER BY created_at DESC LIMIT 50;
-- type: ALL, key: NULL, rows: 4,200,000
-- 全表扫描。每一行都被检查。

添加一个联合索引:

sql
CREATE INDEX idx_orders_status_created ON orders (status, created_at DESC);

再次执行 EXPLAIN

sql
-- type: ref, key: idx_orders_status_created, rows: 12,400
-- Extra: Using index  ← 完全通过索引应答,未读取表行

从检查 420 万行到检查 12,400 行。从 2,300ms 到 2ms。没有修改 PHP 代码,没有修改基础设施。一条索引。

几乎总是需要索引的列:外键(MySQL 不会自动为其添加索引)、大表上 WHERE 子句使用的列、大结果集上 ORDER BY 使用的列、JOIN 条件中使用的列。

2. OPcache:需要验证它确实在工作的优化

PHP 在执行每个 .php 文件之前都会将其编译为字节码。OPcache 将编译后的字节码存储在内存中,消除了每次请求的重复编译。在一个包含数百个类文件的 Laravel 应用中,这意味着显著的性能提升。

OPcache 随 PHP 7.0+ 一起发布,几乎可以肯定已经安装。问题在于默认配置对现代 Laravel 应用是不够的,而许多团队假设它在正常工作,实际上并非如此。

检查 OPcache 是否确实缓存了该缓存的内容:

php
$status = opcache_get_status();
echo "Cached scripts: " . $status['opcache_statistics']['num_cached_scripts'] . "\n";
echo "Hit rate: "       . round($status['opcache_statistics']['opcache_hit_rate'], 2) . "%\n";
echo "Memory used: "    . round($status['memory_usage']['used_memory'] / 1024 / 1024, 1) . "MB\n";

命中率低于 95% 意味着 OPcache 在驱逐文件并强制重新编译。最常见的原因是 opcache.max_accelerated_files 设置过低:

ini
; php.ini — 生产环境 OPcache 配置
opcache.enable                 = 1
opcache.memory_consumption     = 256     ; 从默认 128 提高
opcache.max_accelerated_files  = 20000   ; 默认 2000 对 Laravel + vendor 太低
opcache.validate_timestamps    = 0       ; 在生产环境禁用文件变更检查
opcache.revalidate_freq        = 0       ; 在 validate_timestamps = 0 下无关
opcache.fast_shutdown          = 1

validate_timestamps = 0 意味着 PHP 不再检查文件是否变更——始终使用缓存的字节码。这在生产环境是安全的,只要部署脚本在部署新代码时清除 OPcache:

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

正确配置 OPcache 带来的性能提升,对于一个冷启动的 PHP 应用通常为每次请求 50–95ms——这是投入产出比最高的优化之一,而且几乎零成本。

3. 带有明确目的的缓存:分层、TTL 以及什么不该缓存

缓存并非单一事物——它是三个不同的层次,各有不同的目的、不同的失效策略和不同的适用场景。

第一层:应用数据缓存(Redis/Memcached)

缓存跨请求复用的昂贵计算或数据库查询结果:

php
function getActiveCategories(PDO $pdo, Redis $redis): array
{
    $cacheKey = 'catalog:categories:v1';
    $cached   = $redis->get($cacheKey);
    if ($cached !== false) {
        return json_decode($cached, true, flags: JSON_THROW_ON_ERROR);
    }
    $categories = $pdo->query(
        'SELECT id, name, slug FROM categories WHERE is_active = 1 ORDER BY sort_order'
    )->fetchAll(PDO::FETCH_ASSOC);
    $redis->setex($cacheKey, 600, json_encode($categories, JSON_THROW_ON_ERROR));
    return $categories;
}

关键设计决策:缓存键包含版本号(v1),这样模式变更时,下次部署即可干净地失效;TTL 有上限,因此过期数据的最大存活时间为 10 分钟。

适合放在这里的:分类列表、功能开关、配置数据、预计算的聚合结果、不频繁变动的昂贵外部 API 结果。

不适合放在这里的:财务余额、用于购买决策的库存数量、认证状态、会话数据,以及任何"出错"会导致用户可见后果(超过短暂过期窗口)的数据。

第二层:完整 HTTP 响应缓存(Nginx/Varnish/CDN)

对于所有用户都相同且不频繁变动的完全公开响应,最快的缓存是绕过 PHP 的缓存:

php
// Controller — 设置合适的 HTTP 缓存头
public function productIndex(): Response
{
    $products = Cache::remember('products:active', 300, fn () =>
        Product::active()->select(['id', 'name', 'slug', 'price'])->get()
    );
    return response()
        ->view('products.index', compact('products'))
        ->header('Cache-Control', 'public, max-age=300, s-maxage=300')
        ->header('Vary', 'Accept-Encoding');
}
nginx
# nginx.conf — 在代理层缓存
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=app_cache:10m max_size=1g inactive=60m;
location / {
    proxy_cache            app_cache;
    proxy_cache_valid      200 300s;
    proxy_cache_bypass     $cookie_session;     # 绝不缓存已认证响应
    proxy_cache_bypass     $http_authorization;
    add_header             X-Cache-Status $upstream_cache_status;
    proxy_pass             http://php_upstream;
}

在这一层命中缓存的成本约为 8ms,而 PHP + 数据库的成本约为 220ms。对于高流量的公开端点,这是可用的最优单次优化——而且不需要修改 PHP 代码,只需设置正确的 Cache-Control 头。

第三层:PHP 会话存储(迁移到 Redis)

数据库驱动的会话每次请求执行两次 SQL 查询(读 + 写)。Redis 会话是内存中的键查找,每次约 1ms。

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

此外,对根本没有会话概念的路由——API 端点、Webhook 处理器、健康检查 URL——完全移除会话中间件:

php
// routes/api.php
Route::withoutMiddleware([StartSession::class])
    ->group(function () {
        Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle']);
        Route::get('/health',           [HealthController::class, 'check']);
    });

这些端点之前一直在为它们从未使用过的会话管理支付每次请求两次 SQL 查询的代价。

4. 将昂贵的工作移出请求路径

一个请求应该只做返回响应所必需的工作——仅此而已。不影响用户即刻可见结果的操作不应同步发生。

适合异步处理的候选操作:

  • 邮件发送
  • 向外部服务投递 Webhook
  • 报表生成
  • 图片缩放和缩略图创建
  • 分析事件记录
  • 非关键第三方数据增强(风险评分、个性化)
php
// ❌ 用户在邮件发送完成后才收到响应
public function checkout(CheckoutRequest $request): JsonResponse
{
    $order = $this->orderService->create($request->validated());
    $this->mailer->sendOrderConfirmation($order); // 同步 — 用户等待
    return response()->json(['order_id' => $order->id]);
}

// ✅ 邮件入队 — 用户立即收到响应
public function checkout(CheckoutRequest $request): JsonResponse
{
    $order = $this->orderService->create($request->validated());
    SendOrderConfirmation::dispatch($order); // 异步 — 微秒级返回
    return response()->json(['order_id' => $order->id]);
}

用户体验完全相同——他们在同一时刻看到确认页面。但响应时间截然不同。

同样的原则适用于任何增强数据但不决定响应的第三方 API 调用。一个欺诈评分、一个个性化信号、一个分析事件——这些都不需要阻塞 HTTP 响应。将它们分发到队列,异步处理。

5. 确实有可衡量效果的 PHP 层面优化

大多数 PHP 层面的"优化技巧"产生的收益以微秒计——在紧密循环中有意义,在请求级别无关紧要。以下是值得应用的那些:

避免在循环条件中重复调用函数

php
// ❌ 每次迭代都调用 count()
for ($i = 0; $i < count($items); $i++) {
    // ...
}
// ✅ count() 只调用一次
$itemCount = count($items);
for ($i = 0; $i < $itemCount; $i++) {
    // ...
}

对小数组影响甚微,对大数据集的循环可测量。

使用严格比较

php
// ❌ 宽松比较 — 触发类型转换
if ($status == 'active') { ... }
if ($count == 0) { ... }
// ✅ 严格比较 — 无类型强制转换
if ($status === 'active') { ... }
if ($count === 0) { ... }

严格比较稍快且显著更安全——在 PHP 的宽松比较中,0 == 'active'true,这会产生那种需要花数小时排查的 bug。

优先使用内置函数而非用户态等价实现

PHP 的内置函数用 C 实现,始终比同样逻辑的 PHP 实现更快:

php
// ❌ PHP 循环求最大值
$max = $numbers[0];
foreach ($numbers as $n) {
    if ($n > $max) $max = $n;
}
// ✅ 内置函数 — 相同结果,C 实现
$max = max($numbers);

// ❌ 手动字符串搜索
$found = false;
foreach ($haystack as $item) {
    if ($item === $needle) { $found = true; break; }
}
// ✅ 内置函数
$found = in_array($needle, $haystack, strict: true);

单次调用的性能差异可忽略不计,但在一个请求或循环中调用数千次的函数中,累积效应就显现了。

长时间运行的进程中显式释放内存

在 Web 请求中,PHP 在进程结束时清除所有内存。但在长时间运行的 CLI 脚本、队列工作进程和批处理作业中,变量会不断累积,直到进程耗尽可用内存:

php
// 在处理数百万条记录的批处理作业中
while (true) {
    $batch = fetchNextBatch($pdo, $offset, $batchSize);
    if (empty($batch)) break;
    processBatch($batch);
    $offset += $batchSize;
    unset($batch); // 显式释放 — 不要等待垃圾回收
    gc_collect_cycles(); // 必要时每 N 批强制循环回收
}

这就是一个批处理作业在 200 万条记录后干净完成,与在第 80 万条记录时耗尽 2GB 内存之间的区别。

6. PHP 应用扩展:规模化时真正发生了什么变化

大多数 PHP 扩展建议将两个不同的问题混为一谈:为更多请求而扩展(水平扩展)和为更多数据而扩展(垂直扩展)。它们需要不同的策略。

更多请求 → 水平扩展

增加更多 PHP-FPM 工作进程,或在负载均衡器后面增加更多应用服务器。PHP 的无共享架构使这一点变得直接:每个工作进程都是独立的。

使水平扩展正确运作的前提条件:

  • 无状态应用设计——没有必须由特定工作进程持有的内存状态。会话数据存储在 Redis 中(非进程内)。缓存使用 Redis(而非 APCu,因为 APCu 是进程级别的)。
  • 共享缓存层——Redis 或 Memcached,而非基于文件或内存的、跨服务器不可见的缓存。
  • 数据库连接池——PostgreSQL 使用 PgBouncer,MySQL 使用 ProxySQL,防止随着工作进程数量增长出现连接耗尽。
nginx
# nginx.conf — 跨多个 PHP-FPM 上游节点负载均衡
upstream php_workers {
    server 10.0.1.10:9000 weight=3;
    server 10.0.1.11:9000 weight=3;
    server 10.0.1.12:9000 weight=2;  # 配置较低的机器
    keepalive 32; # 到上游的连接池
}

更多数据 → 策略性索引与分区

更多数据意味着曾经快速的查询会变慢,不是因为更多用户在访问它们,而是因为它们扫描的表更大了。解决方案是索引(上文已讨论),对于非常大的表,还有分区。

按日期范围进行 MySQL 表分区——适用于事件、日志和时间序列数据等以追加为主的表:

sql
CREATE TABLE events (
    id          BIGINT AUTO_INCREMENT,
    user_id     BIGINT NOT NULL,
    event_type  VARCHAR(50) NOT NULL,
    created_at  DATETIME NOT NULL,
    PRIMARY KEY (id, created_at)    -- 分区键必须在主键中
)
PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) (
    PARTITION p2024_01 VALUES LESS THAN (202402),
    PARTITION p2024_02 VALUES LESS THAN (202403),
    -- 按需添加分区
    PARTITION p_future VALUES LESS THAN MAXVALUE
);

created_at 范围过滤的查询只扫描相关分区——显著减少了历史数据查询的 I/O。

优化优先级排序

如果面对一个存在性能问题的 PHP 应用但不知道从何入手,以下序列对大多数应用而言能在最短时间内产生最大的收益:

  1. 识别真正的瓶颈——用 Blackfire、Debugbar 或慢查询日志做性能分析。不要猜测。
  2. 修复 N+1 查询——添加预加载或 IN 批量查询。这是 PHP 应用中数据库过载最常见的原因。
  3. 补上缺失的索引——对五个最慢的查询执行 EXPLAIN。任何在大表上 type: ALL 的查询都需要索引。
  4. 验证 OPcache 配置正确——检查命中率。如有必要,提高 max_accelerated_filesmemory_consumption
  5. 将慢速工作移出请求路径——邮件、Webhook、报表和非关键 API 调用进入队列。
  6. 添加应用层缓存——为昂贵、复用的查询结果使用 Redis,并设置明确的失效策略。
  7. 添加 HTTP 层缓存——为公开端点设置 Cache-Control 头。高流量页面使用 Nginx 代理缓存或 CDN。
  8. 将会话切换到 Redis——配置上微小的改动,每次请求消除两次 SQL 查询。
  9. 水平扩展——仅在上面各项到位后进行。扩展一个低效的应用只会扩展低效本身。

关于"高级"优化

关于 PHP 性能的文章常常包含一些技术,如预 fork 服务器(Swoole、ReactPHP)、字节码级优化、JIT 编译调优和其他高级方案。

这些是真实存在的技术,在正确的场景中也确实有收益。那个正确的场景是:已经穷尽了查询优化、缓存和架构带来的所有收益,仍然需要更高性能。对绝大多数 PHP 应用而言,这个时刻永远不会到来。

本系列上一篇文章中 70% 的响应时间缩减——从 840ms 降到 250ms——完全来自 N+1 修复、缺失索引、OPcache 配置、会话优化、服务提供者延迟加载和 HTTP 缓存。没有 Swoole,没有 JIT 调优,没有更换框架。

先把基础优化做好。基础优化中几乎总是蕴含着比高级技术更多的可用收益。

核心要点

  • 先测量再优化。通过性能分析找到真正的瓶颈。表面上明显的瓶颈很少是真正的瓶颈。
  • 数据库查询优先。N+1 查询和缺失索引是 PHP 应用性能问题最常见的原因。在修复其他问题之前先修复这些。
  • OPcache 配置往往是坏的。检查命中率。默认设置对现代 Laravel 应用不充分。
  • 缓存需要失效策略。一个没有数据变更处理计划的缓存不是优化——是一个等待发生的 consistency bug。
  • 将慢速工作移出热路径。邮件、Webhook 和非关键 API 调用应该进入队列,而非 HTTP 响应周期。
  • 先让应用高效,再水平扩展。扩展一个低效的系统只会扩展成本。
  • 最大的收益来自基础优化,而非高级技术。查询形态、索引、OPcache 和缓存架构,在绝大多数生产代码库中产生的效果远超字节码优化或服务器预 fork。
本作品采用《CC 协议》,转载必须注明作者和本文链接