前言

今天花了一整个下午排查这个博客的性能问题。症状很简单——每次首次打开页面,至少要等 5-10 秒。静态资源?秒加载。问题 100% 出在服务端。

这篇文章完整记录了从发现问题到最终解决的全过程,包括走过的弯路。


环境背景

先交代一下这个博客的部署架构:

  • 框架:Laravel 10 + SQLite
  • 运行环境:WSL2 Ubuntu 22.04
  • Web 服务器:Nginx + PHP-FPM
  • 文件位置:Windows 磁盘 D:\projects\my-blog(在 WSL 中挂载为 /mnt/d/...)
  • 访问方式:https://your-domain.com/blog

注意到关键信息了吗?PHP 文件存储在 Windows 磁盘上,而 PHP 运行在 WSL2 内。


第一阶段:前端排查(走弯路)

最开始怀疑是前端资源加载慢。打开 F12 一看,页面引用了 7 个 CDN 资源:

  • Bootstrap CSS/JS
  • highlight.js CSS/JS
  • marked.js
  • DOMPurify
  • medium-zoom

于是动手把这些 CDN 全部下载到本地 public/vendor/ 目录,修改 frontend.blade.php 模板,把 CDN URL 替换为 {{ asset('vendor/...') }},并给所有 <script> 标签加上 defer 属性。

结果:静态资源确实快了,但页面加载时间毫无变化。

然后看了一眼浏览器的时间线瀑布图——文档请求本身就用了 8.7 秒,所有静态资源在 11-35ms 内加载完毕。问题根本不在前端。


第二阶段:后端代码优化

接着排查后端代码,发现了几个性能问题:

1. Setting Model 的 N+1 查询

Setting::getValue() 每次调用都会执行一次 DB 查询。一个典型页面调用 3 次以上(获取站名、分页数、描述等)。加了缓存:

public static function getValue($key, $default = null)
{
    $settings = Cache::remember('app.settings', 3600, function () {
        return static::pluck('value', 'key')->toArray();
    });
    return $settings[$key] ?? $default;
}

2. TagCloud 每次请求都加载全部文章

buildTagCloud() 方法会从数据库加载所有文章来统计标签。同样加了缓存:

$tagCloud = Cache::remember('blog.tag_cloud', 3600, function () {
    // ... 原有的标签统计逻辑
});

3. Session 驱动用的是 file

在 WSL2 中,file session 意味着每次请求都要通过 9P 协议读写 Windows 磁盘上的 session 文件。改为 cookie。

4. 日志级别是 debug

LOG_LEVEL=debug 产生大量日志写入。改为 error。

结果:这些优化是好的,但并非性能瓶颈的根因。


第三阶段:找到真凶——OPcache + 9P 协议

这一步才是关键发现。

查看 OPcache 配置:

opcache.validate_timestamps = On
opcache.revalidate_freq = 2

这意味着什么?每 2 秒,OPcache 会检查所有已缓存的 PHP 文件是否有更新。一个 Laravel 项目有 100+ 个 PHP 文件(框架本身 + vendor 依赖)。

而这些文件全部存储在 /mnt/d/——WSL2 通过 9P 协议访问 Windows 文件系统。9P 的文件 stat 操作比原生 ext4 慢 10-50 倍。

算一笔账:

  • 100 个文件 × stat 操作 × 9P 延迟 = 每次请求额外花费好几秒在文件检查上

修复

创建 /etc/php/X.Y/fpm/conf.d/99-performance.ini:

[opcache]
opcache.validate_timestamps=0
opcache.revalidate_freq=0
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=10000
opcache.enable_file_override=1

其中 validate_timestamps=0 是最关键的——告诉 OPcache 永远不要检查文件是否更新。一旦 PHP 文件被编译缓存,就一直用缓存版本,直到 PHP-FPM 重启。

重启 PHP-FPM 后……


第四阶段:改炸了

