PHP 代码性能分析
OxPHP 内置了一个按请求工作的性能分析器。与 xdebug 或独立扩展不同,它运行在服务器自身内部,无需重启 PHP,并且在禁用时不会带来任何实质性的开销(mode=Off 分支甚至在触及过滤器缓存之前就已退出)。
这是一份实用指南:从零配置开始,一直讲到追查生产环境中缓慢的端点、阅读火焰图,以及对比优化前后的运行结果。
快速上手:60 秒得到第一份性能分析结果
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:# 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.json、speedscope.json、pprof(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<SpanTree>,<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 已经包含了它。
要禁用它:
ARG OXPHP_WITH_PROFILER=0
# 或
ARG CARGO_FEATURES="plugin-apm,plugin-otel" # 不含 plugin-profiler要验证该插件是否已编译进来:
docker compose exec app cat /proc/self/maps | grep -i profiler
# 或:oxphp --list-plugins(如果该命令可用)激活触发条件
触发条件按以下优先级顺序检查:header → cookie → query →
sample_rate。任何一个匹配都会激活 ProfileAll。token 以常量时间进行比较(subtle::ConstantTimeEq)。
用于开发和脚本。
curl -H "X-OxPHP-Profile: dev-secret" https://app.local/checkout非常适合 CI 基准测试、Postman 集合、curl 脚本。
用于浏览器调试。通过 DevTools 或扩展在浏览器中设置该 cookie:
OXPROF=dev-secret; Domain=app.local; Path=/只要该 cookie 有效,每个请求都会被分析。删除它即可停止。适合走一遍用户场景(打开商品 → 加入购物车 → 结账)并收集一系列性能分析结果。
用于分享链接。
https://app.local/admin/report?__oxprof=dev-secret最简单粗暴的方法,但当你想给同事发一个像“打开这个,它能复现 bug”这样的链接时很方便。注意:该参数会出现在访问日志和 Referer 中,所以不要用生产环境 token 这么做。
用于生产环境。
PROFILER_SAMPLE_RATE=0.001 # ≈ 每 1000 个请求采样 1 个无需 token 即可运行。在生产环境中开启它,以针对真实流量积累统计数据。一个不错的起始范围是 0.0005..0.002;更高的值会产生明显的开销,尤其是在 PROFILER_INTERNAL=true 时。
从采样中排除路径
PROFILER_SAMPLE_RATE 会对所有请求中的一个随机比例进行采样——包括污染数据的框架自身流量。Symfony 的 web 调试工具栏会轮询
/_wdt/{token} 并链接到 /_profiler/{token};Laravel Debugbar 和 Telescope 的行为也类似。用 PROFILER_EXCLUDE_PATHS 把这些排除在采样之外:
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 函数(strlen、json_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 |
xhprof、speedscope、pprof、collapsed 的 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)、空键或重复键都会导致启动错误。 |
生产环境配置示例
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。
围绕某个区域显式捕获
use function OxPHP\Profile\{start, stop, is_active};
function heavy_report(): array
{
start(); // 在请求内激活 ProfileAll
$result = build_report(); // 这会进入 span 树
stop(); // 停止捕获
return $result;
}start() 是幂等的,stop() 也是:连续调用两次是安全的。
在请求中途调用 start() 会重置当前的 span 树(参见
php_sdk.rs 中的 PROFILING_CONTEXT.reset())。这符合规范的不变式:模式在每个请求中只设置一次,要么由 RINIT 处的触发条件设置,要么由第一次
start() 调用设置。
暂停与恢复
use function OxPHP\Profile\{pause, resume};
pause();
noisy_helper_we_dont_care_about(); // 不会进入 span 树
resume();与 stop() 不同,pause/resume 是一种表示“临时”的表意信号。在内部它是同一个标志;这种区分只是为了帮助阅读代码的人。
打点标记:mark()
use function OxPHP\Profile\mark;
mark('cache_miss');
mark('got_auth_token', ['user_id' => (string) $user->id]);将一个 SpanEventKind::Mark 事件附加到最顶层打开的 span 上。没有打开的 span 时为空操作。适合在长函数中记录中间时间戳,或标记 if/else 分支。
数值指标:metric()
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()
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 并设置状态。 |
类与方法的组合
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]。
慢函数阈值
use OxPHP\Profile\SlowThreshold;
#[SlowThreshold(ms: 250)]
function render_dashboard(User $u): string
{
// 如果运行时间 ≥ 250 ms——一个 Slow 事件会被追加到该 span,且
// status_code=2(错误)。在 xhgui / speedscope 中立即可见。
}内存阈值
use OxPHP\Profile\MemoryThreshold;
#[MemoryThreshold(kb: 512)]
function import_csv(string $path): int
{
// 如果函数在执行期间净分配 ≥ 512 KB——
// MemorySpike 事件 + status=error
}对单个函数采样
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 包含什么
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,该参数会直接从你的服务器获取性能分析结果。
# 在 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 后端)
# 保存并打开
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.pprofcollapsed(Brendan Gregg 的 flamegraph.pl)
扩展名:.collapsed
- 文本格式
func;child;grandchild <count>。 - SVG 火焰图的事实标准输入。
- 三种指标变体:挂钟时间、CPU、内存。OxPHP 写入
.collapsed(挂钟);内部路径还会产生.collapsed.cpu和.collapsed.mem(参见tests/fixtures/profiler_exports/)。
curl -H "Authorization: Bearer dev-secret" \
http://localhost:9090/__profiler/runs/<run_id>.collapsed \
| flamegraph.pl --title "Checkout $run_id" > flame.svgBuggregator(本地调试服务器)
Buggregator 是一个单二进制的调试服务器,除其他功能外,它还能将 xhprof 性能分析结果渲染为按项目分组的火焰图。xhprof 推送直接以它的 POST /api/profiler/store 端点为目标:无需 xhprof PHP 扩展或客户端库,因为数据由 OxPHP 的原生性能分析器产生。
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_name 和 tags
驱动 Buggregator 的项目分组和过滤;没有它们性能分析结果仍会渲染,但会落到未分组的状态。hostname 来自 $HOSTNAME,当该变量未设置时回退到 gethostname(2) 系统调用。
存储与清理
/tmp/oxphp-profiles/
├── index.json # NDJSON —— 每行一条记录
├── 1713600000000-a1b2c3d4-0f5e.xhprof.json
├── 1713600000000-a1b2c3d4-0f5e.speedscope.json
└── 1713600001234-b2c3d4e5-4a2b.xhprof.jsonindex.json 条目 schema
{
"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的条目(原子rename→index.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 | 原始性能分析字节。format ∈ xhprof.json、speedscope.json、pprof、collapsed。 |
/__profiler/runs/{id}/speedscope |
GET | 302 → 带 profileURL=… 的 speedscope.app。 |
/__profiler/runs/{id} |
DELETE | 删除所有格式文件 + 索引条目(返回 204)。 |
/__profiler/config |
GET | 当前插件配置(token 已脱敏)。 |
/__profiler/stats |
GET | JSON 计数器快照。 |
示例脚本
# 最近 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
将每次运行发送到远程收集器:
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递增。
完整演示栈
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 上:
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 告警:
- 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——请提高上限或排查原因"工作流
定位慢端点
- 在生产环境中开启
PROFILER_SAMPLE_RATE=0.001。让它积累数据。 - 按
duration_ms对运行记录排序: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})' - 在 speedscope 中打开排在最前的那个:
.../__profiler/runs/<id>/speedscope。 - 在 speedscope 中启用 Left Heavy 模式——你会看到累计时间最多的函数。
- 点击最宽的条——获得 file:line 以及子函数列表。
验证优化前后的假设
- 在改动之前运行一次基准测试:
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 - 应用改动、重新构建、重复。对比中位数。
- 若需详细对比,下载两份 xhprof 性能分析结果并上传到 xhgui——它有内置的对比视图。
追查内存泄漏
- 发送那个会“不断增长”的请求:
curl -H "X-OxPHP-Profile: dev-secret" http://localhost/import?file=big.csv - 在 speedscope 中打开它,切换到内存指标(通过
.collapsed.mem或 speedscope 的内存视图)。 - 在可疑函数上添加
#[MemoryThreshold(kb: 1024)]——下次运行时你会得到显式的MemorySpike事件。 - 使用
metric('mem.after', memory_get_usage())进行精准埋点。
持续监控关键路径
#[Profile]
#[SlowThreshold(ms: 500)]
public function chargeCard(PaymentRequest $r): PaymentResult
{
// 始终被捕获 + 滞后时会有一个显式的 Slow 标记
}在 Grafana 中,为 oxphp_profiler_runs_total{source="sample"} 添加一个面板,并针对来自 index.json 的 duration_ms 异常值设置告警(通过基于日志的指标或一个 sidecar 导出器)。
通过链接复现 bug
同事说“/admin/report 对我返回 500”。你回复:
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>。
最佳实践
- 切勿提交
PROFILER_AUTH_TOKEN。从 Vault / Docker secrets / Kubernetes secrets 读取。 - 在生产环境中——只用
SAMPLE_RATE。header/cookie/query 是开发者工具。如果你需要按需进行生产环境分析——使用一个专用的、每日轮换的 token。 - 不要全局开启
PROFILER_INTERNAL=true。2–5 倍的开销会把生产环境变成实验室。请隔离开来精准使用。 - 让
PROFILER_RETENTION_COUNT保持现实 ——一次运行的大小可以从几百 KB(小请求)到数 MB(大 span 树)。500 次运行 × 2 MB = 1 GB。据此规划磁盘大小。 - 对嘈杂的辅助函数使用
#[Exclude](日志、i18n、自动加载器)——span 树在不丢失意义的前提下变得可读。 - 将性能分析结果关联到 trace:
trace_id是共享的。在 Grafana / Kibana 中,从 trace 视图链接到/__profiler/runs/<id>。 - 对 Git 友好的标识符。在此构建中,
span_id是一个确定性的大端单调计数器。对两份已存储的性能分析结果做 diff 很干净。 - APM + 性能分析器是免费的。保持两者都启用;span 树是共享的,开销仅来自 APM 累积的覆盖范围。
故障排查
没有出现任何性能分析结果
- 插件编译进来了吗?
docker compose build默认包含它。检查你没有传入--build-arg OXPHP_WITH_PROFILER=0或一个不含plugin-profiler的自定义CARGO_FEATURES。 PROFILER_ENABLED=true了吗?- 触发条件真的匹配
PROFILER_AUTH_TOKEN吗?- 检查环境变量中是否有多余的
\n。 - 对于 query——它做了正确的 URL 编码吗?
- 检查环境变量中是否有多余的
- 服务器到底有没有看到你的请求?检查访问日志。
/__profiler/runs 返回 401
头中的 bearer token 与 PROFILER_AUTH_TOKEN 不匹配。常见陷阱:
echo "secret" > secret.txt 会追加一个 \n。请使用 printf 或通过环境变量传入。
xhgui 不显示新的运行记录
- 检查可达性:
docker compose exec app curl -v $PROFILER_EXPORT_URL - 查看
oxphp_profiler_http_push_failures_total。 - 检查日志:每次失败时都会发出带
run_id和 HTTP 状态的tracing::warn!。
磁盘上没有文件
PROFILER_OUTPUT_DIR是绝对路径吗?相对路径会被忽略。- 对
www-data可写吗?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)。可选方案:
- 提高上限(用内存换取细节)。
- 在被调用数万次的函数上添加
#[Exclude]/#[Sample(rate: 0.01)]。 - 只用
start()/stop()包裹可疑区段。
命令速查表
# 为一个请求激活
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 访问。
带手动控制的简单控制器
<?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');
}调用:
curl -H "X-OxPHP-Profile: dev-secret" 'http://localhost/report.php?user_id=42'带属性的服务类
<?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 次迭代中只分析第一次
<?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 { /* ... */ }对比两种实现(带性能分析的微基准测试)
<?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]);工作流:
# 朴素实现
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
// 经典场景:某个可疑函数只对特定用户变慢。
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();
}
}自我分析的集成测试
<?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 有时慢,不是一直慢。”
// Postman 预请求脚本
pm.request.headers.add({
key: 'X-OxPHP-Profile',
value: pm.environment.get('PROFILE_TOKEN')
});跑完 100 次后,用一行 jq 找出最异常的几个:
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
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 上。
参考资料
- 源码内规范:
src/profiling/mod.rs、src/plugins/ox_profiler/ - 桥接(C):
ext/bridge/oxphp_bridge.c、ext/oxphp_sapi.c - PHP 测试:
tests/php/profiler/ - 格式 fixture:
tests/fixtures/profiler_exports/ - xhgui 演示:
tests/compose.xhgui.yml - speedscope:https://www.speedscope.app/
- xhgui:https://github.com/perftools/xhgui
- Google pprof:https://github.com/google/pprof
- flamegraph.pl:https://github.com/brendangregg/FlameGraph