Shared* 可观测性
每个 OxPHP\Shared\* 实例都是运行时为引用计数和容量而追踪的一个注册表条目。这份追踪信息以 /__ox_shared/* 下的 JSON 自省接口,以及 oxphp_shared_* 下的 Prometheus 指标形式暴露给运维人员。本页既是参考手册,也是现场排查指南。
启用
可观测性搭载在内部服务器之上。设置 INTERNAL_ADDR 即可启动它:
INTERNAL_ADDR=127.0.0.1:9090JSON 端点和 /metrics 随后都可在该地址访问。除了保持 SHARED_ENABLED 开启之外,无需任何额外配置。
你可以分别独立地禁用其中任意一项:
| 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 指标。 |
在充满敌意租户的部署环境中请关闭自省;指标仅为聚合值,保持开启是安全的。
SHARED_ENABLED=false 也会移除这两个接口,连同整个子系统的其余部分一起。如果想保留这些原语可用、只隐藏它们持有的内容,请使用上面的开关。
自省端点
所有响应均为 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 },
"saturation": { "entries": 0.00127, "bytes": 0.00231 },
"diagnostics": {
"lock_diagnostics_level": "warn",
"cycle_detect_depth": 16
}
}在仪表盘和 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 暴露 { max_size, count, in_use, idle, waiting, idle_by_thread, rebalance_strategy },Channel 暴露 { capacity, count, 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": 13, "name": "Atomic", "php_class": "OxPHP\\Shared\\Atomic" },
{ "tag": 20, "name": "Map", "php_class": "OxPHP\\Shared\\Map" },
{ "tag": 31, "name": "Channel", "php_class": "OxPHP\\Shared\\Channel" },
{ "tag": 50, "name": "Pool", "php_class": "OxPHP\\Shared\\Pool" }
]
}响应中不列出 Shared\Mutex。
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_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 |
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_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_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 提供。
死锁检测器
一个后台任务负责在 Shared\Mutex 上查找跨线程的等待环:两个或更多线程各自在等待一个被它们中另一个线程持有的 mutex。它每隔 SHARED_LOCK_POLL_INTERVAL_MS(默认 100)扫描一次等待图。SHARED_LOCK_DIAGNOSTICS 决定扫描发现环时它做什么:
| Value | Detector | On a cycle |
|---|---|---|
off |
不启动。SHARED_LOCK_POLL_INTERVAL_MS 不起作用,oxphp_shared_deadlock_detected_total 保持为 0。 |
什么也不做。环上的 withLockTimeout() 会在超时耗尽时放弃,抛出 OperationTimeoutException。只由 withLock() 调用组成的环没有自己的超时来结束它:其中每个请求都会一直等到被终止(如果设置了 max_execution_time,就由它终止),而被终止的请求所持有的锁会在它结束时被释放(参见 Shared\Mutex)。 |
warn(发布构建中的默认值) |
运行。 | 记录日志并计数(见下文);等待保持原样,结果与 off 下相同。 |
strict(二进制调试构建中的默认值) |
运行。 | 记录日志并计数,同时向环上的线程发出信号:环上的 withLock() 会在扫描后 100 ms 内抛出 DeadlockException,而 withLockTimeout() 会等到超时耗尽,然后抛出 DeadlockException 而不是 OperationTimeoutException。如果某个等待者的锁先被释放了(因为另一个等待者的异常展开了持有该锁的闭包),它会获取到锁,不会抛出异常。 |
该值只按小写匹配。任何其他值,包括 Strict 或 trace,都会静默地回落到默认值。
扫描发现的每个环至少会产生一条 WARN 记录,带有 event=shared.deadlock_detected,每条记录指明环上的一个线程(start_thread,并重复为 holder_thread);记录中不会指明 mutex。每产生一条记录,oxphp_shared_deadlock_detected_total 就增加一次。
同线程重入是一项独立的检查,不依赖这个设置:在已经持有某个 mutex 的线程上再次进入它,会在调用处抛出 DeadlockException,所有取值下都是如此(包括 off),并且永远不会形成环。
诊断手册
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"}。随着用量攀升,服务器不会削减或降级任何东西。它的第一个反应是在达到 SHARED_MAX_BYTES 或 SHARED_MAX_ENTRIES 时拒绝创建新的 Shared 对象;已经存在的对象会继续增长,不再对这个上限做检查(Map 没有上限,除非构造时指定了 maxEntries),因此 bytes 仪表的读数可能超过 1.0。想要提前预警,请添加一条告警规则,例如 oxphp_shared_capacity_saturation > 0.7。如果二者中任一偏高:
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 正在跳动:死锁检测器发现两个或更多线程各自在等待一个被它们中另一个线程持有的 Shared\Mutex。在服务器日志中查找 event=shared.deadlock_detected;这些记录指明的是线程,而不是 mutex。恢复步骤:
- 找出那些嵌套获取两个 mutex 的代码路径,让它们在所有地方都按相同的顺序获取,或者把这两个 mutex 合并成一个。
- 在
SHARED_LOCK_DIAGNOSTICS=warn(发布构建中的默认值)下,环只会被记录日志和计数。在预发环境中设置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— 在排查期间按需使用。
给你的智能体的提示词
这些提示词假定你的智能体已连接 OxPHP 文档 MCP 服务器——需要哪些页面它会自己去读。
为我的代码实际用到的那些 Shared\* 原语做一份 Prometheus 仪表盘和告警规则。通过 OxPHP 文档 MCP 服务器读取 `shared-state/shared-observability` 页面。
找出这个仓库构造的每一个 `Shared\Map`、`Shared\Pool`、`Shared\Channel` 以及其他原语,用文档里列出的那些指标——饱和度、等待和丢弃——做出面板和告警阈值。相关内容
- 共享状态 — 心智模型与原语概览。
- Prometheus 指标 — 同一
/metrics端点下的核心服务器指标。 - 内部服务器 —
/__ox_shared/*端点如何接入INTERNAL_ADDR。 - 迁移到外部存储 — 当饱和是结构性问题、无法靠调参解决时。