PHP 代码性能分析

OxPHP 内置了一个按请求工作的性能分析器。与 xdebug 或独立扩展不同,它运行在服务器自身内部,无需重启 PHP,并且在禁用时不会带来任何实质性的开销(mode=Off 分支甚至在触及过滤器缓存之前就已退出)。

这是一份实用指南:从零配置开始,一直讲到追查生产环境中缓慢的端点、阅读火焰图,以及对比优化前后的运行结果。

快速上手:60 秒得到第一份性能分析结果

compose.yml
services: app: image: ghcr.io/oxphp/oxphp:0.10.0 environment: INTERNAL_ADDR: 0.0.0.0:9090 PROFILER_ENABLED: "true" PROFILER_AUTH_TOKEN: "dev-secret" PROFILER_OUTPUT_FORMATS: "xhprof,speedscope,collapsed" volumes: - ./www:/var/www/html - profiles:/tmp/oxphp-profiles ports: - "80:80" - "9090:9090" volumes: profiles:
bash
# 1. 携带性能分析触发条件请求一个页面。 curl -H "X-OxPHP-Profile: dev-secret" http://localhost/slow-endpoint # 2. 列出已捕获的运行记录。 curl -H "Authorization: Bearer dev-secret" http://localhost:9090/__profiler/runs \ | jq '.runs[0]' # 3. 在 speedscope 中打开性能分析结果(浏览器内的火焰图)。 open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"

就是这样。本指南的其余部分会解释实际发生了什么,以及如何把它变成生产环境的洞察。

性能分析器做什么

  • 捕获每一次 PHP 函数调用,通过 Zend Observer API——不修补字节码,不改动应用代码。
  • 构建一棵 span 树,包含挂钟时间、CPU 时间、进入/退出时的内存、属性和事件。
  • 同时导出四种格式xhprof.jsonspeedscope.jsonpprof(protobuf + gzip)、collapsed(用于 flamegraph.pl)。
  • 持久化运行记录:内存 LRU 缓存 + 磁盘文件 + 可选的 HTTP 推送(xhgui 或任意自定义收集器)。
  • 暴露 8 个内部 HTTP 路由(在 INTERNAL_ADDR 上),用于浏览和下载性能分析结果。
  • 发出 Prometheus 指标——按来源统计的运行数、收集的 span 数、写入字节数、丢弃数、推送失败数。
  • 无需重启——由触发条件按请求激活。

工作原理

graph TD
  R["请求"]
  R --> S1["Tokio 线程:ProfilerRequestHandler 检查触发条件<br/>(header / cookie / query / sample_rate),常量时间比较"]
  S1 --> S2["决策写入 PluginRequestActions,<br/>通过 SAPI 通道转发给工作进程"]
  S2 --> S3["在 RINIT 之前,工作进程设置 ProfilingMode = ProfileAll<br/>并为每个函数的开始/结束注册 Observer 处理器"]
  S3 --> S4["每次 PHP 函数调用 → C 钩子 → 桥接缓冲区 → Rust SpanTree<br/>(span_id = 单调递增 BE 计数器;名称在<br/>线程局部 interner 中驻留,无额外分配)"]
  S4 --> S5["响应之后:ProfilerCompleteHandler 接收 Arc&lt;SpanTree&gt;,<br/>运行 4 个导出器,将其放入 LRU 缓存,派生磁盘写入<br/>和 HTTP 推送任务(信号量限制扇出)"]

三种按请求的模式

模式 何时 捕获什么
Off 默认。没有插件请求性能分析。 什么都不捕获。零开销。
ApmOnly 启用了 plugin-apm,但没有匹配到性能分析器触发条件。 仅 APM 的显式钩子:#[Trace]、PDO/cURL 发射器、oxphp_trace_*()
ProfileAll 匹配到了性能分析器触发条件(或调用了 OxPHP\Profile\start())。 通过 Observer API 捕获的每一次 PHP 函数调用,外加 APM 收集的所有内容。

ProfileAll 优先于 ApmOnly:当两个插件都启用且触发条件匹配时,会使用单个共享的 Arc<SpanTree>——不会重复收集。

安装与构建

plugin-profiler 插件是默认 cargo features 的一部分。原生的 docker compose build 已经包含了它。

要禁用它:

Dockerfile
ARG OXPHP_WITH_PROFILER=0 # 或 ARG CARGO_FEATURES="plugin-apm,plugin-otel" # 不含 plugin-profiler

要验证该插件是否已编译进来:

bash
docker compose exec app cat /proc/self/maps | grep -i profiler # 或:oxphp --list-plugins(如果该命令可用)

激活触发条件

触发条件按以下优先级顺序检查:header → cookie → query → sample_rate。任何一个匹配都会激活 ProfileAll。token 以常量时间进行比较(subtle::ConstantTimeEq)。

用于开发和脚本。

bash
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkout

非常适合 CI 基准测试、Postman 集合、curl 脚本。

从采样中排除路径

PROFILER_SAMPLE_RATE 会对所有请求中的一个随机比例进行采样——包括污染数据的框架自身流量。Symfony 的 web 调试工具栏会轮询 /_wdt/{token} 并链接到 /_profiler/{token};Laravel Debugbar 和 Telescope 的行为也类似。用 PROFILER_EXCLUDE_PATHS 把这些排除在采样之外:

