性能优化最常见的错误是「凭感觉改代码」。这篇按「先定位瓶颈 → 再动代码 → 最后调配置」的顺序讲,附一份可以直接照抄的 OPcache 配置。

一、先搞清楚时间花在哪

PHP 的一次请求,时间大致花在四个地方:

  1. 网络与 IO —— 数据库查询、Redis、外部 HTTP 调用
  2. 文件系统 —— 加载 PHP 文件、读取模板
  3. CPU —— 业务逻辑计算
  4. 框架开销 —— 路由、容器、中间件

绝大多数 Web 应用,70% 以上的时间在第 1 项。所以优化顺序应该是:SQL → 缓存 → 代码 → 语言层配置。

先测量,别猜。

最轻量的测量:手写计时

$t = hrtime(true);

// ... 被测代码 ...

$ms = (hrtime(true) - $t) / 1e6;
error_log(sprintf('查询耗时 %.2f ms', $ms));

hrtime(true) 是 PHP 7.3+ 的单调高精度计时器,比 microtime(true) 更准(不受系统时间调整影响)。

结构化测量:Xdebug + 分析器

开发环境装 Xdebug,开 profiler:

xdebug.mode = profile
xdebug.output_dir = /tmp/xdebug

跑一遍请求,用 KCachegrind 或者 QCachegrind 打开 cachegrind.out.*,能直接看到「哪个函数调用了几万次、占了多少时间」。

生产环境别开 Xdebug,它会让性能掉好几倍。生产用 Tideways 或 Blackfire,开销小得多。

最快的定位方式:慢查询日志

如果怀疑是数据库,直接开 MySQL 慢查询日志:

SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;
SET GLOBAL log_queries_not_using_indexes = 'ON';

比在 PHP 里打点快得多,而且能直接看到执行计划。

二、代码层的几个真实收益点

1. N+1 查询

这是 Web 应用最常见的性能杀手。

// 100 个用户 → 101 次查询
$users = $db->fetchAll('SELECT id, name FROM users LIMIT 100');
foreach ($users as &$user) {
    $user['posts'] = $db->fetchAll(
        'SELECT id, title FROM posts WHERE user_id = ?',
        [$user['id']]
    );
}

改成一次查询 + 内存分组:

$users = $db->fetchAll('SELECT id, name FROM users LIMIT 100');
$ids   = array_column($users, 'id');

$ph   = implode(',', array_fill(0, count($ids), '?'));
$rows = $db->fetchAll("SELECT id, title, user_id FROM posts WHERE user_id IN ({$ph})", $ids);

$grouped = [];
foreach ($rows as $row) {
    $grouped[$row['user_id']][] = $row;
}

foreach ($users as &$user) {
    $user['posts'] = $grouped[$user['id']] ?? [];
}

从 101 次往返降到 2 次。这一条通常比后面所有微优化加起来都值钱。

2. 循环里重复计算

// 差:每次迭代都调用 count()
for ($i = 0; $i < count($items); $i++) { /* ... */ }

// 好
$count = count($items);
for ($i = 0; $i < $count; $i++) { /* ... */ }

count() 在 PHP 8 里是 O(1)(数组结构里存了长度),所以这个优化收益很小。但函数调用本身有开销,几万次循环里差别就出来了。更重要的是语义清晰。

真正需要避免的是循环里做 IO:

// 灾难:循环里查数据库、读文件
foreach ($ids as $id) {
    $row = $db->fetchOne('SELECT * FROM t WHERE id = ?', [$id]);
}

3. 字符串拼接

