拆开 Typecho 的 PHP-FPM 慢请求问题
用 Typecho 建站的站长都知道它轻量,一个页面几十毫秒就能出来。但随着文章数累积到几千篇、开了几个插件、评论表涨到几万行之后,很多人的站会开始出现一种症状:大部分时候很快,但每隔一会儿就卡一下,卡的时候要两三秒才出页面,而且往往同时有多个请求一起卡。
这种"偶发整体卡顿"比"一直很慢"更难排查,因为它不是稳定的性能瓶颈,而是资源竞争或外部依赖造成的尖峰。这篇文章不讲泛泛的 PHP 优化,而是给出一套针对 PHP-FPM + Typecho 场景的定位方法,从 FPM 的 slow log 入手,一步步找到具体是哪行代码或哪个查询在拖后腿。
第一步:把 PHP-FPM 的 slow log 打开
大部分 PHP-FPM 的安装默认关闭了慢日志,这是排查卡顿最大的障碍。没有慢日志,你只能看到"总体变慢",看不到"具体哪个脚本慢"。
在 pool 配置里(Debian/Ubuntu 通常在 /etc/php/8.x/fpm/pool.d/www.conf,CentOS 在 /etc/php-fpm.d/www.conf)打开这几项:
request_slowlog_timeout = 3s
slowlog = /var/log/php-fpm/slow.log
request_terminate_timeout = 60s
request_terminate_timeout_track_finished = yesrequest_slowlog_timeout = 3s 意味着执行超过 3 秒的请求会被记录并 dump 出 PHP 的调用栈。request_terminate_timeout 是硬性终止时间,防止某个请求永远挂住占着进程不放。
改完 systemctl reload php8.x-fpm(reload 即可,不用 restart,不会中断现有请求),然后观察 slow.log。有卡顿的时候,它会给出一份非常直接的证据:
[pool www] pid 12345
script_filename = /var/www/blog/index.php
[0x00007f...] file_get_contents() /var/www/blog/usr/plugins/Xxx/Plugin.php:88
[0x00007f...] curl_exec() /var/www/blog/var/Typecho/Common.php:312
[0x00007f...] Typecho_Http_Client->send() /var/www/blog/usr/plugins/Xxx/Plugin.php:88这份栈是从下往上读的:最下面是最外层调用,最上面是当前卡住的位置。上面这个例子一眼就能看出:某个插件在 file_get_contents 或 curl_exec 上阻塞了——这是"慢请求"里占比最高的一类,占我见过的案例里至少一半。
第二类高频元凶:插件里的同步外部请求
PHP 是同步阻塞的语言。一个请求如果在代码里发起 HTTP 请求去调用外部接口(比如评论审核 API、文章统计上报、图床接口、AI 摘要生成),那么整个 FPM 进程就会被占住,直到那个外部请求返回或超时。
最要命的是超时时间。如果代码里 file_get_contents 没有设置超时,或者 curl 没设 CURLOPT_TIMEOUT,那么当外部接口抽风时,进程会一直等下去,默认可能是几十秒甚至无限。
症状就非常典型了:
- 平时很快,偶尔整体卡住几秒到几十秒
- 卡顿是"成片"的——因为 FPM 进程池里的进程被同时占满,后续请求全部排队
- 外部接口恢复后,卡顿自动消失
解决办法有三条,按优先级:
- 给所有外部请求加超时。curl 加
CURLOPT_TIMEOUT => 3, CURLOPT_CONNECTTIMEOUT => 2;file_get_contents用 stream context 设timeout。这是必须做的底线。 - 把非关键的外部调用改成异步或延迟执行。比如文章页里的统计上报、AI 摘要,完全可以不阻塞页面渲染,改成写队列后台跑,或者用 JS 在浏览器侧发起。
- 实在要同步调用的,加本地缓存。同一个数据在 5 分钟内重复请求,直接从缓存返回,能砍掉绝大部分外部调用量。
第三类:数据库慢查询与锁等待
Typecho 默认的查询都不慢,但插件和自定义主题经常写出没有走索引的查询。slow.log 里会看到类似 PDO::query 或 mysqli_query 卡住的栈。
这时候要配合 MySQL 的慢查询日志一起看:
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1;
SET GLOBAL log_queries_not_using_indexes = 'ON';Typecho 场景下最常见的慢查询模式是:
- 按 meta 字段(标签、分类)查询文章时,
typecho_relationships和typecho_metas的 JOIN 没有合适索引 - 评论表的
WHERE cid = ? AND status = ?在大数据量下没有复合索引 - 用了
LIKE '%关键词%'这种前置通配的搜索,索引完全失效
另外要特别注意锁等待。如果队列应用(比如后台跑定时任务更新统计)和前台请求同时写同一张表,可能出现行锁等待。这种慢在 slow.log 里表现为 PDOStatement::execute 卡住,而单看那条 SQL 并不慢。用 SHOW ENGINE INNODB STATUS 或者 SELECT * FROM sys.innodb_lock_waits 能看到具体的等待关系。
第四类:FPM 进程池配置不合理
如果 slow.log 里什么都没抓到,但站点确实会卡,那问题可能不在代码,而在 FPM 的进程池参数。
关键参数是 pm.max_children。它决定了同时能处理多少个请求。如果设得太小(比如默认的 5),高并发时请求就排队;设得太大,内存又会被吃光,触发 OOM 或者开始用 swap,那才是真正的灾难。
合理的算法是:
pm.max_children = 可用内存 / 单个 PHP 进程平均占用单个 PHP 进程的内存占用可以从 ps -o rss,cmd -C php-fpm 观察,Typecho 站通常在 30–60MB 之间,装了重型插件的可能到 100MB。一台 1GB 内存的 VPS,留 200MB 给系统和 MySQL,大概只能给 12–15 个 children。
同时把 pm.max_requests 设上(比如 500),让进程处理一定请求数后自动重启,避免内存泄漏长期累积。PHP 插件写得不好时内存泄漏是真实存在的,这个设置是最后一道保险。
还有一个容易被忽略的参数:pm.status_path。打开它,然后用 Nginx 暴露一个只有内网或本地能访问的状态页,curl http://127.0.0.1/fpm-status?full 能看到当前每个进程在跑什么脚本。卡顿发生的时候看一眼,能立刻知道是哪个 URL 在耗时。
第五类:磁盘 IO 与文件系统
这条在 VPS 上尤其常见。便宜的小鸡用的是网络存储或者被别人抢 IO 的邻居盘,磁盘读写在高峰期会突然变慢几十倍。
PHP 请求虽然不直接做大量 IO,但以下操作都会踩到:
- session 文件读写(默认存在
/var/lib/php/sessions,大量请求下这个目录会成为热点) - OPcache 校验文件修改时间(
opcache.validate_timestamps=1时每个请求都要 stat 文件) - 日志写入(Nginx 和 PHP 都在写日志)
- 模板编译缓存
用 iostat -x 1 观察 %util 和 await。如果 %util 长期接近 100% 而 await 很大,说明磁盘是瓶颈。优化的方向是把 session 和缓存移到内存(tmpfs 或者 Redis),并且把 opcache.validate_timestamps 关掉(代价是改代码后要手动 reload FPM)。
把排查流程固化下来
总结成一份可复用的清单:
- 开 PHP-FPM slow log,设
request_slowlog_timeout = 3s。这是第一步,没有它后面全是猜。 - 卡顿发生时抓 slow.log 的调用栈,看最顶层卡在哪个函数。外部请求、数据库、文件 IO 三类占九成。
- 查 MySQL 慢查询 + 锁等待,给高频 WHERE 条件补复合索引。
- 算 max_children,按可用内存除以单进程占用,宁小勿大。
iostat -x 1看磁盘,确认不是邻居盘的锅。- 开启 FPM status 页,实时看哪个脚本在耗时间。
这套方法的精髓是"用证据代替猜测"。PHP 性能问题最怕的就是凭感觉优化——把缓存全开、把参数调大、把插件删光,结果可能一点用都没有,还引入了新问题。slow log 加上一个 status 页,总共不到十分钟的配置,能让你在卡顿再次发生时直接拿到答案,这比任何经验主义都管用。
最后提一个心态上的建议:不要把"偶尔卡一下"当成必须消灭的敌人。如果一天只卡几次、每次几秒,而你的站没有商业 SLA,把时间花在写内容上通常比花在抠最后那点延迟上回报更高。运维是手段,不是目的。