先给结论:PHP 性能监控要回答的不是"服务器还活着吗",而是"这次请求的时间花在哪了"。前者看系统指标就够,后者必须做请求级的时间拆解。
遇到过一个很典型的情况:商品列表页从 1 秒拖到 8 秒,而 CPU 不到 30%、内存充裕、PHP 错误日志里一条 Fatal 都没有。把所有看板翻了一遍没找到线索,最后把响应时间拆开才定位到------单次请求对数据库发起了 200 多次查询,典型 N+1。开发环境数据量小,这问题根本不会暴露。
核心要点
- 先做时间拆解,再谈优化:数据库 / 外部调用 / PHP 逻辑 / 网络各占多少,决定了下一步打开哪个界面。
- 均值会骗人:用户投诉的慢几乎都落在 P95 / P99 上,而均值往往完全正常。
- PHP 有两个盲区不在系统指标里:OPcache 命中率下降、PHP-FPM 进程池排队。
- Notice / Warning 比 Fatal 更值得盯,大量性能问题会先以告警形式出现。
- 函数级剖析是定位手段不是监控手段,开销决定它只能按需开启。
一、五层归因模型
"页面慢"这三个字本身没有信息量,先把它落到某一层。
| 层 | 典型表现 | 该看什么 |
|---|---|---|
| 前端与网络 | 服务端耗时正常但页面整体慢;地域差异明显 | 真实用户监控、TTFB / DNS / TLS 时间线 |
| Web 服务器 · 进程池 | 高峰期排队超时,低峰期正常 | 排队请求数、达到进程上限的次数 |
| PHP 运行时 | 同一段代码忽快忽慢;首次请求明显更慢 | OPcache 命中率与内存、内存峰值 |
| 应用代码 | 特定接口慢且可稳定复现 | 函数级剖析、调用栈耗时占比 |
| 数据层 | 耗时随数据量增长;查询次数异常 | 慢查询日志、执行计划、单请求查询次数 |
二、六个指标
指标 1:响应时间分解
"总耗时 1.2 秒"无法指导行动,但"数据库 900ms + 外部接口 200ms + 应用逻辑 100ms"可以直接告诉你往哪走。做法是在统一入口埋计时点,把每段耗时归类。
php
<?php
// 请求级计时:中间件在入口 start(),在出口把 report() 落日志
final class RequestTimer
{
private static array $segments = [];
private static float $t0 = 0.0;
public static function start(): void { self::$t0 = microtime(true); }
public static function span(string $name, callable $fn)
{
$s = microtime(true);
try { return $fn(); }
finally { self::$segments[$name] = (self::$segments[$name] ?? 0) + (microtime(true) - $s); }
}
public static function report(): array
{
$total = microtime(true) - self::$t0;
$accounted = array_sum(self::$segments);
return [
'total_ms' => round($total * 1000, 1),
'db_ms' => round((self::$segments['db'] ?? 0) * 1000, 1),
'http_ms' => round((self::$segments['http'] ?? 0) * 1000, 1),
'cache_ms' => round((self::$segments['cache'] ?? 0) * 1000, 1),
// 未归类时间 ≈ 应用自身逻辑耗时(含框架开销)
'app_ms' => round(($total - $accounted) * 1000, 1),
];
}
}
"应用自身耗时" = 总耗时 − 已归类时间,里面混着框架开销。看比例判断:长期低于 10%,优化重心就该放到数据库或外部依赖上。
指标 2:吞吐量必须和错误率成对看
错误率上升时,失败请求返回得很快,吞吐量反而会"变好"。只看 QPS 会得出完全相反的结论。
指标 3:错误与异常跟踪
除了 Fatal,更要按类型和频率统计 Notice / Warning。很多性能问题------未定义索引、隐式类型转换、废弃函数调用------会先以告警形式高频出现,等它升级成 Fatal,业务已经受损了。
指标 4:查询监控与 N+1
慢查询日志只能抓"单条慢",抓不到"每条都不慢但加起来很慢",所以要单独统计单请求查询次数。
sql
-- 按接口统计分位数与平均查询次数(时序库方言按需调整)
SELECT route,
count(*) AS req_cnt,
round(avg(duration_ms), 1) AS avg_ms,
round(percentile_cont(0.95) WITHIN GROUP (ORDER BY duration_ms), 1) AS p95_ms,
round(percentile_cont(0.99) WITHIN GROUP (ORDER BY duration_ms), 1) AS p99_ms,
round(avg(queries), 1) AS avg_queries
FROM request_log
WHERE ts >= now() - interval '24 hours'
GROUP BY route
HAVING count(*) > 100 -- 样本太少的接口不参与判断
ORDER BY p99_ms DESC -- 按长尾排序,而不是按均值
LIMIT 30;
按"查询次数"排序接口,往往比按"耗时"排序更早发现隐患。
指标 5:资源消耗------进程池与 OPcache
这两个是 CPU / 内存指标完全看不见的瓶颈,展开在下一节。
指标 6:函数级性能剖析
产出是调用栈耗时占比,用于回答"到底慢在哪个函数"。实现分两条路:采样开销低、可低频用于生产;插桩精确但明显更重。无论哪种,都要用开关控制开启范围与时长。
三、两个系统指标看不到的盲区
PHP-FPM 进程池排队。 打开状态页看三个字段就够:listen queue(当前排队数,持续大于 0 就是信号)、max active processes(等于 total 说明进程池被打满过)、max children reached(达到上限的次数,最关键的一个)。注意状态页务必限制来源 IP,别暴露公网。
OPcache 命中率。 用 opcache_get_status() 取 hits / misses 算命中率。长期低于 95%,或 oom_restarts、hash_restarts 持续增长,说明内存或文件数配置需要调整。此时 CPU 曲线依然很平静,但每个请求都在重新编译。
顺带一提长驻进程的内存。 队列消费者、常驻服务这类进程不会被请求生命周期回收,如果内存曲线呈单调上升,基本可以判定存在泄漏或缓存无上限增长。它同样不体现在机器内存使用率上------机器内存足够大时,这点增长会被稀释到看不出来。
四、落地顺序
- 第 1 周: 统一入口埋点,把响应时间分解落地,日志里记录耗时构成。
- 第 2 周: 接进程池状态页与 OPcache 命中率,补上两个盲区。
- 第 3 周起: 统计单请求查询次数与按接口分位数,把排序口径从均值换成 P99。
常见问题(FAQ)
Q1:响应时间分解一定要改代码吗?
不一定。可以自己埋点(可控但要改代码),也可以由探针在数据库、HTTP 客户端等位置自动打断点(开箱即用但依赖覆盖度)。建议先用最外层计时算出"应用自身占比",再决定往哪一层细化。
Q2:查询次数多少算异常?
没有绝对阈值,看趋势。同一个接口今天 5 次、下个月 200 次,就是明确信号;会随数据量增长的,基本都是 N+1。
Q3:OPcache 命中率多少算正常?
长期应稳定在 95% 以上。持续偏低,或内存不足重启、哈希表重启的次数在增长,通常是内存或文件数配置问题。
Q4:剖析能常开吗?
不建议。剖析本身消耗资源并污染观测数据,只应在能复现问题时短时开启,定位完立刻关闭。
参考来源
- PHP 官方手册------PHP-FPM 配置、OPcache、错误处理章节
- MySQL 官方文档------慢查询日志与 EXPLAIN 输出说明
- Google SRE Book------Monitoring Distributed Systems
- 厂商实现文档(仅作技术对照,非推荐):Applications Manager 帮助文档
一句话总结
先把请求时间拆开,再补上进程池、OPcache、查询次数三个盲区指标,最后把排序口径从均值改成 P99------PHP 性能问题的定位效率会有质变。
利益相关:本文作者从事 IT 运维相关领域工作。文中方法论均为通用工程实践,不构成采购建议;参考来源中列出的厂商实现文档仅作技术对照之用。