优化完信心满满地刷新页面——浏览器显示出了 index.php 的原始 PHP 源代码。

好家伙,把站改炸了。

排查过程又开始了:

  • PHP-FPM 运行正常 ✓
  • Nginx 配置语法正确 ✓
  • 直接访问 .php 文件没问题 ✓
  • 但 /nblog/ 路由返回 405 Method Not Allowed

Nginx 配置的坑

这个博客跑在 /nblog/ 子路径下,Nginx 用的是 alias 而不是 root。alias + try_files + index 的组合在 Nginx 中有一个经典的坑:

location /nblog {
    alias /mnt/d/projects/my-blog/public;
    index index.php;
    try_files $uri $uri/ @nblog;
}

当访问 /nblog/ 时:

  1. try_files 检查 $uri(即 /nblog/)
  2. alias 把它映射到 public/ 目录——目录存在!
  3. 匹配成功,Nginx 找到 index.php
  4. 但它把 index.php 作为静态文件返回,而不是转发给 PHP-FPM

于是就看到了原始 PHP 源码。

折腾了 7 个版本的 Nginx 配置

从 _patch_nginx.py 写到 _patch_nginx7.py,尝试了各种组合:

  • 嵌套 PHP location ❌
  • 去掉 $uri/ ❌
  • 去掉 index 指令 ❌
  • 各种 rewrite 规则 ❌

每次都是 nginx -t 通过,nginx -s reload,然后 wget 测试——依然 405。

最后的真相

在 index.php 里加了调试日志:

file_put_contents('/tmp/laravel_debug.log',
    date('Y-m-d H:i:s') . " METHOD=" . $request->getMethod() . "\n",
    FILE_APPEND);

刷新页面——日志文件根本不存在。index.php 压根没被执行。

等等……我不是设了 opcache.validate_timestamps=0 吗?

就是这个! 我修改了 index.php 加了调试代码,但 OPcache 缓存的还是旧版本的 index.php。之前做 php artisan optimize 生成了路由缓存,后来 route:clear 清除了路由缓存文件,但 OPcache 里缓存的 PHP 字节码还是旧的(包含旧路由逻辑的版本)。

一个 sudo service phpX.Y-fpm restart 就解决了一切。

而那 7 个 Nginx 补丁?其实第一个版本就是对的,Nginx 配置从头到尾都没问题。是 OPcache 的旧缓存在作怪。


最终结果

指标 优化前 优化后
页面加载时间(热缓存) 8.7 秒 0.4 秒
提升倍数 — ~22 倍

经验总结

1. 先看瀑布图再优化

浏览器截图直接告诉我:文档请求 8.7 秒,静态资源 11ms。根本不需要在前端上浪费时间。

2. WSL2 + /mnt/d/ = 9P 性能陷阱

任何跨文件系统操作都会变慢;频繁的文件 stat、session 读写、日志写入——全部受影响。如果项目必须放在 Windows 磁盘上,务必关闭 OPcache 文件校验。

3. validate_timestamps=0 是双刃剑

关了它性能飞升,但代价是:修改 PHP 代码后必须手动重启 PHP-FPM。不然你改的代码根本不会生效——我自己就因为这个调试了好久。

4. 调试时要记得你做过什么优化

我花了大量时间调试 Nginx 配置,实际上 Nginx 一直是好的。真正的问题是我自己设的 validate_timestamps=0 导致 OPcache 不加载新代码。改了 7 个版本的 Nginx 补丁,不如一个 service phpX.Y-fpm restart。

5. 完整的优化清单

  • OPcache validate_timestamps=0(最大收益)
  • OPcache 内存调至 256MB
  • CDN 资源本地化
  • JS 添加 defer
  • Setting 查询加 Cache::remember
  • TagCloud 查询加 Cache::remember
  • Session driver 改为 cookie
  • Log level 改为 error

下午 debug 了三小时,核心修复只需要一行配置。但没有前面那三小时的排查,你不会知道该改哪一行。