bash
PROFILER_EXCLUDE_PATHS=/_profiler,/_profiler/**,/_wdt/**

逗号分隔的 glob 模式,语法与 PHP_DENY_PATHS 相同:* 不跨越 /** 会跨越,开头的 / 是可选的。只有当你同时列出两者时,一个模式才能匹配裸路径以及其子树——/_profiler/** 能覆盖 /_profiler/x 但不能覆盖裸的 /_profiler,因此才有上面的双模式写法。模式匹配的是收到的原始请求路径——不做百分号解码或 .. 规范化——所以请列出你的框架实际使用的字面路径。

排除只影响自动采样

携带显式触发条件的请求——x-oxphp-profile 头、OXPROF cookie 或 __oxprof 查询参数——始终会被分析,即使是在被排除的路径上。这让你可以有意地分析 /_profiler 本身,同时把它排除在后台采样之外。

配置参考

变量 默认值 说明
PROFILER_ENABLED false 总开关。true → 加载插件。
PROFILER_AUTH_TOKEN (未设置) 触发条件的密钥,也是 /__profiler/* 路由的 bearer token。空字符串 = “无需 token”(任何非空触发值都能通过)。切勿将 token 提交到仓库。
PROFILER_SAMPLE_RATE 0.0 [0.0; 1.0]。随机采样率。
PROFILER_EXCLUDE_PATHS (未设置) PROFILER_SAMPLE_RATE 中排除的 CSV glob 模式(PHP_DENY_PATHS 语法)。显式触发条件仍会分析它们。示例:/_profiler,/_profiler/**,/_wdt/**
PROFILER_INTERNAL false 观测内部 C 函数(strlenjson_encode……)。覆盖全面,但有 2–5 倍开销。请精准地使用。
PROFILER_MAX_SPANS 50000 每个请求 span 树大小的硬上限。超过后,后续 span 会被标记为 truncated 且不会被写入。
PROFILER_MAX_DEPTH 256 栈深度的硬上限。
PROFILER_OUTPUT_DIR /tmp/oxphp-profiles 绝对路径。必须对 www-data 可写。
PROFILER_OUTPUT_FORMATS xhprof,speedscope xhprofspeedscopepprofcollapsed 的 CSV 子集。
PROFILER_RETENTION_COUNT 100 保留多少次运行记录(磁盘上和 LRU 中都是)。每 5 秒后台修剪一次。
PROFILER_DISK_MAX_PER_SEC 10 保护磁盘的令牌桶。溢出部分会被丢弃并使 oxphp_profiler_disk_drops_total 递增。
PROFILER_EXPORT_URL (未设置) 每次捕获的运行记录要 POST 到的 URL(xhgui、自定义收集器)。
PROFILER_EXPORT_FORMAT xhprof 用于 HTTP 推送的四种格式之一。
PROFILER_EXPORT_AUTH_TOKEN (未设置) 推送目标的 bearer token。
PROFILER_EXPORT_XHGUI auto 强制使用 xhgui 封装模式。auto:URL 路径以 /run/import 结尾(xhgui 的标准端点;不匹配 host/query 提示——对于非标准路径请将其设为 true)。
PROFILER_EXPORT_BUGGREGATOR auto 强制使用 Buggregator 封装。auto:URL 路径以 /api/profiler/store 结尾。该封装始终发出 xhprof,因此对它而言 PROFILER_EXPORT_FORMAT 会被忽略(非 xhprof 值只会告警,不会致命)。与 PROFILER_EXPORT_XHGUI 互斥(同时启用两者会导致启动错误)。
PROFILER_EXPORT_APP_NAME (未设置) Buggregator 的 app_name——性能分析结果归入的项目。
PROFILER_EXPORT_TAGS (未设置) Buggregator 的 tags,一个用于过滤的 key=value,key2=value2 列表。格式错误的项(不是 key=value)、空键或重复键都会导致启动错误。

生产环境配置示例

yaml
environment: PROFILER_ENABLED: "true" PROFILER_AUTH_TOKEN: "${PROFILER_TOKEN_FROM_VAULT}" PROFILER_SAMPLE_RATE: "0.001" # ~0.1% 的流量 PROFILER_INTERNAL: "false" PROFILER_OUTPUT_DIR: /var/lib/oxphp/profiles PROFILER_OUTPUT_FORMATS: "xhprof,collapsed" PROFILER_RETENTION_COUNT: "500" PROFILER_DISK_MAX_PER_SEC: "20" PROFILER_EXPORT_URL: "http://xhgui.monitoring.svc.cluster.local/run/import" PROFILER_EXPORT_FORMAT: "xhprof"

PHP SDK

所有函数都位于 OxPHP\Profile 命名空间中。它们始终可以安全调用:如果当前请求未激活性能分析,修改型函数会安全地空操作,而 is_active() 返回 false

围绕某个区域显式捕获

php
use function OxPHP\Profile\{start, stop, is_active}; function heavy_report(): array { start(); // 在请求内激活 ProfileAll $result = build_report(); // 这会进入 span 树 stop(); // 停止捕获 return $result; }

start() 是幂等的,stop() 也是:连续调用两次是安全的。

Warning

在请求中途调用 start()重置当前的 span 树(参见 php_sdk.rs 中的 PROFILING_CONTEXT.reset())。这符合规范的不变式:模式在每个请求中只设置一次,要么由 RINIT 处的触发条件设置,要么由第一次 start() 调用设置。

暂停与恢复

php
use function OxPHP\Profile\{pause, resume}; pause(); noisy_helper_we_dont_care_about(); // 不会进入 span 树 resume();

stop() 不同,pause/resume 是一种表示“临时”的表意信号。在内部它是同一个标志;这种区分只是为了帮助阅读代码的人。

打点标记:mark()

php
use function OxPHP\Profile\mark; mark('cache_miss'); mark('got_auth_token', ['user_id' => (string) $user->id]);

将一个 SpanEventKind::Mark 事件附加到最顶层打开的 span 上。没有打开的 span 时为空操作。适合在长函数中记录中间时间戳,或标记 if/else 分支。

数值指标:metric()

php
use function OxPHP\Profile\metric; $rows = $pdo->query('SELECT ...')->fetchAll(); metric('db.rows', (float) count($rows)); metric('payload.kb', strlen($body) / 1024.0);

metric.<name>=<value> 追加到当前 span 的属性中。与 mark() 不同,这是一个普通的键值对(没有时间戳)。它会在 speedscope/xhgui 中作为一个 span 属性显示。

状态检查:is_active()

php
if (OxPHP\Profile\is_active()) { // 可以承担一次昂贵的调试转储—— // 反正这个请求正在被分析 error_log(json_encode($debug_state)); }

两次 TLS 读取,没有 FFI。在热点代码中调用是安全的。

属性(PHP 8)

这七个属性分为两类:观测器过滤器在 span 创建之前运行;装饰器在 span 关闭之后运行。

属性 类别 作用
#[Profile] 过滤器 强制将该函数纳入 span 树(即使一般规则会将其排除)。
#[Exclude] 过滤器 跳过该函数;其子 span 会重新挂到最近的被纳入的祖先上。
#[Sample(rate: 0.1)] 过滤器 只保留一部分调用(rate ∈ [0.0; 1.0])。概率性——无锁。
#[Tag(key, value)] 过滤器 为 span 附加一个标签。可重复——多个 #[Tag] 会累加。
#[Mark(label?)] 装饰器 在函数进入时发出一个 Mark 事件。
#[SlowThreshold(ms)] 装饰器 当挂钟时间 ≥ ms 时,发出一个 Slow 事件并设置状态。
#[MemoryThreshold(kb)] 装饰器 当净分配 ≥ kb 时,发出 MemorySpike 并设置状态。

类与方法的组合

php
use OxPHP\Profile\{Tag, Profile, Exclude}; #[Tag(key: 'layer', value: 'domain')] #[Profile] // 整个类始终会被分析 class OrderService { #[Tag(key: 'op', value: 'create')] public function create(array $data): Order { /* ... */ } #[Exclude] // 被排除,尽管类级别有 #[Profile] public function debug_dump(): void { /* ... */ } public function find(int $id): ?Order { /* ... */ } // 继承 #[Profile]#[Tag(layer)] }
  • 类级别的属性会传播到每个方法。
  • 方法级别的属性会叠加到类级别之上(标签会累加)。
  • 方法上的 #[Exclude]覆盖类级别的 #[Profile]