// 差:循环里用 .= 拼大字符串
$html = '';
foreach ($items as $item) {
    $html .= '
  • ' . $item . '
  • '; } // 好:攒数组再 implode $parts = []; foreach ($items as $item) { $parts[] = '
  • ' . $item . '
  • '; } $html = implode('', $parts);

    现代 PHP 的 .= 有优化,小规模差别不大。但拼接超大字符串时,内存分配次数是数量级差异。

    4. isset() vs array_key_exists()

    $arr = ['a' => null];
    
    isset($arr['a']);              // false(值为 null 也算不存在)
    array_key_exists('a', $arr);   // true

    isset() 是语言结构,快得多;array_key_exists() 是函数调用。如果数组值不会是 null,用 isset()。

    需要判断「键存在且值不为 null」用 isset(),需要判断「键存在(值可能是 null)」用 array_key_exists()。别为了快而用错语义。

    5. 多个替换用 strtr()

    // 四次遍历
    $out = str_replace(['{name}', '{age}', '{city}'], [$n, $a, $c], $tpl);
    
    // 一次遍历
    $out = strtr($tpl, ['{name}' => $n, '{age}' => $a, '{city}' => $c]);

    strtr() 用哈希表一次扫描完成,替换项越多优势越明显。

    6. 生成器处理大数组

    // 一次读进内存,100 万行直接 OOM
    function readAll(string $file): array {
        $rows = [];
        $fh = fopen($file, 'r');
        while (($line = fgets($fh)) !== false) {
            $rows[] = $line;
        }
        fclose($fh);
        return $rows;
    }
    
    // 生成器:内存恒定
    function readLines(string $file): Generator {
        $fh = fopen($file, 'r');
        try {
            while (($line = fgets($fh)) !== false) {
                yield $line;
            }
        } finally {
            fclose($fh);
        }
    }
    
    foreach (readLines('/var/log/big.log') as $line) {
        // 处理一行,内存只占一行
    }

    finally 里关文件句柄,保证即使中途 break 或抛异常也能释放。

    三、OPcache:性价比最高的一项

    OPcache 把编译后的字节码缓存在共享内存,省掉每次请求的「读文件 + 词法分析 + 语法分析 + 编译」。

    ; 启用
    opcache.enable=1
    opcache.enable_cli=0
    
    ; 共享内存大小。项目越大要越多,128M 是常见起点
    opcache.memory_consumption=128
    
    ; 最多缓存多少个脚本。项目文件多的话这个值要调大,否则频繁淘汰
    opcache.max_accelerated_files=20000
    
    ; 是否检查文件修改时间。生产环境关掉,性能提升明显
    opcache.validate_timestamps=0
    
    ; 关掉上面的检查后,这个就没意义了
    ; opcache.revalidate_freq=0
    
    ; 保存注释(很多框架靠注解做路由,关掉会出问题)
    opcache.save_comments=1
    
    ; 快速关闭,避免请求结束时的析构开销
    opcache.fast_shutdown=1
    
    ; 内部字符串驻留,省内存
    opcache.interned_strings_buffer=16

    关键提醒:opcache.validate_timestamps=0 之后,改了代码不会自动生效,必须重启 PHP-FPM 或者调 opcache_reset()。很多「改了代码线上没变化」的诡异问题都是这个引起的。

    如果不想重启,可以在部署脚本里加一步:

    # 宝塔 / 通用
    systemctl reload php-fpm
    
    # 或者用一个小脚本触发
    curl -s https://example.com/opcache-reset.php?token=xxx

    (这个 reset 脚本一定要加 token 校验并限制 IP,否则谁都能清缓存。)

    四、JIT:别期待太高

    PHP 8.0 引入 JIT。配置:

    opcache.jit=tracing
    opcache.jit_buffer_size=64M

    但要清楚它的定位:JIT 优化的是 CPU 密集型计算。

    对典型的 Web 请求(大量时间花在 MySQL、Redis、网络 IO 上),JIT 带来的提升通常在个位数百分比。真正能吃满 JIT 的是:

    • 图像处理(GD、Imagick 的纯 PHP 部分)
    • 数学计算、加解密
    • 模板渲染这类纯 CPU 循环

    如果你的应用是「查库 → 拼 HTML」,先把 SQL 和缓存做好,比折腾 JIT 收益大得多。

    另外,开了 JIT 内存占用会上升,小内存机器(1G 以下)要谨慎。

    五、其他容易忽略的点

    realpath 缓存

    PHP 每次 require 都要解析真实路径,涉及文件系统调用。调大缓存:

    realpath_cache_size=4096k
    realpath_cache_ttl=600

    配合 Composer 的 --classmap-authoritative,能显著减少文件系统调用。

    别滥用 @

    // @ 会临时改 error_reporting,并且抑制后你完全不知道发生了什么
    $content = @file_get_contents($url);

    改成显式判断:

    if (is_file($path) && is_readable($path)) {
        $content = file_get_contents($path);
    }

    @ 除了掩盖问题,本身也有开销。

    输出缓冲

    ob_start();
    // ... 大量 echo ...
    $html = ob_get_clean();

    好处是可以一次性压缩(ob_gzhandler)、可以设 Content-Length、可以最后统一处理。但别开无限层嵌套。

    会话文件锁

    session_start() 会锁住会话文件,同一用户的并发请求会串行等待。API 场景如果不改 session,直接:

    session_start(['read_and_close' => true]);

    或者干脆别用文件 session,换 Redis。

    六、一份可执行的优化清单

    按投入产出比排序:

    1. 慢查询日志开起来,把 long_query_time 设 0.5 秒,找出前十个慢 SQL
    2. 给 WHERE / ORDER BY / JOIN 的字段加索引,用 EXPLAIN 验证
    3. 消灭 N+1 查询
    4. 热点数据上 Redis,别每次都查库
    5. OPcache 按上面的配置调好
    6. Composer 用 dump-autoload -o --classmap-authoritative
    7. 静态资源走 CDN,开 gzip / brotli
    8. 最后才考虑 JIT 和代码微优化

    小结

    性能优化有一条铁律:没有测量就没有优化。

    我见过太多「把 for 循环改成 foreach、把 count() 提到循环外」的所谓优化,实际收益不到 1%;而一个没加索引的查询,慢 300 倍。

    先看慢查询日志,再看火焰图,最后才动代码。