本文给出 PHP 性能监控的工程实现:响应时间埋点、进程池与 OPcache 指标采集、查询次数统计、按需剖析。代码可直接参考改造,不依赖任何特定商业产品。
核心要点
- 响应时间分解靠统一入口埋点,先落地这一件事
- 进程池与 OPcache 要单独采集,它们不在系统指标里
- 查询次数是发现 N+1 的关键指标,比慢查询日志更灵敏
- 剖析的开启要用开关控制,避免常开
- 分位数延迟按接口统计,不要看全局均值
一、响应时间分解:统一入口埋点
不管用什么框架,都要在请求处理链路的最外层加一个计时器,并在关键出口(数据库、HTTP 客户端、缓存)记账。下面是一个最小实现:
<?php
// profiler.php ------ 极简分段计时器,放入框架中间件或入口文件
final class RequestTimer
{
private static array $marks = [];
private static float $start = 0.0;
private static array $segments = ['db' => 0.0, 'http' => 0.0, 'cache' => 0.0];
public static function start(): void
{
self::$start = microtime(true);
self::$marks = [];
self::$segments = ['db' => 0.0, 'http' => 0.0, 'cache' => 0.0];
}
/** 包裹一次外部调用,自动累计到对应分段 */
public static function span(string $segment, callable $fn)
{
$t0 = microtime(true);
try {
return $fn();
} finally {
$dt = microtime(true) - $t0;
if (isset(self::$segments[$segment])) {
self::$segments[$segment] += $dt;
}
}
}
/** 输出本次请求的时间分布 */
public static function report(): array
{
$total = microtime(true) - self::$start;
$accounted = array_sum(self::$segments);
return [
'total_ms' => round($total * 1000, 1),
'db_ms' => round(self::$segments['db'] * 1000, 1),
'http_ms' => round(self::$segments['http'] * 1000, 1),
'cache_ms' => round(self::$segments['cache'] * 1000, 1),
// 未归类时间 ≈ 应用自身逻辑耗时(含框架开销)
'app_ms' => round(($total - $accounted) * 1000, 1),
];
}
}
// 使用示例
// RequestTimer::start();
// $rows = RequestTimer::span('db', fn() => $pdo->query($sql)->fetchAll());
// $out = RequestTimer::span('http', fn() => $client->get($url));
// error_log(json_encode(RequestTimer::report(), JSON_UNESCAPED_UNICODE));
工程提示:
总耗时 - 已归类时间得到的"应用自身耗时"里混着框架开销。判断标准看比例:如果应用自身占比长期低于 10%,优化重心应该在数据库或外部依赖上。
二、进程池指标:从状态页到监控系统
进程池的排队情况是"已经在恶化但系统指标正常"的最典型来源。先打开状态页,再决定要不要接监控系统。
; php-fpm.conf ------ 打开状态页(务必限制来源 IP,别暴露公网)
[www]
pm.status_path = /_fpm_status
ping.path = /_fpm_ping
; Nginx 侧只允许内网访问
; location = /_fpm_status { allow 10.0.0.0/8; deny all; fastcgi_pass ...; }
# 抓取状态页关键字段(命令行快速核对)
$ curl -s http://127.0.0.1/_fpm_status?full | head -20
pool: www
process manager: dynamic
accepted conn: 1284392 # 累计接受连接
listen queue: 0 # 当前排队数 ------ 持续 > 0 就是信号
max listen queue: 37 # 历史最大排队数
listen queue len: 511
idle processes: 6
active processes: 14
total processes: 20
max active processes: 20 # 等于 total 说明进程池被打满过
max children reached: 12 # 达到上限的次数 ------ 最关键的指标
接监控系统时,这几个字段就是自定义指标:
# opcache_status.php ------ 采集 OPcache 命中率(命令行或定时任务调用)
<?php
$s = opcache_get_status(false);
if (!$s || empty($s['opcache_enabled'])) { echo "opcache disabled\n"; exit(1); }
$hits = $s['opcache_statistics']['hits'];
$misses = $s['opcache_statistics']['misses'];
$total = $hits + $misses;
$rate = $total ? round($hits / $total * 100, 2) : 0;
printf("opcache_hit_rate %.2f\n", $rate);
printf("opcache_memory_used_mb %.1f\n", $s['memory_usage']['used_memory'] / 1048576);
printf("opcache_memory_free_mb %.1f\n", $s['memory_usage']['free_memory'] / 1048576);
printf("opcache_wasted_pct %.1f\n", $s['memory_usage']['wasted_memory']
/ ($s['memory_usage']['used_memory'] + $s['memory_usage']['free_memory']) * 100);
printf("opcache_restarts %d\n", $s['opcache_statistics']['oom_restarts']
+ $s['opcache_statistics']['hash_restarts']);
// 要点:命中率长期低于 95%、或 oom_restarts / hash_restarts 持续增长,
// 都说明 OPcache 配置(内存或文件数)需要调整------这正是系统指标看不到的瓶颈。
三、查询次数:发现 N+1 的关键指标
慢查询日志只能抓到"单条慢",抓不到"很多条都不慢但加在一起很慢"。所以必须单独统计单请求查询次数。
<?php
// db_counter.php ------ 包装 PDO 统计单请求内的查询次数
final class CountingPdo
{
private \PDO $pdo;
private int $count = 0;
private float $time = 0.0;
public function __construct(\PDO $pdo) { $this->pdo = $pdo; }
public function query(string $sql, array $params = []): array
{
$t0 = microtime(true);
$st = $this->pdo->prepare($sql);
$st->execute($params);
$rows = $st->fetchAll(\PDO::FETCH_ASSOC);
$this->count++;
$this->time += microtime(true) - $t0;
return $rows;
}
public function stats(): array
{
return ['queries' => $this->count, 'db_ms' => round($this->time * 1000, 1)];
}
}
// 输出示例:
// queries=3 db_ms=12.4 -> 正常
// queries=214 db_ms=890.2 -> 典型 N+1,需要预加载 / 批量查询
//
// 用法:在请求结束时把 stats() 记进日志,然后按"查询次数"排序接口,
// 比按"耗时"排序更容易提前发现隐患。
四、按需剖析:用开关控制,不要常开
函数级剖析的产出是调用栈耗时占比。实现上分采样与插桩:采样开销低、适合低频用于生产;插桩精确但明显更重。无论哪种,都要用开关控制开启范围与时长。
; php.ini ------ 剖析相关配置(示意,按你使用的剖析扩展调整)
; xdebug 4 用 mode 控制,生产环境默认关闭
xdebug.mode = off
xdebug.start_with_request = trigger ; 由请求触发而非全部请求
xdebug.trigger_value = PHP_PROFILE ; 触发值,配合请求头/参数使用
xdebug.output_dir = /tmp/xdebug
xdebug.profiler_output_name = cachegrind.out.%p
# 只对特定请求开启剖析:先设触发值,再打一次目标接口
$ curl -s -H "Cookie: XDEBUG_TRIGGER=PHP_PROFILE" \
"http://127.0.0.1/api/orders?page=1" > /dev/null
# 拿到 cachegrind 文件后,本地渲染成火焰图 / 调用树再分析
$ ls -lh /tmp/xdebug/
$ pyprof2calltree -i /tmp/xdebug/cachegrind.out.12345 -k # 用可视化工具打开
# 纪律:
# 1) 剖析文件可能很大,用完及时清理;
# 2) 只在能复现问题时开启,定位完立刻关闭;
# 3) 不要在生产上对全部请求常开------它会成为新的瓶颈,并污染观测数据。
五、分位数延迟:按接口统计
-- 按接口统计分位数(示意,具体函数按你的时序库调整)
SELECT
route,
count(*) AS req_cnt,
round(avg(duration_ms), 1) AS avg_ms,
round(percentile_cont(0.50) WITHIN GROUP (ORDER BY duration_ms), 1) AS p50_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;
-- 关注两件事:
-- 1) p99 与 p50 的差距(差距大 = 长尾严重,用户体感差)
-- 2) avg_queries 是否随数据量增长(增长 = N+1)
六、落地顺序
- **第 1 周:**统一入口埋点,把响应时间分解做出来,并在日志里记录"分解后的耗时构成"。
- **第 2 周:**接进程池状态页与 OPcache 命中率,补上系统指标看不到的两个盲区。
- **第 3 周起:**统计单请求查询次数与按接口的分位数延迟,把"按均值排序"改成"按 P99 排序"。
常见问题(FAQ)
Q1:响应时间分解一定要改代码吗?
不一定。可以在统一入口自己埋点(可控但要改代码),也可以由探针在数据库、HTTP 客户端等位置自动打断点(开箱即用但依赖覆盖度)。建议先用最外层计时把"应用自身耗时占比"算出来,再决定往哪一层细化。
Q2:查询次数多少算异常?
没有绝对阈值,看趋势。同一个接口今天 5 次、下个月 200 次,就是明确信号。判断方法是看查询次数是否随数据量增长------会增长的基本都是 N+1。
Q3:OPcache 命中率多少算正常?
长期应稳定在 95% 以上。如果持续偏低,或内存不足重启、哈希表重启的次数在增长,说明内存或文件数配置需要调整。这类问题在系统 CPU、内存指标上完全看不出来。
Q4:剖析开销大吗,能常开吗?
采样方式开销低,可低频短时开启;插桩方式开销明显。都不建议在生产环境长期常开,因为剖析本身会消耗资源并污染观测数据。
Q5:分位数统计为什么要按接口而不是全局?
全局分位数会被请求量大的快接口稀释,掩盖真正慢的接口。按接口统计再按 P99 排序,才能真正找到影响用户的那批请求。
参考来源
- PHP 官方手册------PHP-FPM 配置、错误处理、OPcache 与内存管理章节
- MySQL 官方文档------慢查询日志与 EXPLAIN 输出说明
- Google SRE Book------Monitoring Distributed Systems
- OpenTelemetry 规范------Trace 与 Metric 数据模型
- 厂商实现文档(仅作技术对照,非推荐):Applications Manager 帮助文档
一句话总结
先落地"响应时间分解"这一件事,再补上进程池、OPcache、查询次数三个盲区指标,最后把排序口径从均值改成 P99------PHP 性能问题的定位效率会有质变。
如果这篇对你有帮助,欢迎点赞收藏,也欢迎在评论区聊聊你们是怎么定位慢接口的。
利益相关:本文作者从事 IT 运维相关领域工作。文中方法论均为通用工程实践,不构成采购建议;参考来源中列出的厂商实现文档仅作技术对照之用。