慢函数阈值

php
use OxPHP\Profile\SlowThreshold; #[SlowThreshold(ms: 250)] function render_dashboard(User $u): string { // 如果运行时间 ≥ 250 ms——一个 Slow 事件会被追加到该 span,且 // status_code=2(错误)。在 xhgui / speedscope 中立即可见。 }

内存阈值

php
use OxPHP\Profile\MemoryThreshold; #[MemoryThreshold(kb: 512)] function import_csv(string $path): int { // 如果函数在执行期间净分配 ≥ 512 KB—— // MemorySpike 事件 + status=error }

对单个函数采样

php
use OxPHP\Profile\Sample; #[Sample(rate: 0.01)] function log_event(string $evt, array $ctx): void { // ≈ 1% 的调用会进入 span 树;其余的完全被跳过—— // span 及其子 span 都不会被创建。适用于每个请求中 // 被调用数百万次的函数。 }
过滤器还是装饰器?

当一个函数被调用得非常频繁、而你想降低捕获成本时,使用 #[Sample]#[Exclude](它们在 span 创建之前生效)。当你想标记超过某个阈值的事件时,使用 #[SlowThreshold] / #[MemoryThreshold](它们查看已经收集好的 span)。

一个已捕获的 span 包含什么

ruby
FinishedSpan { span_id # Arc<str>,兼容 W3C parent_span_id # Arc<str> trace_id # Arc<str>,与 APM 共享 name # 完全限定的 PHP 函数/方法名 start_ns # 挂钟时间,自性能分析器纪元起的纳秒数 end_ns cpu_ns # CLOCK_THREAD_CPUTIME_ID(平台不提供时为 0) memory_start # 进入时的 zend_memory_usage(0) memory_end # 退出时的 zend_memory_usage(0) attributes # Vec<(Arc<str>, Arc<str>)> —— 来自 #[Tag]、metric()、APM SQL/HTTP events # Vec<SpanEvent { ts, kind, label, attrs }> status_code # 0 = 未设置,1 = ok,2 = error status_message leaked # 如果 span 被 finalize 强制关闭则为 true(PHP 抛异常越过了 observer) }

事件类型SpanEvent::kind):

