PHP性能监控怎么做?从响应时间到慢函数的6个关键指标

本文给出 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. **第 1 周:**统一入口埋点,把响应时间分解做出来,并在日志里记录"分解后的耗时构成"。
  2. **第 2 周:**接进程池状态页与 OPcache 命中率,补上系统指标看不到的两个盲区。
  3. **第 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 运维相关领域工作。文中方法论均为通用工程实践,不构成采购建议;参考来源中列出的厂商实现文档仅作技术对照之用。

相关推荐
Wang's Blog1 小时前
Java框架 SpringCloud 快速入门: Nacos 配置管理之添加配置与 dataId 命名规范
java·开发语言·spring cloud
AR-26710-1 小时前
Linux Day16——日志
linux·运维
andxe1 小时前
800G OSFP封装对比:OSFP IHS 与 RHS 封装差异
网络·光模块·光通信
阿俊-全栈开发1 小时前
LikeShop单商户Java商城如何从容承接高并发流量?
java·开发语言·spring boot·spring·系统架构
云樱梦海1 小时前
5 分钟上手 IndexTTS 2.5 便携包:不用装 Python、不挑显卡、本地离线跑语音克隆
开发语言·python·tts·indextts2.5
米糕闯编程1 小时前
鱼香ros2(十二)C++话题订阅与发布
开发语言·c++·ros2·话题
脉动数据行情11 小时前
Go 语言实战|对接美股英伟达 NVDA 实时行情 生产级 WebSocket 方案
开发语言·websocket·golang
小HANN1 小时前
华为云企业网站上云实战|从零搭建高可用WordPress(ECS+RDS+ELB+弹性伸缩+云监控全流程落地)
linux·运维·服务器·经验分享
FLJwu1 小时前
运动相机按键与连接器硬件选型实战02:USB‑C接口选型|防水失效、虚接断连、腐蚀烧口量产大坑
c语言·开发语言·经验分享·数码相机·相机·运动相机·oem 量产