Shared* 可观测性
每个 OxPHP\Shared\* 实例都是运行时为引用计数和容量而追踪的一个注册表条目。这份追踪信息以 /__ox_shared/* 下的 JSON 自省接口,以及 oxphp_shared_* 下的 Prometheus 指标形式暴露给运维人员。本页既是参考手册,也是现场排查指南。
启用
可观测性搭载在内部服务器之上。设置 INTERNAL_ADDR 即可启动它:
INTERNAL_ADDR=127.0.0.1:9090JSON 端点和 /metrics 随后都可在该地址访问。无需任何额外配置。
你可以分别独立地禁用其中任意一项:
| Env var | Default | Effect |
|---|---|---|
SHARED_INTROSPECTION_ENABLED |
true |
开关 /__ox_shared/* JSON API。 |
SHARED_INTROSPECTION_PREVIEW_ENABLED |
true |
开关 /preview(值形态预览可能泄露数据)。 |
SHARED_METRICS_ENABLED |
true |
开关 oxphp_shared_* Prometheus 指标。 |
在充满敌意租户的部署环境中请关闭自省;指标仅为聚合值,保持开启是安全的。
自省端点
所有响应均为 Content-Type: application/json; charset=utf-8。查询参数为标准的 URL 编码。
GET /__ox_shared/summary
顶层快照:按类型聚合的计数、内存、操作速率,以及相对于配置上限的饱和度。
{
"total_entries": 127,
"total_bytes": 2_481_664,
"by_type": {
"Counter": { "count": 48, "bytes": 3_072, "ops": 1_402_391 },
"Map": { "count": 12, "bytes": 1_638_400, "ops": 48_201 },
"Pool": { "count": 4, "bytes": 16_384, "ops": 67_014 }
},
"limits": { "max_entries": 100_000, "max_bytes": 1073741824, "soft_ratio": 0.7 },
"saturation": { "entries": 0.00127, "bytes": 0.00231 },
"diagnostics": {
"lock_diagnostics_level": "warn",
"cycle_detect_depth": 16,
"poison_strict": false
}
}在仪表盘和 cron 告警中使用 summary。单次抓取即可获得每种类型的健康状况和容量余量。
GET /__ox_shared/entries?limit=N
列出实时条目(上限为 limit,默认 100,最大 500)。每个条目占一行:
{
"items": [
{ "id": 42, "type": "Map", "refcount": 2, "ops": 1820, "mem_bytes": 204_800, "age_sec": 612 },
{ "id": 43, "type": "Counter", "refcount": 3, "ops": 48_014, "mem_bytes": 64, "age_sec": 612 }
],
"next_cursor": null,
"total_matching": 127
}refcount 是外部保留计数:有多少个 PHP 包装器和嵌套的 Shared 条目持有着它。当你预期某个条目应当可被 GC 回收却没有回收时,就该检查这个字段。
GET /__ox_shared/entry?id=N
针对单个条目的、按类型区分的详细信息:
{
"id": 42,
"type": "Map",
"refcount": 2,
"ops": 1820,
"mem_bytes": 204_800,
"age_sec": 612,
"type_specific": {
"key_count": 1_240,
"max_entries": 50_000,
"saturation": 0.0248,
"sample_keys": ["tenant:acme", "tenant:beta", "..."]
}
}type_specific 因类型而异:Pool 暴露 { size, in_use, idle, waiting, idle_by_thread, max_size },Channel 暴露 { capacity, pending, closed, senders_blocked, receivers_blocked },Counter 暴露 { value },依此类推。
GET /__ox_shared/preview?id=N
对标量值和小型数组值的值形态预览。字符串值会被截断到 SHARED_PREVIEW_STRING_LIMIT(默认 256 字节);数组仅展示前 SHARED_PREVIEW_ARRAY_LIMIT 个条目(默认 20)。受 SHARED_INTROSPECTION_PREVIEW_ENABLED 控制。
{ "id": 42, "type": "Counter", "preview": "1420" }在开发阶段使用 preview;当值可能包含用户数据时,请在生产环境中禁用它。
GET /__ox_shared/types
枚举 v1 类型目录,对于需要标签 → 类映射的生成式工具很有用:
{
"types": [
{ "tag": 10, "name": "Counter", "php_class": "OxPHP\\Shared\\Counter" },
{ "tag": 11, "name": "Flag", "php_class": "OxPHP\\Shared\\Flag" },
{ "tag": 12, "name": "Once", "php_class": "OxPHP\\Shared\\Once" },
{ "tag": 20, "name": "Map", "php_class": "OxPHP\\Shared\\Map" },
{ "tag": 30, "name": "Mutex", "php_class": "OxPHP\\Shared\\Mutex" },
{ "tag": 31, "name": "Channel", "php_class": "OxPHP\\Shared\\Channel" },
{ "tag": 50, "name": "Pool", "php_class": "OxPHP\\Shared\\Pool" }
]
}GET /__ox_shared/graph?id=N[&depth=D][&edges=E]
从 id=N 开始,对外向的 Shareable 引用做 BFS 遍历。返回可达子图的节点和边。默认值:depth=16、edges=500。触及遍历预算上限会在响应中设置 truncated: true。
{
"root": 42,
"nodes": [
{ "id": 42, "type": "Map", "refcount": 2, "mem_bytes": 204_800 },
{ "id": 51, "type": "Counter", "refcount": 1, "mem_bytes": 64 }
],
"edges": [
{ "from": 42, "to": 51, "key": "hits" }
],
"truncated": false
}在遇到 CycleException 之后使用 graph,查看遍历器所走过的可达路径;或者在诊断“为什么这个 Counter 没有被 GC 回收”时使用它:图会显示出每一个对它持有保留计数的父节点。
Prometheus 指标
所有指标都在 GET /metrics 处与核心服务器指标一同暴露。
注册表全局
| Metric | Type | Labels | Description |
|---|---|---|---|
oxphp_shared_objects_total |
gauge | type |
每种类型的实时条目计数。 |
oxphp_shared_operations_total |
counter | type |
分派给每种类型的累计操作数。 |
oxphp_shared_bytes |
gauge | type |
每种类型的近似字节数(相对 mallinfo 有 ±30%)。 |
oxphp_shared_total_bytes |
gauge | — | 所有类型之和。 |
oxphp_shared_capacity_saturation |
gauge | kind |
entries 和 bytes 相对各自上限的比例。 |
oxphp_shared_deadlock_detected_total |
counter | — | 检测到的跨线程等待环。 |
Channel
| Metric | Type | Labels |
|---|---|---|
oxphp_shared_channel_count |
gauge | channel_id |
oxphp_shared_channel_pending (deprecated) |
gauge | channel_id |
oxphp_shared_channel_senders_blocked |
gauge | channel_id |
oxphp_shared_channel_receivers_blocked |
gauge | channel_id |
oxphp_shared_channel_items_sent_total |
counter | channel_id |
oxphp_shared_channel_items_dropped_total |
counter | channel_id |
oxphp_shared_channel_pending 是
oxphp_shared_channel_count 的旧命名;在弃用过渡期内两个序列携带相同的值,
当别名在未来某个版本中被移除时它们将出现分歧。
新建仪表盘请对接 _count。
Map
| Metric | Type | Labels |
|---|---|---|
oxphp_shared_map_entries |
gauge | map_id |
oxphp_shared_map_max_entries |
gauge | map_id |
oxphp_shared_map_saturation |
gauge | map_id |
Pool
| Metric | Type | Labels |
|---|---|---|
oxphp_shared_pool_count |
gauge | pool_id |
oxphp_shared_pool_size (deprecated) |
gauge | pool_id |
oxphp_shared_pool_in_use |
gauge | pool_id |
oxphp_shared_pool_idle |
gauge | pool_id |
oxphp_shared_pool_waiting |
gauge | pool_id |
oxphp_shared_pool_acquire_total |
counter | pool_id |
oxphp_shared_pool_evicted_total |
counter | pool_id, reason |
oxphp_shared_pool_wait_seconds |
histogram | pool_id |
oxphp_shared_pool_size 是
oxphp_shared_pool_count 的旧命名;在弃用过渡期内两个序列携带相同的值,
当别名在未来某个版本中被移除时它们将出现分歧。
新建仪表盘请对接 _count。
oxphp_shared_pool_evicted_total 的标签:reason=idle_timeout | evict | shutdown。idle_timeout 是对空闲槽位的自动逐出,evict 是显式调用 Pool::evict(),而 shutdown 是进程退出时的拆卸。
Counter / Flag / Once / Mutex
单实例的 counter、flag、once 和 mutex 不发布各自独立的指标序列:它们会使标签基数膨胀。请改用注册表全局的 oxphp_shared_operations_total{type=...} 计数器,以及 /__ox_shared/entry?id=… JSON 来做单实例检查。
作为后续工作追踪;如今的可见性通过 /__ox_shared/entry 提供。
诊断手册
Pool 已饱和(429 且重试持续失败)
症状:HTTP 调用方遇到超时,oxphp_shared_pool_waiting 攀升,oxphp_shared_pool_count 被钉在 maxSize。
检查:
curl -s http://localhost:9090/__ox_shared/entry?id=<pool_id> | jq .type_specific查看 idle_by_thread。如果它是 {} 或严重失衡(worker 0 有 8 个空闲,worker 3 有 0 个),说明获取操作正在争抢那些恰好在别处繁忙的线程。v1 中的每线程亲和性不会做再平衡。要么提高 maxSize,要么降低每线程的获取热点。
如果 idle_by_thread 是均衡的但所有槽位都处于 in_use,那就提高 maxSize。
内存饱和
检查 oxphp_shared_total_bytes 和 oxphp_shared_capacity_saturation{kind="bytes"}。如果二者中任一偏高:
curl /__ox_shared/entries?limit=500并按mem_bytes排序,找出占用最大的贡献者。- 对每一个执行
curl /__ox_shared/entry?id=<N>以检查其形态。对于 Map,查看key_count与max_entries的对比。 - 最常见的原因:一个以用户输入为键、无上限的
Shared\Map。补救办法是设置maxEntries上限并制定保留策略。
某个包装器无法被垃圾回收
/__ox_shared/entries 中的 refcount 会告诉你有多少个未释放的保留计数。如果在 PHP 包装器离开作用域后它仍然大于 1,说明另一个 Shared 条目正让它保持存活。
curl -s http://localhost:9090/__ox_shared/graph?id=<N> | jq .nodes反向遍历这张图。任何能到达这个卡住条目的节点都持有着一个保留计数。移除该引用($map->remove($key)、关闭 channel、丢弃 Mutex 条目),refcount 就会下降。
生产环境触发了 CycleException
该异常的消息包含环检测器所探索的可达路径。通过 /__ox_shared/entries 将这些 ID 映射回类型,并向 /__ox_shared/graph?id=<root> 请求完整形态:
# Exception message: "cycle would form: #42 → #51 → #42"
curl -s http://localhost:9090/__ox_shared/graph?id=42 | jq结果会把这条链可视化出来,让你看清那个无意中引入的反向引用是在哪里产生的。
死锁检测器已触发
oxphp_shared_deadlock_detected_total 正在跳动。检查服务器日志。检测器会为每个环发出一条日志记录,包含涉及的 mutex ID 和持有它们的线程。恢复步骤:
- 对每一个执行
curl /__ox_shared/entry?id=<mutex_id>并确认poisoned=false。如果已中毒,说明检测器已经中止了该环。 - 如果这个环是一个真实的重入 bug,请重构为每个锁定作用域使用独立的 mutex。
- 在预发环境中设置
SHARED_LOCK_DIAGNOSTICS=strict,把未来的重入变成快速失败,而不是一个被检测到的环。
长时间浸泡测试框架
tests/soak/pool_soak.sh 是一个手动(非 CI)测试框架,用于在数小时或数天的持续负载下验证 Shared\Pool 的稳定性表现。它会:
- 以动态工作进程伸缩(默认
PHP_WORKERS=4:40)和一个较短的池idleTimeout启动开发镜像,从而让逐出调度器持续触发。 - 加载
tests/soak/workload.php作为工作进程引导脚本,它会构造 10 个池 ×maxSize=8,并在每个请求上提供 acquire/release 服务。 - 用
wrk驱动流量,持续SOAK_DURATION_MIN分钟(默认 1440 = 24h)。 - 每 60 秒抓取一次
/metrics和容器的 RSS,写入tests/soak/out/<timestamp>/metrics.csv。 - 在结束时写出
verify.txt,标注五项发布退出准则的通过/失败(RSS 漂移在 ±5% 以内、零陈旧句柄 panic、关闭时零泄漏条目、空闲超时逐出平稳上升、零死锁检测器触发)。
主机上的前置要求:docker、wrk、curl、awk。
典型调用方式:
# 24h full soak before a release
tests/soak/pool_soak.sh
# 1h smoke for validating the harness itself
SOAK_DURATION_MIN=60 tests/soak/pool_soak.sh
# Heavier concurrency
SOAK_CONCURRENCY=400 SOAK_THREADS=8 tests/soak/pool_soak.sh产物落在 tests/soak/out/<timestamp>/:
metrics.csv— 每分钟一行(unix 时间戳、RSS、按类型的条目计数、按池的逐出计数器、死锁计数、操作数)。server.log— 容器 stdout/stderr,包括任何陈旧句柄或 panic 的踪迹。wrk.out/wrk.err— 负载生成器的原始输出。metrics.final— 在容器拆卸前刚刚采集的最后一次/metrics抓取。用于确认负载停止后条目数和字节数已回到基线(无泄漏的 Shared 条目)。verify.txt— 五项退出准则的通过/失败报告。
不要把它接入 CI。一次 24h 的运行代价不菲,其目的是发布前的信心保证,而非持续验证。
抓取节奏
注册表端点会在读锁下遍历实时状态,因此抓取虽便宜但并非免费。推荐的节奏:
/metrics— 每 15 秒(典型的 Prometheus 默认值)。仅为聚合值;开销可忽略。/__ox_shared/summary— 仪表盘用 每 60 秒。比/metrics略重。/__ox_shared/entries— 仅按需。它会遍历所有分片;不要每个 tick 都抓取。/__ox_shared/entry//preview//graph— 在排查期间按需使用。
相关内容
- 共享状态 — 心智模型与原语概览。
- Prometheus 指标 — 同一
/metrics端点下的核心服务器指标。 - 内部服务器 —
/__ox_shared/*端点如何接入INTERNAL_ADDR。 - 迁移到外部存储 — 当饱和是结构性问题、无法靠调参解决时。