类型 由谁发出
Mark mark()metric()#[Mark]
Slow #[SlowThreshold]
MemorySpike #[MemoryThreshold]
Sql APM 钩子(PDO、mysqli)
Http APM 钩子(cURL、HTTP 流)
Exception APM 异常处理器
Alloc (为堆采样保留)
Other 兜底

导出格式

文件位于 PROFILER_OUTPUT_DIR 下,命名为 <run_id>.<ext>,其中 run_id = <ts_ms>-<req_id_prefix>-<rand4>(例如 1713600000000-a1b2c3d4-0f5e)。

speedscope(交互式分析的默认选项)

扩展名:.speedscope.json

  • 浏览器内的火焰图,支持缩放、搜索,以及 CPU / 时间 / 内存切换。
  • 零配置——直接在 speedscope.app 打开。
  • OxPHP 在 /__profiler/runs/{id}/speedscope 返回一个 302 重定向 → 指向带 profileURL=… 参数的 speedscope.app,该参数会直接从你的服务器获取性能分析结果。
bash
# 在 macOS 终端中 Ctrl-点击 / 在 Linux 上使用 xdg-open open "http://localhost:9090/__profiler/runs/<run_id>/speedscope"

xhprof(用于 xhgui:时间线和历史对比)

扩展名:.xhprof.json

  • 兼容 xhgui(URL 搜索、趋势、两次运行之间的对比)。
  • 非常适合生产环境积累:在你的应用旁边运行一个 xhgui 容器,将 PROFILER_EXPORT_URL=http://xhgui/run/import 指向它,历史记录就会在 UI 中不断累积。
  • 现成的 docker-compose:tests/compose.xhgui.yml

pprof(Google pprof 工具、Grafana pprof 插件、Pyroscope)

扩展名:.pprof(protobuf + gzip,fast 级别,zlib 后端)

bash
# 保存并打开 curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.pprof > profile.pprof go tool pprof -http=:8080 profile.pprof # 或 pyroscope-cli adhoc --input profile.pprof

collapsed(Brendan Gregg 的 flamegraph.pl)

扩展名:.collapsed

  • 文本格式 func;child;grandchild <count>
  • SVG 火焰图的事实标准输入。
  • 三种指标变体:挂钟时间、CPU、内存。OxPHP 写入 .collapsed (挂钟);内部路径还会产生 .collapsed.cpu.collapsed.mem (参见 tests/fixtures/profiler_exports/)。
bash
curl -H "Authorization: Bearer dev-secret" \ http://localhost:9090/__profiler/runs/<run_id>.collapsed \ | flamegraph.pl --title "Checkout $run_id" > flame.svg

Buggregator(本地调试服务器)

Buggregator 是一个单二进制的调试服务器,除其他功能外,它还能将 xhprof 性能分析结果渲染为按项目分组的火焰图。xhprof 推送直接以它的 POST /api/profiler/store 端点为目标:无需 xhprof PHP 扩展或客户端库,因为数据由 OxPHP 的原生性能分析器产生。

yaml
services: buggregator: image: ghcr.io/buggregator/server:latest ports: ["8000:8000"] app: image: ghcr.io/oxphp/oxphp:latest environment: PROFILER_ENABLED: "true" PROFILER_SAMPLE_RATE: "0.01" PROFILER_EXPORT_URL: "http://buggregator:8000/api/profiler/store" PROFILER_EXPORT_FORMAT: "xhprof" PROFILER_EXPORT_APP_NAME: "checkout" # 按项目对性能分析结果分组 PROFILER_EXPORT_TAGS: "env=staging,region=eu" # 可在 UI 中过滤

路径以 /api/profiler/store 结尾的 URL 会自动选择 Buggregator 封装(对于自定义 URL,PROFILER_EXPORT_BUGGREGATOR: "true" 会强制启用它;"false" 则退出)。该封装始终发出 xhprof,因此对它而言 PROFILER_EXPORT_FORMAT 会被忽略(非 xhprof 值会在启动时告警,但不致命——性能分析器绝不会因为一个导出选项而让服务器崩溃)。app_nametags 驱动 Buggregator 的项目分组和过滤;没有它们性能分析结果仍会渲染,但会落到未分组的状态。hostname 来自 $HOSTNAME,当该变量未设置时回退到 gethostname(2) 系统调用。

存储与清理

text
/tmp/oxphp-profiles/ ├── index.json # NDJSON —— 每行一条记录 ├── 1713600000000-a1b2c3d4-0f5e.xhprof.json ├── 1713600000000-a1b2c3d4-0f5e.speedscope.json └── 1713600001234-b2c3d4e5-4a2b.xhprof.json

index.json 条目 schema

json
{ "run_id": "1713600000000-a1b2c3d4-0f5e", "request_id": "a1b2c3d4e5f67890", "trace_id": "0af7651916cd43dd8448eb211c80319c", "timestamp_ms": 1713600000000, "duration_ms": 123, "method": "GET", "url": "/checkout", "status": 200, "user_agent": "Mozilla/5.0 …", "client_ip": "10.0.0.42", "source": "Header", // Header | Cookie | Query | SampleRate "span_count": 4821, "event_count": 7, "error_count": 0, "leaked_count": 0, "truncated": false, // true —— 超过了 PROFILER_MAX_SPANS "oxphp_version": "0.10.0", "formats": ["xhprof.json", "speedscope.json"] }

index.json/__profiler/runs 解析,按最新优先排序,并通过 ?limit=N&offset=M 分页。

保留策略

  • 一个每 5 秒运行的后台任务会删除超过 PROFILER_RETENTION_COUNT 的条目(原子 renameindex.json)。
  • 没有对应 index.json 条目的文件(因崩溃而成为孤儿)会被清扫。
  • PROFILER_DISK_MAX_PER_SEC 令牌桶保护磁盘:如果速率更高,运行记录就不会被写入,且 oxphp_profiler_disk_drops_total 递增。

内部 HTTP 路由

INTERNAL_ADDR=0.0.0.0:9090 时,插件会在 /__profiler/ 前缀下注册 8 个端点。当配置了 token 时,所有端点都要求 Authorization: Bearer <PROFILER_AUTH_TOKEN>。比较是常量时间的。

路由 方法 用途
/__profiler/ GET 带端点索引的 HTML 首页。
/__profiler/runs GET 运行记录的 JSON 数组。?limit=N&offset=M
/__profiler/runs/{id} GET 单次运行的 JSON 元数据。
/__profiler/runs/{id}.{format} GET 原始性能分析字节。formatxhprof.jsonspeedscope.jsonpprofcollapsed
/__profiler/runs/{id}/speedscope GET 302 → 带 profileURL=… 的 speedscope.app。
/__profiler/runs/{id} DELETE 删除所有格式文件 + 索引条目(返回 204)。
/__profiler/config GET 当前插件配置(token 已脱敏)。
/__profiler/stats GET JSON 计数器快照。

示例脚本

bash
# 最近 20 次运行中最慢的 5 个 curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:5]' # 某个 URL 的所有性能分析结果 curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=500" \ | jq '.runs[] | select(.url == "/checkout")' # 删除所有超过 1 小时的运行记录(独立于插件的保留策略) NOW=$(date +%s%3N) CUTOFF=$((NOW - 3600000)) curl -s -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs?limit=1000" \ | jq -r --arg c "$CUTOFF" '.runs[] | select(.timestamp_ms < ($c|tonumber)) | .run_id' \ | xargs -I{} curl -X DELETE -H "Authorization: Bearer $TOK" \ "http://localhost:9090/__profiler/runs/{}"

HTTP 推送与 xhgui

将每次运行发送到远程收集器:

yaml
environment: PROFILER_EXPORT_URL: "http://xhgui/run/import" PROFILER_EXPORT_FORMAT: "xhprof" PROFILER_EXPORT_AUTH_TOKEN: "shared-secret" # 可选
  • xhgui 封装自动检测:URL 路径以 /run/import 结尾(xhgui 的标准端点)。host 或 query 中模糊的 xhgui 子串不会被匹配——对于这类 URL,请通过 PROFILER_EXPORT_XHGUI=true|false 强制指定。
  • 重试计划:3 次尝试,指数退避 100/200/400 ms,总预算 5 秒挂钟时间。请求体在多次尝试之间以 bytes::Bytes 形式共享(重试零分配)。
  • 错误会使 oxphp_profiler_http_push_failures_total 递增。

完整演示栈

bash
docker compose -f tests/compose.xhgui.yml up -d # app: :80, xhgui: :8142 (UI), :27017 (mongo)

E2E 冒烟测试:tests/php/profiler/test_xhgui_import.php

Prometheus 指标

暴露在 /metrics 上:

text
oxphp_profiler_runs_total{source="header"|"cookie"|"query"|"sample"} oxphp_profiler_spans_collected_total oxphp_profiler_bytes_written_total{format="xhprof"|"speedscope"|"pprof"|"collapsed"} oxphp_profiler_disk_drops_total oxphp_profiler_http_push_failures_total oxphp_profiler_truncated_total oxphp_profiler_in_memory_runs

入门级 Prometheus 告警:

yaml
- alert: ProfilerDiskDrops expr: rate(oxphp_profiler_disk_drops_total[5m]) > 0 annotations: summary: "性能分析器正在磁盘上丢弃运行记录——请检查 PROFILER_DISK_MAX_PER_SEC" - alert: ProfilerPushFailing expr: rate(oxphp_profiler_http_push_failures_total[5m]) > 0 annotations: summary: "xhgui / 收集器不可达" - alert: ProfilerTruncatingTrees expr: rate(oxphp_profiler_truncated_total[5m]) > 0 annotations: summary: "请求超过了 PROFILER_MAX_SPANS——请提高上限或排查原因"

工作流

定位慢端点

  1. 在生产环境中开启 PROFILER_SAMPLE_RATE=0.001。让它积累数据。
  2. duration_ms 对运行记录排序:
    bash
    curl -s -H "Authorization: Bearer $TOK" \ "http://INT_ADDR/__profiler/runs?limit=500" \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10] | map({run_id, url, duration_ms, span_count})'
  3. 在 speedscope 中打开排在最前的那个:.../__profiler/runs/<id>/speedscope
  4. 在 speedscope 中启用 Left Heavy 模式——你会看到累计时间最多的函数。
  5. 点击最宽的条——获得 file:line 以及子函数列表。

验证优化前后的假设

  1. 在改动之前运行一次基准测试:
    bash
    for i in $(seq 1 20); do curl -s -H "X-OxPHP-Profile: dev-secret" http://localhost/api/report > /dev/null done curl -s -H "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=20" \ | jq '.runs | map(.duration_ms) | add / length' > /tmp/p50_before.txt
  2. 应用改动、重新构建、重复。对比中位数。
  3. 若需详细对比,下载两份 xhprof 性能分析结果并上传到 xhgui——它有内置的对比视图。

追查内存泄漏

  1. 发送那个会“不断增长”的请求:
    bash
    curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv
  2. 在 speedscope 中打开它,切换到内存指标(通过 .collapsed.mem 或 speedscope 的内存视图)。
  3. 在可疑函数上添加 #[MemoryThreshold(kb: 1024)]——下次运行时你会得到显式的 MemorySpike 事件。
  4. 使用 metric('mem.after', memory_get_usage()) 进行精准埋点。

持续监控关键路径

php
#[Profile] #[SlowThreshold(ms: 500)] public function chargeCard(PaymentRequest $r): PaymentResult { // 始终被捕获 + 滞后时会有一个显式的 Slow 标记 }

在 Grafana 中,为 oxphp_profiler_runs_total{source="sample"} 添加一个面板,并针对来自 index.jsonduration_ms 异常值设置告警(通过基于日志的指标或一个 sidecar 导出器)。

通过链接复现 bug

同事说“/admin/report 对我返回 500”。你回复:

text
https://app.local/admin/report?__oxprof=<one-time-token>

在他们访问之后——/__profiler/runs?limit=5,打开性能分析结果,就能准确看到异常落在了哪里(status_code=2 + Exception 事件)。

与 APM 的交互

  • 两个插件共享同一个 Arc<SpanTree>。不会重复收集。
  • 没有性能分析器触发条件 + 启用了 APM → mode=ApmOnly。span 树只包含显式标记的 span(#[Trace]、APM SQL/HTTP 钩子)。
  • 命中性能分析器触发条件 → mode=ProfileAll。span 树包含所有内容,外加 APM 注解。
  • APM 仍然将它的显式 span 发送到 OTLP(Jaeger/Tempo 每条 trace 上限约为 10k span)。要看全貌——/__profiler/runs/<id>

最佳实践

  1. 切勿提交 PROFILER_AUTH_TOKEN。从 Vault / Docker secrets / Kubernetes secrets 读取。
  2. 在生产环境中——只用 SAMPLE_RATE。header/cookie/query 是开发者工具。如果你需要按需进行生产环境分析——使用一个专用的、每日轮换的 token。
  3. 不要全局开启 PROFILER_INTERNAL=true。2–5 倍的开销会把生产环境变成实验室。请隔离开来精准使用。
  4. PROFILER_RETENTION_COUNT 保持现实 ——一次运行的大小可以从几百 KB(小请求)到数 MB(大 span 树)。500 次运行 × 2 MB = 1 GB。据此规划磁盘大小。
  5. 对嘈杂的辅助函数使用 #[Exclude](日志、i18n、自动加载器)——span 树在不丢失意义的前提下变得可读。
  6. 将性能分析结果关联到 tracetrace_id 是共享的。在 Grafana / Kibana 中,从 trace 视图链接到 /__profiler/runs/<id>
  7. 对 Git 友好的标识符。在此构建中,span_id 是一个确定性的大端单调计数器。对两份已存储的性能分析结果做 diff 很干净。
  8. APM + 性能分析器是免费的。保持两者都启用;span 树是共享的,开销仅来自 APM 累积的覆盖范围。

故障排查

没有出现任何性能分析结果
  1. 插件编译进来了吗?docker compose build 默认包含它。检查你没有传入 --build-arg OXPHP_WITH_PROFILER=0 或一个不含 plugin-profiler 的自定义 CARGO_FEATURES
  2. PROFILER_ENABLED=true 了吗?
  3. 触发条件真的匹配 PROFILER_AUTH_TOKEN 吗?
    • 检查环境变量中是否有多余的 \n
    • 对于 query——它做了正确的 URL 编码吗?
  4. 服务器到底有没有看到你的请求?检查访问日志。
/__profiler/runs 返回 401

头中的 bearer token 与 PROFILER_AUTH_TOKEN 不匹配。常见陷阱: echo "secret" > secret.txt 会追加一个 \n。请使用 printf 或通过环境变量传入。

xhgui 不显示新的运行记录
  1. 检查可达性:
    bash
    docker compose exec app curl -v $PROFILER_EXPORT_URL
  2. 查看 oxphp_profiler_http_push_failures_total
  3. 检查日志:每次失败时都会发出带 run_id 和 HTTP 状态的 tracing::warn!
磁盘上没有文件
  • PROFILER_OUTPUT_DIR绝对路径吗?相对路径会被忽略。
  • www-data 可写吗?
    bash
    docker compose exec app ls -la /tmp/oxphp-profiles
  • PROFILER_DISK_MAX_PER_SEC 是不是太低了?查看 oxphp_profiler_disk_drops_total
生产环境开销过大
  • PROFILER_INTERNAL=false(这是默认值)。
  • PROFILER_SAMPLE_RATE 处于合理范围(0.0005..0.002)。
  • 合理的 PROFILER_MAX_SPANS——超过后 span 树会被截断,但捕获仍在进行。对于非常大的请求,更推荐在关注的区段周围精准地使用 start()/stop()
index.json 中 truncated=true

某个请求超过了 PROFILER_MAX_SPANS(默认 50,000)。可选方案:

  1. 提高上限(用内存换取细节)。
  2. 在被调用数万次的函数上添加 #[Exclude] / #[Sample(rate: 0.01)]
  3. 只用 start()/stop() 包裹可疑区段。

命令速查表

bash
# 为一个请求激活 curl -H "X-OxPHP-Profile: $TOK" http://localhost/endpoint # 列出运行记录,按耗时排前 10 curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/runs \ | jq '.runs | sort_by(.duration_ms) | reverse | .[:10]' # 在 speedscope 中打开 open "http://localhost:9090/__profiler/runs/$RUN_ID/speedscope" # 下载为 xhprof 以导入 xhgui curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.xhprof.json > run.xhprof.json # 下载为 pprof 并打开 curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.pprof > run.pprof go tool pprof -http=:8080 run.pprof # flamegraph.pl curl -sH "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID.collapsed \ | flamegraph.pl > flame.svg # 删除一次运行 curl -X DELETE -H "Authorization: Bearer $TOK" \ http://localhost:9090/__profiler/runs/$RUN_ID # 指标 curl -s http://localhost:9090/metrics | grep oxphp_profiler_ # 当前插件配置(安全——token 已脱敏) curl -sH "Authorization: Bearer $TOK" http://localhost:9090/__profiler/config | jq

实用示例

下面是一些开箱即用的 PHP 场景,你可以把它们放进 www/public/ 并用 curl 访问。

带手动控制的简单控制器

www/public/report.php
<?php declare(strict_types=1); use function OxPHP\Profile\{start, stop, mark, metric, is_active}; function fetch_rows(PDO $db, int $user_id): array { $stmt = $db->prepare('SELECT * FROM orders WHERE user_id = ? LIMIT 1000'); $stmt->execute([$user_id]); return $stmt->fetchAll(PDO::FETCH_ASSOC); } function render_report(array $rows): string { $sum = array_sum(array_column($rows, 'amount')); return json_encode(['count' => count($rows), 'total' => $sum]); } $db = new PDO('mysql:host=db;dbname=app', 'app', 'secret'); $user_id = (int) ($_GET['user_id'] ?? 1); // 只显式分析这段繁重的代码块——即使触发条件未设置。 start(); mark('report.begin', ['user_id' => (string) $user_id]); $rows = fetch_rows($db, $user_id); metric('db.rows', (float) count($rows)); $body = render_report($rows); metric('response.bytes', (float) strlen($body)); mark('report.done'); stop(); header('Content-Type: application/json'); echo $body; // 可选——告知前端此请求已被分析: if (is_active()) { header('X-Profiled: 1'); }

调用:

bash
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'

带属性的服务类

www/lib/OrderService.php
<?php declare(strict_types=1); use OxPHP\Profile\{Profile, Tag, Exclude, Sample, SlowThreshold, MemoryThreshold}; #[Profile] #[Tag(key: 'layer', value: 'domain')] #[Tag(key: 'svc', value: 'orders')] final class OrderService { public function __construct( private readonly PDO $db, private readonly Mailer $mailer, ) {} #[SlowThreshold(ms: 250)] #[Tag(key: 'op', value: 'create')] public function create(array $payload): int { $this->db->beginTransaction(); try { $id = $this->insertOrder($payload); $this->insertLines($id, $payload['items']); $this->db->commit(); $this->mailer->sendReceipt($id); return $id; } catch (\Throwable $e) { $this->db->rollBack(); throw $e; } } #[MemoryThreshold(kb: 2048)] #[Tag(key: 'op', value: 'export')] public function exportCsv(int $user_id): string { $stmt = $this->db->prepare('SELECT * FROM orders WHERE user_id = ?'); $stmt->execute([$user_id]); $buf = fopen('php://temp', 'r+'); fputcsv($buf, ['id', 'created_at', 'total']); while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) { fputcsv($buf, [$row['id'], $row['created_at'], $row['total']]); } rewind($buf); return stream_get_contents($buf); } // 无关紧要的 getter——不要让 span 树变得杂乱。 #[Exclude] public function find(int $id): ?array { $stmt = $this->db->prepare('SELECT * FROM orders WHERE id = ?'); $stmt->execute([$id]); return $stmt->fetch(PDO::FETCH_ASSOC) ?: null; } // 非常频繁的审计——采样以避免让 span 树膨胀。 #[Sample(rate: 0.05)] private function audit(string $event, array $ctx): void { $this->db->prepare('INSERT INTO audit (event, ctx) VALUES (?, ?)') ->execute([$event, json_encode($ctx)]); } private function insertOrder(array $p): int { /* ... */ return 0; } private function insertLines(int $id, array $items): void { /* ... */ } }

批处理作业:在 N 次迭代中只分析第一次

www/bin/import.php
<?php declare(strict_types=1); use function OxPHP\Profile\{start, stop, pause, resume, mark}; $files = glob('/data/incoming/*.csv'); $i = 0; foreach ($files as $path) { if ($i === 0) { start(); // 只完整分析第一个文件 mark('batch.begin', ['path' => $path]); } else { pause(); // 其余的——对捕获而言是空操作 } import_one($path); if ($i === 0) { mark('batch.first_done'); stop(); } $i++; } function import_one(string $path): void { /* ... */ }

对比两种实现(带性能分析的微基准测试)

www/public/bench.php
<?php // 朴素实现 vs 流式实现的对比 declare(strict_types=1); use function OxPHP\Profile\{start, stop, mark, metric}; function naive_sum(string $path): int { $rows = array_map('str_getcsv', file($path)); // 整个文件读入内存 return array_sum(array_column($rows, 1)); } function streaming_sum(string $path): int { $h = fopen($path, 'r'); $total = 0; while (($row = fgetcsv($h)) !== false) { $total += (int) ($row[1] ?? 0); } fclose($h); return $total; } $path = '/data/big.csv'; $which = $_GET['impl'] ?? 'naive'; start(); mark('bench.begin', ['impl' => $which]); $t0 = hrtime(true); $result = $which === 'naive' ? naive_sum($path) : streaming_sum($path); $elapsed_ms = (hrtime(true) - $t0) / 1e6; metric('bench.elapsed_ms', $elapsed_ms); metric('bench.result', (float) $result); mark('bench.done'); stop(); echo json_encode(['impl' => $which, 'elapsed_ms' => $elapsed_ms, 'result' => $result]);

工作流:

bash
# 朴素实现 curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=naive" # 流式实现 curl -H "X-OxPHP-Profile: dev-secret" "http://localhost/bench.php?impl=streaming" # 在 xhgui 中对比(最近两次 xhprof 运行) curl -sH "Authorization: Bearer dev-secret" \ "http://localhost:9090/__profiler/runs?limit=2" | jq '.runs[] | .run_id'

生产代码中的条件性分析

php
<?php // 经典场景:某个可疑函数只对特定用户变慢。 declare(strict_types=1); use function OxPHP\Profile\{start, stop, is_active}; function charge(User $user, Money $amount): PaymentResult { // 审计:如果这个请求正在被分析, // 就在第三方调用内部启用额外日志。 $verbose = is_active(); $gateway = new StripeClient(verbose: $verbose); return $gateway->charge($user->id, $amount); } function oncall_path(Order $order): void { // 只分析 VIP 用户——无需外部触发条件。 if ($order->user->tier === 'vip') { start(); } process($order); if ($order->user->tier === 'vip') { stop(); } }

自我分析的集成测试

tests/php/profile_smoke.php
<?php declare(strict_types=1); require __DIR__ . '/test_helper.php'; use function OxPHP\Profile\{start, stop, mark, is_active}; $t = new TestCase('profile_smoke', 'my-app'); // 手动启用性能分析器(测试 SDK 无需触发条件)。 $t->assertFalse('initially not active', is_active()); start(); $t->assertTrue('active after start', is_active()); mark('test.midpoint'); // 一些工作 $sum = 0; for ($i = 0; $i < 100_000; $i++) { $sum += $i; } stop(); $t->assertFalse('inactive after stop', is_active()); $t->assertSame('computation OK', $sum, 4999950000); $t->done();

从一系列 Postman 请求中定位热点

场景:“/api/search 有时慢,不是一直慢。”

javascript
// Postman 预请求脚本 pm.request.headers.add({ key: 'X-OxPHP-Profile', value: pm.environment.get('PROFILE_TOKEN') });

跑完 100 次后,用一行 jq 找出最异常的几个:

bash
curl -sH "Authorization: Bearer $TOK" \ "http://int.app.local:9090/__profiler/runs?limit=200" \ | jq -r '.runs | map(select(.url | startswith("/api/search"))) | sort_by(-.duration_ms) | .[:5] | map("\(.duration_ms)ms \(.run_id) \(.url)") | .[]'

一个为性能分析器供给数据的自定义装饰器

你自己的 #[ProfileDb]——记录行数并自动调用 metric('db.rows', …)

php
<?php use OxPHP\Decorator\{AttributeInterface, Context}; use function OxPHP\Profile\metric; #[Attribute(Attribute::TARGET_METHOD)] class ProfileDb implements AttributeInterface { public function before(Context $ctx): void {} public function after(Context $ctx): void { $result = $ctx->returnValue; if (is_array($result)) { metric('db.rows', (float) count($result)); } elseif ($result instanceof PDOStatement) { metric('db.rows', (float) $result->rowCount()); } } } oxphp_register_decorator(ProfileDb::class); class UserRepository { #[ProfileDb] public function findAll(): array { /* ... */ return []; } }

装饰器 + 性能分析器的组合开箱即用:metric() 会自动附加到 Observer API 当前正在观测的那个函数的 span 上。

参考资料