配置参考

OxPHP 通过环境变量进行配置,另外还有一个可选文件:.oxphpdeny,即从 DOCUMENT_ROOT 顶层读取的路径拒绝规则。每一项设置都有默认值,因此零配置部署开箱即用。

布尔值

标记为布尔类型的变量接受一组固定的规范取值,大小写不敏感并会去除首尾空白:

  • 真值:on、true、1、yes
  • 假值:off、false、0、no

任何落在该集合之外的非空值——例如 ture 这样的拼写错误——会在启动时快速失败,并给出指明该变量的错误信息。这能在流量到来之前捕获配置错误,而不是悄无声息地把标志翻到错误的方向。

未设置的变量或空赋值(FOO=)会回退到文档记载的默认值。空值被特意当作未设置处理:Docker Compose / Kubernetes 中形如 FOO=${FOO} 的替换在宿主变量缺失时会产生 FOO=,而这不应导致服务器拒绝启动。

服务器

Variable Default Description
LISTEN_ADDR 0.0.0.0:80 主 HTTP 服务器的地址与端口
DOCUMENT_ROOT /var/www/html/public 提供文件和 PHP 脚本服务的根目录
ENTRY_FILE (未设置) 单一规范入口脚本。未设置 = 直接文件映射。*.php = 前端控制器。非 .php = 静态回退(SPA)。当 WORKER_MODE_ENABLED=true 时 = 工作进程引导。相对于 DOCUMENT_ROOT 解析(允许相对路径和 ..;绝对路径按原样使用)。参见路由
WORKER_MODE_ENABLED false 启用持久化工作进程模式。要求 ENTRY_FILE 指向一个 .php 脚本。布尔——参见布尔值
MAX_CONNECTIONS 10000 最大并发 TCP 连接数。也是默认 QUEUE_MAX_WAITING 的上限来源(取其一半),因此格式错误的值是启动错误而不是静默回退。调低它并不会移动 QUEUE_CAPACITY 的默认值——后者只按工作进程数计算——请让它保持高于 PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING,参见为接受循环保留余量
TOKIO_WORKERS CPU / 2(最小 1) 异步 I/O 线程。1 = 单线程,N > 1 = 固定线程数,未设置 = 自动(CPU / 2,最小 1)。参见确定异步运行时的大小

确定异步运行时的大小

TOKIO_WORKERS 默认取可用核数的一半,而它留下的那一半并没有闲着:它属于 PHP 工作进程池。这两组线程争抢同一批核心,所以无法在不从应用那里拿走 CPU 周期的前提下把运行时调宽。

在进程池规模已经接近核心数的地方,把它调到全部核心数是一种损失。在一台 4 核机器上、用 4 个工作进程的池提供压缩响应实测:运行时线程从 2 个增加到 4 个,在 1.6 KB 的 JSON 响应体上损失了 13% 的吞吐率,在 5 KB 的响应体上损失了 26%,而且为此烧掉了更多 CPU,因为线程被过度订阅了。

在平衡不同的地方再调高它:进程池相对于机器较小,或者工作是 I/O 密集而不是 PHP 密集的(代理的上游、静态文件、流式响应),这时干活的是运行时,进程池大部分时间在等待。每次改动它时,除了吞吐率也要盯着进程池:oxphp_busy_workers 对比 oxphp_workers_current 会告诉你核心去了哪一边。吞吐率上升而进程池变得更忙,是真正的收益;吞吐率上升而进程池变空,通常意味着工作只是换了地方,而不是做完了更多。

Tip

TOKIO_WORKERS 和 PHP_WORKERS 都默认取可用核心数的一半,所以两者之和正好落在核心数上。改动其中一个,和就会移动,这就是为什么无论改哪一个都值得实测而不是凭猜测。

PHP 工作进程

Variable Default Description
EXECUTOR sapi PHP 执行器后端。sapi 用于执行 PHP,stub 用于不带 PHP 的基准测试
PHP_WORKERS CPU / 2(最小 1) 工作进程池大小。N = 固定池,MIN:MAX = 动态伸缩,0 = 自动
PHP_WORKERS_IDLE_SECONDS 30 动态工作进程被回收前保持空闲的秒数(仅动态模式)。回收发生在工作进程手头没有任何在途请求的时刻,因此它正在服务的请求不会被中途切断——而持有一个永不结束的请求(例如一个打开的流)的工作进程永远等不到这样的时刻,完全不会被回收
QUEUE_CAPACITY 初始工作进程数 × 128 PHP 队列中最大待处理请求数。到达已满队列的请求会等待空位(参见 QUEUE_WAIT_TIMEOUT_MS)。对于动态池(MIN:MAX),初始工作进程数 = 最小数量。0 = 自动
QUEUE_WAIT_TIMEOUT_MS 1000 请求在被 529 拒绝之前,最多可以花多长时间等待一个 PHP 工作进程。这是一个上限,而不是固定值。这是在到达时盖上的单一截止时间,覆盖请求可能面临的两段等待:等队列空位,以及在队列里等工作进程。过载时,服务器会自行把预算缩短到这个上限之下,并在队列排空后再把它放长;oxphp_admission_wait_budget_us 公布当前生效的预算,oxphp_admission_wait_budget_ceiling_us 公布配置的值(参见自适应等待预算)。截止时间从两头执行:截止时间一过,请求就会得到 529 应答;工作进程取到已过截止时间的请求时会拒绝而不是执行它,所以没人取走的排队请求不会无限期地等下去。它不约束处理器随后运行多久。同时最多有 QUEUE_MAX_WAITING 个请求在等待;超过之后请求会被立即拒绝。0 = 队列一满就立即拒绝;它消除的是等队列空位的等待,而不是在队列里的等待。当应用会通过 HTTP 回调这台服务器自身时(内层调用需要的工作进程正被外层调用占着,这段等待是白费的),或当前面的负载均衡器有更短的超时设置时,请调低它。等待途中关闭连接的客户端会立即把位置让出来,HTTP/1.1 和 HTTP/2 都是如此;已经进入队列后才关闭的客户端会保留位置,直到工作进程轮到它或预算耗尽,然后得到 499 应答,而不是被算作一次过载拒绝。服务器看不到的是那些不关闭连接就不再等待的客户端。这样的客户端会一直占着位置,直到被放行或预算耗尽;如果一个工作进程先空了出来,它的脚本就为不存在的人跑了一遍,所以把预算压在前置组件的超时之下仍然值得做
QUEUE_MAX_WAITING 初始工作进程数 × 128,上限 MAX_CONNECTIONS / 2 同时停放着等待队列空位的最大请求数。超过之后请求会被立即拒绝而不是等待。每个等待者在等待期间都占着一条连接和一份已完整缓冲的请求体,所以这是对占用资源的约束,而不是对终将有回报的等待的约束。默认值上的 MAX_CONNECTIONS / 2 上限只约束积压中的这一部分;排队和运行中的请求同样占着连接,所以服务器实际留给接受与拒绝的余量,取决于 PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING 与 MAX_CONNECTIONS 的对比——见下文。请按你自己的处理器延迟来设定它——同样见下文。0 = 自动,且绝不低于 1
QUEUE_MAX_WAITING_BYTES 67108864(64 MiB) 停放中的请求合计最多可持有的请求体字节数。请求体会把总量推过该值的请求会被立即以 529 拒绝而不是等待;不携带请求体的请求绝不会因此被拒绝。QUEUE_MAX_WAITING 以请求数约束同一个集合,但对它们的大小不置一词——请求体在请求到达队列之前就已完整缓冲,因此只有数量上限时,停放集合可以持有 QUEUE_MAX_WAITING × 10 MiB。对应当吸收大请求体突发而不是卸除它们的上传密集型应用,调高它;在内存受限的容器上,调低它。0 = 自动

确定等待集合的大小

有多少请求能有意义地等待,取决于进程池消化它们的速度。有 W 个工作进程、处理器耗时 T 毫秒时,进程池每毫秒放行 W / T 个请求,所以 B 毫秒的预算大约能放进 W × B / T 个等待者。超出这个数的每个请求都会等完整个预算然后照样被拒绝,全程占着一条连接和一份已缓冲的请求体。

服务速率在启动时是未知的,所以默认值算不出这个数,因而刻意取得宽松。这适合快的处理器:进程池能在预算内轻松排空很深的积压;对慢的处理器则大得离谱:8 个工作进程、200 毫秒的处理器加默认的 1 秒上限(即进程池没有在卸载负载时生效的预算),只有约 40 个请求能被及时放行,而默认值却允许停放多达 1024 个。

bash
QUEUE_MAX_WAITING=40

把它设在 W × B / T 附近,会让多出来的部分立即得到 529,而不是一秒之后才得到。缩短 QUEUE_WAIT_TIMEOUT_MS 从另一个因子达到同样的效果。两者互相权衡;在慢处理器上,更小的预算通常是更好的杠杆,因为它同时约束了客户端等到拒绝要花多久。这里的 B 是当前生效的预算,过载时它是服务器缩短后的值,而不是配置的上限。在根据配置文件里的某个数字确定等待集合的大小之前,先读一下 oxphp_admission_wait_budget_us。

这个集合还被第二重约束限定:字节。每个等待者在等待期间都把请求体保存在内存里,所以按请求数计的上限把它们占用的内存交给了流量来决定:同样是 1024 个等待者,在无请求体的 GET 上不花什么内存,在上传上却能花掉数 GB。QUEUE_MAX_WAITING_BYTES 直接约束这个总和——超过之后,携带请求体的请求会被立刻拒绝而不是停放,无请求体的请求照常等待。应当吸收突发而不是卸除的上传密集型应用需要更大的值;有硬性内存限制的容器需要更小的值。两种上限造成的拒绝在 oxphp_admission_refused_total 中分开计数(waiting_full 对应数量上限,waiting_bytes 对应内存上限),所以这个指标会直接点名该去拧哪个旋钮。

自适应等待预算

QUEUE_WAIT_TIMEOUT_MS 是一个上限。当等待所放行的工作是在为已经离开的客户端完成时,服务器会自行缩短预算,而且缩得很猛,这一点在选择上限之前值得了解:

  • 下调:每当进程池启动的请求中有四分之一或更多交给了已经离开的客户端,预算就减半;统计窗口至少包含八个已启动的请求,而不是一个固定的时间间隔。
  • 下限:上限的六十四分之一(默认值下为 15.6 ms)与 10 ms 中较大的那个,但绝不高于上限本身。
  • 上调:它按上限的八分之一逐步回升,而且只在观察到队列为空、且进程池启动的请求中交给已离开客户端的不到四分之一时才回升。平常零星的关闭标签页和客户端超时不会把它压住,而一台队列从不排空的服务器会一直停留在缩短后的预算上,直到负载缓解。

设置了 1000 的运维人员应当预期,在一次过载期间生效的大约是 15,所以针对等待的告警要写在 oxphp_admission_wait_budget_us / oxphp_admission_wait_budget_ceiling_us 这一对指标上,而不是针对配置的数字。

缩短是由关闭连接的客户端驱动的。一群不关闭连接就不再等待的客户端不会产生任何信号,上限会继续生效。

自调用

通过 HTTP 回调这台服务器自身的应用,无论等待多长都是白费:内层调用需要的工作进程正被外层调用占着。oxphp_admission_wait_wasted_total 统计这些等待,但只在预算处于上限时才统计。预算降下来之后,这个计数器就不再变化,所以那里的 0 本身并不能说明这种模式不存在。预算是否降下来也不是自调用本身的属性:一个自身调用方愿意宽裕等待的自调用者(内部 cron、超时很长的 sidecar)会保持它的上限。

QUEUE_WAIT_TIMEOUT_MS=0 解决不了这个问题。它消除的是等队列空位的等待,而不是在队列里的等待,所以除非 QUEUE_CAPACITY 小到队列会被填满,否则内层调用会被放进一个没有任何东西在消费的队列,永远得不到应答。

为接受循环保留余量

一个请求从到达到被应答,无论中间在做什么,都占着它的连接——以及 MAX_CONNECTIONS 许可中的一个。这覆盖三类彼此独立的群体,因为队列槽位在工作进程把请求取走的那一刻、脚本运行之前就已释放:

  • 在工作进程中运行:至少 PHP_WORKERS 个,工作进程模式下更多,那里一个线程多路复用多个纤程;
  • 排队中:至多 QUEUE_CAPACITY 个;
  • 在准入处停放:至多 QUEUE_MAX_WAITING 个。

只有第三项有从 MAX_CONNECTIONS 推导出的上限。请让 PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING 保持在 MAX_CONNECTIONS 之下。越过之后,失败模式会变得更糟:接受循环在开始服务一个连接之前要先取得一个连接许可,所以一旦 PHP 路径占满了全部许可,它就会停摆,此时到来的客户端得到的不是 529,而是完全没有响应——负载均衡器无法把这与一个死掉的实例区分开,而 INTERNAL_ADDR 上的健康探针依然是绿的,因为它不经过这两个限制中的任何一个。

这种停摆会被记录到日志。一次需要等待许可的接受会记录一条点名该预算的 WARN,并在许可到来的那一刻写入一条 INFO,报告循环停摆了多久。这些报告是限速的,以免一台在上限附近反复横跳的服务器把自己的日志变成故障本身:在一次已报告的停摆后几秒内开始的停摆,起初会被压下,若窗口关闭时仍在持续,届时再报告;在窗口内就结束的停摆则不留任何日志行。指标会看到每一次:oxphp_accept_stalled(一个 gauge,停摆持续期间可读,因为内部监听器不经过 MAX_CONNECTIONS)和 oxphp_accept_stalls_total(一个计数器);参见指标参考。到来的客户端仍然得不到任何响应而不是一个错误,但日志和指标会记录下这次停摆。

服务器会在启动时检查这一点,并在总和达到预算时发出警告,点名每一个值;oxphp config --check 报告同样的内容,但不改变其结论或退出码。把消除警告当作必要条件而不是充分条件:空闲的 keep-alive 连接、静态文件请求和进行中的握手同样占用许可,而它们没有一个能在启动时被数出来。

调低 MAX_CONNECTIONS 是掉进这个警告最常见的路径,因为 QUEUE_CAPACITY 的默认值按工作进程数计算,不会跟着降下来——7 个工作进程加 MAX_CONNECTIONS=1000 得到 7 + 896 + 500 对上 1000 的预算。注意该去拧哪个旋钮:在 QUEUE_MAX_WAITING 保持默认时,单独调高 MAX_CONNECTIONS 并不能消除警告,因为那个默认值是 MAX_CONNECTIONS 的一半,会跟着一起涨——在 7 个工作进程时总和会稳定在 1799,条件要到 MAX_CONNECTIONS=1800 以上才解除。请调低 QUEUE_CAPACITY,或显式设置 QUEUE_MAX_WAITING 之后再提高预算。

对于动态池(MIN:MAX),运行项按最小值计——这也是队列默认值的计算基数——所以扩到最大规模的池实际持有的比总和显示的更多。

这个比较以连接数计,而预算由请求消耗,所以以 HTTP/2 为主的部署合理地可以高于它:一条连接最多承载 H2_MAX_CONCURRENT_STREAMS 个请求,同样的积压只需一小部分连接就能承载。在那里出现这个警告是意料之中。一个使用默认预算的大进程池同样会触及它——自动定大小的 39 个工作进程的池给出 39 + 4992 + 4992 对上 10 000——那一个值得处理而不是忽略。

静态工作进程与动态工作进程

将 PHP_WORKERS 设为单个数字以使用固定池:

bash
PHP_WORKERS=8 # Fixed 8 workers PHP_WORKERS=0 # Auto-detect: CPU / 2 (min 1)

将 PHP_WORKERS 设为 MIN:MAX 以启用自动伸缩:

bash
PHP_WORKERS=2:16 # Scale between 2 and 16 workers PHP_WORKERS=4:0 # 4 minimum, auto-detect maximum (CPU × 2) PHP_WORKERS=0:16 # auto-detect minimum (CPU / 4, min 1), 16 maximum

在动态模式下,当所有工作进程都繁忙时 OxPHP 会扩容工作进程,当工作进程空闲时间超过 PHP_WORKERS_IDLE_SECONDS 时会缩容。

如何检测 CPU 数量

本页中以 CPU 数表示的每个默认值(TOKIO_WORKERS,以及未设置、为 0,或在 MIN:MAX 任一侧为 0 的 PHP_WORKERS),统计的都是允许该进程运行的 CPU:即它的亲和性掩码(taskset、docker run --cpuset-cpus),在设置了容器 CPU 配额时再降到该配额(docker run --cpus;cgroup v2 上为 cpu.max,v1 上为 cpu.cfs_quota_us)。配额向下取整,且至少计为 1,因此 --cpus=1.5 计为 1。

有两类限制不被计入,只受其中一类约束的容器会按它能看到的全部 CPU 来确定规模:

  • CPU 权重:docker run --cpu-shares,cgroup v2 上的 cpu.weight,v1 上的 cpu.shares。它决定争用时 CPU 时间如何与邻居分配。它不是上限,也推导不出任何 CPU 数量。
  • 在容器之外执行的限制。 平台可以出售一台机器的一部分,并在容器看不到的地方执行这一限制。例如在 DigitalOcean App Platform 上,曾观察到一个按 1 vCPU 出售的实例运行在一个没有配额(cpu.cfs_quota_us 为 -1)、可见 8 个 CPU 的 gVisor 沙箱中,于是默认值算出了 4 个 PHP 工作进程、4 个运行时线程和容量为 512 的队列。
在看不见的地方执行限制的套餐

在这类套餐上,请显式设置 PHP_WORKERS 和 TOKIO_WORKERS,使其与套餐实际提供的资源相符。在工作进程模式下,还要拿 PHP_WORKERS × WORKER_MAX_MEMORY_MIB 与套餐的内存作比较。这个阈值是工作进程在其循环中检查的回收触发条件,而不是对其 PHP 堆可分配量的上限,因此在任何工作进程因此被回收之前,进程池可能占用这么多甚至更多的内存,此外还要加上 OPcache 和服务器本身;在默认值 0 下则根本没有阈值。同样的沙箱也可能把内存限制藏起来:上面那个 App Platform 容器就没有看到任何内存限制。

服务器在启动时会记录 CPU 数量、决定它的来源,以及由此得出的 PHP_WORKERS 和 TOKIO_WORKERS。下面是在一台 14 CPU 的机器上以 docker run --cpus=1 运行时的输出:

json
{"timestamp":"2026-10-05T18:55:39.801254Z","level":"INFO","fields":{"message":"CPU count for default sizing","cpus":1,"cpu_source":"cgroup quota","host_cpus":14,"php_workers":"1 (auto)","tokio_workers":"1 (auto)"}}
  • 当掩码和配额都没有降低数量时,cpu_source 为 host;当其中之一降低了数量时,为 affinity 或 cgroup quota;当服务器因无法读取掩码或在线 CPU 数而无从判断时,为 unknown。如果它根本无法统计 CPU,cpus 为 4。
  • host_cpus 是系统报告的在线 CPU 数(Linux 上为 /sys/devices/system/cpu/online),掩码或配额不会降低它。无法读取时省略该字段。如果某个运行时改写该文件、只列出容器的 CPU,那么被收窄的掩码就会被读作 host。
  • (auto) 标记完全交由默认值决定的值:该变量未设置或为空,或者对 TOKIO_WORKERS 而言不是数字。

在一个卖给你的 CPU 少于 cpus 的套餐上看到 cpu_source 为 host,就是上面描述的情况。

工作进程模式

Variable Default Description
WORKER_MAX_MEMORY_MIB 0 每个工作进程被回收前的最大内存(MiB)。0 = 无限制

设置 WORKER_MODE_ENABLED=true 并让 ENTRY_FILE 指向你的工作进程引导脚本(例如 ENTRY_FILE=worker.php 或 ENTRY_FILE=../worker.php)。此后 PHP 工作进程会跨请求保持存活,把引导状态(自动加载器、数据库连接)保留在内存中。当工作进程超过 WORKER_MAX_MEMORY_MIB 或连续三个请求中途崩散时会被自动回收,在应用调用 Worker::scheduleExit() 时按需回收;哪些情况计入第三项,参见回收。早期版本中的 WORKER_MAX_REQUESTS 旋钮已弃用并被忽略:两者都不要设置,或者迁移到 Worker::scheduleExit()。

已弃用:INDEX_FILE 与 WORKER_FILE

为向后兼容,遗留的 INDEX_FILE 和 WORKER_FILE 变量仍会被解析。设置后,它们会在启动时输出一行 WARN 日志,并映射到新模型上:

Legacy Equivalent today
INDEX_FILE=index.php ENTRY_FILE=index.php
INDEX_FILE=index.html ENTRY_FILE=index.html
WORKER_FILE=/path/worker.php WORKER_MODE_ENABLED=true ENTRY_FILE=/path/worker.php

如果新旧同时设置,则以 ENTRY_FILE / WORKER_MODE_ENABLED 为准。可以在方便时再迁移;已弃用的形式将在未来版本中移除。

SAPI / PHP

Variable Default Description
SUPERGLOBALS_ENABLED true 在脚本执行前向 PHP 描述请求,让它构建 $_SERVER 和 $_GET。在两者都不读取的应用中,设为假值可跳过这项工作。它只影响这两个:$_POST、$_FILES 和 $_COOKIE 由请求体和 Cookie 头部构建,而这两者无论如何都会交给 PHP;php://input 是一个流而不是超全局变量。完整对照表参见 SUPERGLOBALS_ENABLED

超时

Variable Default Description
HEADER_TIMEOUT_SECONDS 5 连接建立后接收 HTTP 头部的最大秒数(Slowloris 防护)
DRAIN_TIMEOUT_SECONDS 25 优雅关闭期间等待进行中连接的最大秒数。截止时刻仍处于阻塞调用中的请求可能让进程拖过这个时限(参见优雅关闭)

PHP 执行时间受 PHP 自身的 max_execution_time ini 指令(以及运行时的 set_time_limit())限制,而非某个 OxPHP 环境变量。这一限制不会作用到异步任务:ASYNC_WORKERS 池的线程不持有执行截止时间,因此该指令和 set_time_limit() 都不会限制用 oxphp_async() 派发的任务所执行的内容。请改用 oxphp_async_await() 的超时参数来限制这类任务;参见异步 Promise。

限流

Variable Default Description
RATE_LIMIT 0(关闭) 每个 IP 在每个时间窗口内的最大请求数。0 禁用限流
RATE_WINDOW_SECONDS 60 限流窗口时长(秒)

安全

Variable Default Description
FRAME_OPTIONS SAMEORIGIN 点击劫持防护。SAMEORIGIN 只允许同源页面进行框架嵌入,DENY 阻止所有框架嵌入,off 禁用(当你通过自己的 CSP 管理框架嵌入时使用)。任何其他值都会回退到默认的 SAMEORIGIN 并给出启动警告。在每个响应上同时设置 X-Frame-Options 和 Content-Security-Policy: frame-ancestors。发出的头部值、服务器头部如何让位于应用设置的头部,以及取值建议,参见下文点击劫持防护
TRUSTED_PROXIES (未设置) 可信反向代理网络:由 CIDR、单个 IP 以及关键字 private 和 cloudflare 混合组成的逗号分隔列表(例如 private,cloudflare)。设置后,OxPHP 会使用最右非可信算法从 Forwarded(RFC 7239)或 X-Forwarded-For 头部中提取真实客户端 IP;使用 cloudflare 时,如果转发链表明请求来自 Cloudflare 边缘节点,则改为从 CF-Connecting-IP 中获取。还会处理 X-Forwarded-Proto 和 X-Forwarded-Host,用于 $_SERVER['HTTPS']、REQUEST_SCHEME、SERVER_NAME 和 SERVER_PORT。未设置 = 功能禁用
PHP_DENY_PATHS (未设置) 逗号分隔的 glob 模式,其 .php 文件绝不能通过直接 URI 执行(例如 /uploads/**,/cache/**,/admin/legacy.php)。模式可以针对整个目录或单个文件。适用于直接映射模式——传统模式和 SPA 模式;在框架模式和工作进程模式下会被忽略并给出启动警告,因为这两种模式永远不会直接执行任意 .php 文件。也涵盖通过目录索引解析到达的脚本(/uploads/ → uploads/index.php)。对于直接的 .php URI,匹配发生在磁盘 I/O 之前,因此被拒绝的路径无论文件是否存在都产生相同的响应(不存在存在性预言)。遗留名称 PHP_DENY_DIRS 作为已弃用的别名被接受,并会输出启动 WARN。参见 PHP 执行拒绝列表
PHP_DENY_FALLBACK 404 命中 PHP_DENY_PATHS 或某条 .oxphpdeny 拒绝规则时返回什么。可以是一个 HTTP 状态码 400–599(与 ERROR_PAGES_DIR 配合可提供自定义 HTML),或者是一个以 / 开头、指向 DOCUMENT_ROOT 内 PHP 回退脚本的 URI 路径。回退脚本会在 $_SERVER 中收到 OXPHP_DENIED_PATH 和 OXPHP_DENIED_PATTERN。在启动时校验:脚本必须存在、规范化后位于 DOCUMENT_ROOT 内,且自身不能匹配 PHP_DENY_PATHS(防止循环)。.oxphpdeny 规则可以覆盖该脚本;在工作进程模式下,.oxphpdeny 拒绝规则只接受状态码。参见路径拒绝规则
SYMLINK_ALLOW_PATHS (未设置) 逗号分隔的绝对路径列表,在这些路径之下允许符号链接逃逸出 DOCUMENT_ROOT。每一项都必须已在磁盘上存在;相对路径和不存在的路径会中止启动。未设置 = 不允许任何符号链接逃逸。参见符号链接允许路径

特殊值 private 会展开为所有 RFC-1918 私有网络、回环地址和链路本地地址(IPv4 与 IPv6):10.0.0.0/8、172.16.0.0/12、192.168.0.0/16、127.0.0.0/8、169.254.0.0/16、::1/128、fc00::/7、fe80::/10。

点击劫持防护

点击劫持是一种攻击:一个恶意页面把你的网站嵌进一个不可见的 <iframe>,诱骗用户去点击他们看不见的东西——一个“是的,删除我的账户”按钮、一次一键购买、一个 OAuth 的“授权”提示。防御手段是告诉浏览器谁(如果有的话)可以把你的页面嵌进框架。FRAME_OPTIONS 控制的就是这件事。

有两个头部管辖框架嵌入——遗留的 X-Frame-Options(所有浏览器都理解)和现代的 Content-Security-Policy: frame-ancestors(两者同时存在时取代前者)——因此 OxPHP 会把两个都发出,让策略在新旧浏览器上同样成立。单个 FRAME_OPTIONS 值映射为一对相互匹配的头部:

FRAME_OPTIONS X-Frame-Options Content-Security-Policy 谁可以嵌入你的页面
SAMEORIGIN(默认) SAMEORIGIN frame-ancestors 'self' 只有同源页面
DENY DENY frame-ancestors 'none' 任何人都不行,连你自己的页面也不行
off (不发送) (不发送) 任何人——服务器不施加框架嵌入限制

取值建议。SAMEORIGIN 是默认值:它阻止点击劫持真正依赖的跨源框架嵌入,同时仍允许你自己的页面互相嵌套——许多应用有正当的这种需求(管理后台预览、仪表盘小组件、托管在同一源上的支付组件)。当你的站点没有任何东西打算被框架嵌入、连自己也不例外时,选择 DENY,姿态最严格。只有当你通过应用自己发出的完整 Content-Security-Policy 管理框架嵌入时才选择 off——见下文。

**嵌入外部源。**两个 X-Frame-Options 值都无法点名某个被允许的源(ALLOW-FROM 已从标准中移除)。要允许指定的第三方嵌入你的页面,请设置 FRAME_OPTIONS=off,并让应用发出自己的、带显式 frame-ancestors 列表的 Content-Security-Policy,例如 header("Content-Security-Policy: frame-ancestors 'self' https://partner.example.com");。

**应用头部优先。**服务器头部是回退项,只在响应没有携带对应头部时才应用:通过 PHP header() 设置了自己的 X-Frame-Options 或 Content-Security-Policy 的应用,其头部会原封不动地保留。两个框架嵌入头部被当作同一个策略对待,因此服务器永远不会与应用相矛盾:

  • 如果你的应用设置了 X-Frame-Options,OxPHP 会跳过它的 frame-ancestors 回退(服务器的 CSP 会在现代浏览器中覆盖你应用的选择)。
  • 如果你的应用设置了包含 frame-ancestors 指令的 Content-Security-Policy,OxPHP 会跳过它的 X-Frame-Options 回退(更严格的服务器 X-Frame-Options 会在忽略 CSP 的旧浏览器中过度拦截)。

同样的优先级也适用于 X-Content-Type-Options——OxPHP 在每个响应上把它设为 nosniff:应用设置的值会原样保留。注意 nosniff 是唯一起作用的值——应用用其他任何值覆盖它,都会悄悄禁用 MIME 嗅探防护。

TLS

Variable Default Description
TLS_CERT (未设置) PEM 编码的 TLS 证书路径。必须同时设置 TLS_CERT 和 TLS_KEY 才能启用 TLS
TLS_KEY (未设置) PEM 编码的 TLS 私钥路径
TLS_MIN_VERSION 1.2 接受的最低 TLS 协议版本:1.2 或 1.3。即使未启用 TLS,也会在启动时(以及通过 oxphp config --check)校验——任何其他值,包括非 UTF-8 字节,都是硬性启动错误。空值被当作未设置处理

HTTP/2

Variable Default Description
H2_MAX_CONCURRENT_STREAMS PHP_WORKERS_MAX × 4(最小 32) 每个 HTTP/2 连接同时打开的最大流数
H2_MAX_PENDING_RESET 20 在关闭连接前排队的最大 RST_STREAM 帧数(Rapid Reset 防护)
H2_MAX_HEADER_LIST_BYTES 65536 每个请求解码后头部的最大总字节数
H2_KEEPALIVE_INTERVAL_SECS 20 HTTP/2 PING 帧之间的秒数;0 禁用
H2_KEEPALIVE_TIMEOUT_SECS 10 在关闭连接前等待 PING 回复的秒数

静态文件

Variable Default Description
STATIC_MAX_AGE 30d 静态文件的 Cache-Control: max-age。接受:30s、5m、2h、30d、1w、1y、纯秒数(3600),或用 off 禁用该头部。取代已弃用的 STATIC_CACHE_TTL。
STATIC_REVALIDATE off 布尔——参见布尔值。设为真值以对内存内容缓存启用 mtime 重新校验:每个文件的修改时间最多每 3 秒重新检查一次(按文件计,而非按请求计),过期条目会被自动逐出,因此变更会在 3 秒内变得可见。取代已弃用的 STATIC_CACHE(其中 off 的含义正好相反)。

压缩

Variable Default Description
COMPRESSION_ENCODINGS br,zstd,gzip 服务器提供的内容编码,逗号分隔:br(或 brotli)、zstd、gzip。未知的名称是启动错误。把整个值设为 off(或 none)可完全关闭压缩。它不是列表中的一项,所以 br,off 是错误而不是开关。空赋值会恢复默认值。这里书写的顺序会被忽略:缓存的静态文件优先使用 Brotli(其开销只付一次),其他一切优先使用 Zstandard。参见压缩
COMPRESSION_BROTLI_LEVEL 5 Brotli 质量(0 到 11)。取代已弃用的 COMPRESSION_LEVEL
COMPRESSION_ZSTD_LEVEL 6 Zstandard 压缩级别(0 到 19)
COMPRESSION_GZIP_LEVEL 6 Gzip 压缩级别(0 到 9)
COMPRESSION_LEVEL (未设置) COMPRESSION_BROTLI_LEVEL 的已弃用名称;会输出启动 WARN。COMPRESSION_LEVEL=0 保留其旧含义,禁用所有编码,而不仅仅是 Brotli。显式的 COMPRESSION_BROTLI_LEVEL 优先于非零值,但不优先于 0——无论其他设置如何,0 都会关闭压缩

只有当 COMPRESSION_ENCODINGS 列出了某种编码并且它的级别不为 0 时,才会提供该编码;两者任一不满足都会撤下它。超出范围或不是数字的级别会中止启动,并给出点名该变量的错误。取值不会被钳制。

日志

Variable Default Description
LOG_LEVEL info 日志详细程度:trace、debug、info、warn、error
ACCESS_LOG (未设置) 按请求的访问日志:all = 每个请求,error = 仅 4xx/5xx,未设置 = 关闭
Note

ACCESS_LOG 接受 all 或 error。不设置它即可完全禁用访问日志。

在服务器应答之前就关闭连接的客户端会以 debug 而不是 error 级别记录。这是公共监听器上最常见的事件(取消的 fetch、关闭的标签页、读超时触发的代理),并不是服务器的故障,所以在默认的 LOG_LEVEL 下它不可见。可以用 oxphp_request_cancelled_total{reason="client_abort"} 统计它们,或者调高到 LOG_LEVEL=debug 查看单个连接。服务器自己执行的超时(HEADER_TIMEOUT_SECONDS)仍保持 warn,其他所有连接失败仍保持 error。

LOG_LEVEL 不过滤访问日志

两者相互独立。LOG_LEVEL 设置服务器自身诊断信息的详细程度,所以 LOG_LEVEL=warn 加 ACCESS_LOG=error 会报告警告和错误,同时记录每一个 4xx/5xx 请求。要单独关掉访问日志,需要一条点名其目标的 RUST_LOG 指令:RUST_LOG=warn,access_log=off,其中 warn 替换为你原本运行的 LOG_LEVEL。RUST_LOG 在已设置且可解析时会取代 LOG_LEVEL,而不是叠加在它之上。参见访问日志。

可观测性

Variable Default Description
INTERNAL_ADDR (未设置) 内部服务器(/health、/metrics、/config)的地址。未设置时不会启动内部服务器。仅端口的值(:9090)会绑定到 127.0.0.1;绑定显式的 0.0.0.0:9090 可将其暴露到主机之外
INTERNAL_ALLOW_IPS (未设置) 内部服务器的逗号分隔 CIDR/IP 允许列表。列表之外的对端在访问 /metrics、/config 和插件路径时会收到 403;健康探针(/health、/healthz、/readyz、/startupz 及其完整形式)保持可访问。未设置/为空 = 允许所有。回环地址不是隐式包含的——列出 127.0.0.1/32 以保留 localhost 访问。格式错误的列表会中止启动
ERROR_PAGES_DIR (未设置) 包含以 {status}.html 命名的自定义错误页面(例如 404.html、503.html)的目录
MAX_QUERY_BODY 524288 内部查询端点的最大请求体大小(字节)(512 KiB)
TRACE_CONTEXT false 布尔——参见布尔值。为真时,启用 W3C Trace Context 传播:读取 traceparent/tracestate 头部并通过 $_SERVER 转发给 PHP

OpenTelemetry

Variable Default Description
OTEL_ENABLED false 启用 OpenTelemetry span 导出。自动设置 TRACE_CONTEXT=true。布尔——参见布尔值
OTEL_EXPORTER_OTLP_PROTOCOL grpc 导出协议:grpc 或 http/protobuf
OTEL_EXPORTER_OTLP_ENDPOINT http://localhost:4317(gRPC)或 http://localhost:4318(HTTP) OTLP 采集器端点。使用 http/protobuf 时,它是一个基础 URL:span 会被发送到其下的 /v1/traces
OTEL_EXPORTER_OTLP_TIMEOUT 10000 导出超时(毫秒)。通过 gRPC 时,它同样约束连接到采集器的过程(包括 TLS 握手),因此请让它高于建立连接所需的时间;需要先建立连接的一次尝试,最长可能耗时两倍的超时。在 Linux 上通过 gRPC 时,它还决定一条不再接收数据的连接多快被关闭——参见日志中的 span 导出错误
OTEL_EXPORTER_OTLP_HEADERS (未设置) 认证头部:key=value,key2=value2
OTEL_SERVICE_NAME oxphp 导出 span 中的服务名
OTEL_SERVICE_VERSION (未设置) 服务版本属性
OTEL_RESOURCE_ATTRIBUTES (未设置) 额外的资源属性:env=prod,region=us-east-1
OTEL_TRACES_SAMPLER parentbased_traceidratio 采样策略:always_on、always_off、traceidratio、parentbased_always_on、parentbased_always_off、parentbased_traceidratio
OTEL_TRACES_SAMPLER_ARG 1.0 基于比例的采样器的采样比率(0.0–1.0)
Note

无效或超出范围的 OTEL_TRACES_SAMPLER_ARG 值会被钳制到 [0.0, 1.0] 并以 warn 级别记录日志。未知的 OTEL_TRACES_SAMPLER 值会回退到 parentbased_traceidratio 并记录日志。

APM

Variable Default Description
OTEL_APM_ENABLED false 启用 APM:自动埋点、错误捕获以及 PHP 追踪 SDK。要求 OTEL_ENABLED=true。布尔——参见布尔值
OTEL_APM_SLOW_QUERY_MS 100 慢查询阈值(毫秒)。超过此值的数据库查询会获得 oxphp.db.slow=true span 属性
OTEL_APM_DB_CAPTURE_PARAMS_ENABLED false 在 db.params span 属性中记录绑定参数。若参数可能包含敏感数据,请在生产环境中禁用。布尔——参见布尔值
OTEL_APM_STACKTRACE_MAX_BYTES 8192 exception.stacktrace 属性的最大大小(字节)。超过上限时,堆栈跟踪会从尾部截断并带 …(truncated) 标记。0 禁用截断
OTEL_APM_MESSAGE_MAX_BYTES 4096 exception.message 属性的最大大小(字节)(默认值与 New Relic 的每属性值上限一致)。超过上限时,消息会从尾部截断并带 …(truncated) 标记。0 禁用截断

启用 APM 时,OxPHP 会自动挂钩 34 个内部 PHP 函数(PDO、mysqli、cURL、Redis、Memcached、文件 I/O)以创建子 span。无论 APM 是否启用,oxphp_apm_*() PHP 函数都会被注册。禁用时,span 函数是返回哨兵值的空操作。oxphp_apm_trace() 是例外:它仍会运行回调并返回回调的值,所以禁用 APM 绝不会改变你的应用计算出的结果。

异步工作进程

Variable Default Description
ASYNC_WORKERS 0(禁用) 专用异步工作进程线程的数量。当为 0 时,异步函数(oxphp_async 等)仍会被注册,但调用时会抛出 OxPHP\Async\AsyncException。设为正值以启用后台任务执行
ASYNC_QUEUE_CAPACITY ASYNC_WORKERS × 64 异步队列中最大待处理任务数。0 = 自动(workers × 64)
ASYNC_MAX_FIBERS 256 每个工作进程并发异步任务纤程的上限。进程全局的进行中上限(排队 + 运行)为 ASYNC_MAX_FIBERS × ASYNC_WORKERS;超过它的派发会立即以 OxPHP\Async\AsyncException 被拒绝,因此扇出组合不会死锁

异步工作进程池处理从 PHP 派发的即发即忘后台任务。它独立于 PHP 工作进程池,标准请求处理并不需要它。

这三个变量中任何一个出现格式错误的值(例如 ASYNC_WORKERS=8x)都是启动错误——回退到默认值会悄无声息地禁用或错误配置该池。恰好为空的值被当作未设置处理。

运行时钩子

Variable Default Description
RUNTIME_HOOKS (关闭) 按需启用:用挂起纤程的实现替换阻塞式的 PHP 内置函数。1/true/all 启用所有钩子类别;逗号分隔的列表启用指定类别(例如 RUNTIME_HOOKS=sleep,streams)
类别 钩住什么
sleep 原生 sleep() 和 usleep() 会像 oxphp_sleep()/oxphp_usleep() 一样挂起当前纤程,包括在那两者同样无法挂起、转而在工作线程上等待的上下文中(参见 sleep 钩子不挂起的地方)
streams 两种等待会挂起当前纤程而不是钉住工作线程:对 tcp:// 套接字流的阻塞读取,以及 stream_select()。读取覆盖在单个套接字上阻塞的客户端——fsockopen()、stream_socket_client()、HTTP 流封装器、mysqlnd(PDO_MySQL、mysqli)、phpredis;stream_select() 覆盖同时等待多个套接字的循环。两者都无需改动代码。以其他方式等待的客户端不受影响(见下文)
net 位于 PHP 流之下、streams 读取钩子看不到的那些等待,会挂起当前纤程而不是钉住工作线程:通过 stream_socket_client()、fsockopen() 以及所有基于 PHP 流构建的客户端(其中包括 mysqlnd,即 PDO_MySQL 和 mysqli,以及 phpredis)建立连接;TLS 握手;加密激活后在 ssl:///tls:// 流上的读取;需要等待发送缓冲区空间的写入;以及在 unix://、udp:// 和 udg:// 套接字上、在 stream_socket_pair() 流上,以及在 socket_export_stream() 由非 TCP 套接字生成的流上的流读取。需要同时列出 streams,并且只在 Linux 上有效(参见 net 何时保持关闭)。不覆盖:libcurl、libpq(pgsql、pdo_pgsql)和 libmemcached 内部的等待——它们从自己的库中调用 poll();DNS 解析;stream_socket_recvfrom();超时为无限(default_socket_timeout=-1)的普通(非 TLS)流上的写入;以及持久连接的建立(pfsockopen()、STREAM_CLIENT_PERSISTENT,以及基于它们构建的客户端的持久模式)

钩子在工作进程模式的请求纤程和异步任务纤程内部生效。在纤程之外(传统/框架/SPA 请求上下文、CLI),保留原生行为,包括参数校验错误。启用 sleep 钩子后,直接调用 sleep() 的第三方代码在纤程能够被挂起的地方不再钉住工作线程(并非处处如此;见下文),两种情况都无需改动代码。在被钩住的 sleep 期间取消异步任务,会以 OxPHP\Async\AsyncException 展开它;挂起了的被钩住的 sleep() 返回 0;原生内置函数的信号中断返回值只会在下文的阻塞路径上出现。

确认设置已生效

服务器会以解析后的集合形式,在两个地方报告它安装了哪些类别。在内部服务器上:

Installed hook categories
$ curl -s localhost:9090/config | jq .runtime_hooks [ "sleep", "streams", "net" ]

以及从 PHP 中读取,适用于内部服务器不可达或未启用的部署:

php
<?php echo json_encode(oxphp_server_info()['runtime_hooks']); // ["sleep","streams","net"]

两者读取的是同一个值,报告的是集合而不是字符串:RUNTIME_HOOKS=all、RUNTIME_HOOKS=1 和 RUNTIME_HOOKS=sleep, streams, net 都会返回 ["sleep","streams","net"]。空列表表示没有安装任何钩子。变量未设置、RUNTIME_HOOKS=0、值中只包含本构建不认识的类别,以及变量名拼错,得到的都是这个答案。两者混合的值则不同:RUNTIME_HOOKS=sleep,bogus 读出来是 ["sleep"],所以非空列表本身并不能证明每个名称都被理解了。每个无法识别的名称会在启动时于服务器日志中各占一行,而那一行是唯一点名它们的地方。每次改动该设置后都要检查:无法识别的环境变量在任何地方都不算错误,所以“拼错了”在别处只会表现为延迟。

已安装不等于已生效

这个列表报告的是哪些内置函数的处理器被替换了,这发生在启动时,与路由模式无关。在根本没有纤程的地方,替换后的处理器会委托给被替换的原处理器,所以传统模式的服务器会报告 ["sleep","streams","net"],行为却始终是原生的。php CLI 进程报告的是 ["sleep","streams"]:引擎是该可执行文件的一部分,所以 net 无法把 PHP 的等待与程序自身的等待区分开,因而保持关闭。把这个列表理解为“这个进程已为钩子做好准备”,而路由模式决定是否有东西能用上它们。

在 streams 内部范围还要更窄:套接字读取部分可能单独安装失败——在无法把流 ops 表设为可写的平台上——而 stream_select() 和数据库入口点仍然被钩住。这个类别无论如何都会被报告;唯一的信号是一条说明套接字读取保持阻塞的服务器日志行。那一行和无法识别类别的那一行以及 net 写出的那些行(见下文)一样,遵循 PHP 的错误日志设置:error_log 未设置时(默认),它会以 warn 进入服务器自己的日志,而 LOG_LEVEL=error 会把它隐藏。

net 何时保持关闭

只有当 net 确实就位时,它才会被报告。如果列出了它却没有列出 streams,它会保持关闭,并在服务器日志中写一行说明。在非 Linux 平台上、在启动时无法把服务器自身的代码与 PHP 的代码区分开的地方,或者在 PHP 的 poll() 没有绑定到服务器所拦截的那个 poll() 的地方,它同样保持关闭,并写一行说明连接和 TLS 等待仍保持阻塞。因此,你要求了 net 之后它却没有出现在列表中,这本身就是答案,而不是报告中的遗漏。

这个列表唯一不会告诉你的,是 OpenSSL 位于何处。OpenSSL 所做的等待(TLS 握手,包括 ssl:// 连接末尾的那次握手,以及加密激活后在 TLS 流上的读取和写入)只有在 OpenSSL 编译进 PHP 时才会被停放,官方镜像正是如此。如果 OpenSSL 作为单独的共享扩展加载(extension=openssl),在服务器看来这些等待就不属于 PHP 自身,因此它们会占着线程,而连接、unix:// 以及其他读取仍会挂起,net 也仍会被报告。

sleep 钩子不挂起的地方

在纤程切换被拒绝的地方,被钩住的 sleep()/usleep() 会把调用交给它们所替换的内置函数,后者在工作线程上等待,并返回它自己的返回值。因此在那里被信号打断的 sleep() 会返回它没有睡完的秒数,和在纤程之外一样。(参数检查无论哪种情况都是钩子自己的:它拒绝任何大于 4294967295 的值,而未钩住的内置函数会接受并截断它。)凡是离开当前帧会不安全的地方,切换都会被阻止,下面的列表并不详尽:

  • 在 declare(ticks) 处理器内部,以及 pcntl 的信号分发内部(两者都是上游 PHP 自身造成的);
  • 在构建请求输入期间;
  • 在由接收按字段定义数组的 filter_input_array() 触达的 FILTER_CALLBACK 内部(参见超全局变量中 $_GET 下的说明);
  • 在请求崩散之后(发生致命错误后,或向已被取消的请求写入之后)运行析构函数的清理遍历内部,所以一个靠 sleep 来退避的 __destruct() 会占着线程完成它。

请求的正常结束不在此列:析构函数和关闭函数在那里运行时允许切换。oxphp_sleep() 和 oxphp_usleep() 在所有这些地方同样走阻塞路径,所以换用它们并不能绕过去。

由用户态调度器启动的纤程(AMPHP、Revolt,或裸的 new Fiber)是另一种情况:那里切换并未被阻止,调度器会正常挂起和恢复它自己的纤程,但 OxPHP 无法恢复一个不属于它的上下文,所以钩子会让开,原因与 streams 钩子相同。这说的是在请求内部启动的纤程;工作进程模式的请求本身就是一个由 OxPHP 驱动的纤程,所以钩子对它照常适用。应用自己启动的纤程内部的代码会在工作线程上 sleep,这部分负载必须按钩子关闭的情况来估算容量。

streams 钩子覆盖的范围

streams 钩子让其变得协作式的,是对 PHP 套接字流的阻塞读取,以及 stream_select() 内部的等待。在指望它之前,先确认你的客户端是用这两种方式之一等待的,或者是用 net 类别新增的方式之一(见下文)。几个常见的客户端不是,对它们来说什么都不会变:

  • ext/curl。 curl_exec() 和 curl_multi_* 自己跟套接字打交道,位于 PHP 流之下,所以钩子永远看不到它们。这覆盖了所有基于 curl 的 HTTP 客户端——包括使用默认 handler 的 Guzzle。curl 客户端可以改为指向 stream 封装器 handler,那条路径会经过 PHP 流。

  • socket_select()。 那是 ext/sockets,一套作用于原始描述符的不同 API,没有被钩住。stream_select() 被钩住了。同样的道理适用于 stream_socket_accept() 内部的等待,它也没有被钩住。

  • unix://、udp:// 和 udg:// 流、套接字对、socket_export_stream() 由非 TCP 套接字生成的流,以及加密激活后的 ssl:///tls://。 它们的 ops 表是 PHP 或 OpenSSL 私有的,所以 streams 钩子触及不到它们。net 类别可以:列出它之后,在其中任何一种流上的流读取(fread()、fgets() 之类)都会像在 tcp:// 流上那样挂起纤程。没有 net 时,它们仍像以前一样占着线程(在 stream_socket_enable_crypto() 成功之前,SSL 流的读取委托给普通套接字读取,无论哪种情况都会挂起)。stream_socket_recvfrom() 不是流读取:它直接调用 recvfrom(),没有 PHP 自己的等待可供挂起,因此在 udp:// 或 udg:// 套接字上,即使有 net,它也会占着线程直到数据报到达。socket_export_stream() 生成的是该套接字所用传输的流:UDP、unix 或数据报 unix 套接字分别变成 udp://、unix:// 或 udg:// 流,TCP 套接字变成普通的 tcp:// 流(单靠 streams 就已覆盖),其他任何类型的套接字则变成套接字对所具有的那种通用流。

  • 建立连接与 DNS 解析。 有 net 时,通过 PHP 流传输建立的连接会挂起纤程:stream_socket_client()、fsockopen() 以及基于它们构建的客户端。ftp_connect() 和 socket_connect() 在流层之外自行建立连接,不被覆盖。DNS 解析同样不会挂起:主机名在连接开始之前就在工作线程上解析,所以缓慢的解析器会占着线程。自带网络库的客户端(经由 libpq 的 pdo_pgsql 和 pgsql、经由 libmemcached 的 memcached 扩展)在该库内部建立连接并等待,两个类别都看不到。无论如何,保持连接打开的客户端在每次查询上都能受益。

    持久连接的建立是例外,而且它涵盖的不止 pfsockopen() 和 STREAM_CLIENT_PERSISTENT:带 PDO::ATTR_PERSISTENT 的 PDO_MySQL、带 p: 主机前缀的 mysqli 以及使用 pconnect() 的 phpredis 也都以持久方式打开它们的流,它们的连接建立在等待期间会占着线程,就像 net 出现之前那样。原因在于持久列表。持久流在连接之前就以其键登记在该列表中,所以在第一个纤程等待期间请求同一个键的第二个纤程,会找到一个尚无描述符的流,把它当作已断开的连接,并在仍在建立连接的纤程底下把它关掉。有些客户端给每条连接一个独有的键(mysqlnd 就是如此),没有任何东西能以这种方式找到那些流,但服务器依据的是流是否持久,而不是谁能找到它,所以它们同样会占着线程。代价在于连接的建立——持久连接只在首次打开时付出一次——而不在于其上的读写。

  • 通过 localhost 访问的 MySQL。 MySQL 客户端把 localhost 读作“使用 unix socket”,那不是 tcp:// 流,所以 streams 钩子看不到它。请在 DSN 里写 127.0.0.1,或者列出覆盖 unix:// 流的 net。这对 PDO_MySQL 和 mysqli 同样适用,而且很容易被忽略,因为一切照常工作,只是没有收益。

  • 写入。 只有 streams 时,只有读取会等待。只要一个纤程拥有描述符且没有别的东西把数据读走,读就绪状态就会保持不变;而发送缓冲区里的空间是由对端授予和收回的,所以因可写而被唤醒的纤程,可能在写入执行时发现窗口又关上了,此后 PHP 无论如何都会让线程阻塞满它的整个超时。因此,填满套接字缓冲区的写入,其行为与没有钩子时完全一样。实践中这代价很小:花时间的是等待回复,而不是把查询递给内核。(写入仍会被查看,但只为一件事:这条连接当前承载的是哪个纤程的往来交换——见下文关于共享连接的说明。在没有其他纤程使用的连接上——通常情况下的每一条连接都是——写入走原生路径,不受触碰。)

    列出 net 后,需要等待空间的写入同样会挂起纤程,而且每次唤醒后都会重新检查描述符,所以期间关上的窗口只会多花一次挂起,而不是占用线程。超时为无限(default_socket_timeout=-1)的普通流是例外:PHP 在它上面用阻塞的 send() 发送,自己从不等待,所以没有可挂起的东西,写入会像没有钩子时那样占着线程。超时为无限的 TLS 流则不同:OpenSSL 自己的循环会在 poll() 中无限期地等待,而这个等待会被挂起。

在这个范围之内,钩子保持原生契约:套接字超时(stream_set_timeout()、default_socket_timeout)不变地适用,超时的读取仍通过 stream_get_meta_data() 报告 timed_out,流的身份不受触碰,所以 socket_import_stream() 之类的继续工作。超时有一个限制:截止时间每个调度器 tick 只检查一次,所以它最早在下一个 tick 触发——工作进程模式下最好是 100 µs,异步池 50 µs,负载之下更长,因为一个 tick 会持续到工作进程正在运行的东西结束为止。异步池在无事发生时会暂停更久,但不会睡过它持有的截止时间:读或写的截止时间会把暂停缩短到正好落在自己身上,所以等得更久不会让超时更晚。在 default_socket_timeout 为 60 秒的情况下,绝大多数部署观察不到这一点。

stream_select() 的钩法是:先等待三个数组点名的描述符,然后把调用交给 PHP、超时设为零,所以你能观察到的一切仍由 PHP 决定:返回计数、把数组改写为只剩就绪的流、警告,以及参数错误。只要数组里有钩子不会替其站岗的东西,等待就会被跳过——调用就像没有钩子一样运行:已有缓冲数据的读取流(stream_select() 会直接从缓冲区应答,不看描述符)、根本没有描述符的流、不是活跃流的元素(比如留在数组里的已关闭流——PHP 用错误应答,而非等待)、内核根本不会监视其就绪状态的描述符(普通文件是常见情形,它在你问的那一刻就算就绪)、以及达到或超过 FD_SETSIZE 的描述符——PHP 自己的 select() 直接拒绝它。最后一条是真实的天花板而非形式条款:忙碌的工作进程可以持有超过 1024 个打开的描述符,点名其中之一的 stream_select() 无论有没有钩子都会以同样的方式失败。

并发纤程之间共享的连接

在并发纤程之间共享一条连接,对 OxPHP 守护的客户端来说是安全的,也不会带来收益。 这是工作进程模式应用的常态而非边缘情况:WordPress、Laravel 和 Symfony 在工作进程启动时打开一次数据库和缓存客户端,把同样的实例交给每个请求;除非重写它们的数据访问层,否则无法要求它们为每个纤程开一条连接。

客户端协议是一串往来交换——写一条命令、读它的应答——连接上没有任何东西标记一次交换在哪里结束。停在读取上的纤程停在一次交换的中间,第二个纤程的命令落在那里会破坏协议。两个客户端对此的失败方式不同,且都不体面:mysqlnd 跟踪自己的连接状态,在发送任何东西之前就拒绝该命令;phpredis 没有这种检查,两个纤程会读到彼此的应答——一个请求的数据被返回给另一个请求,且不抛任何错误。

因此,纤程在使用连接之前会先认领它,而且是在必须发生的两个层面上:套接字 ops 层,保证字节有序;以及 PDO 和 mysqli 的客户端入口层,因为 mysqlnd 的拒绝发生在任何 I/O 之前,套接字层的任何手段都来不及阻止它。另一个纤程碰到同一条连接时,在客户端层会等待它被让出——那里持有的只是连接的身份;在套接字层则不等待而是被拒绝,就像套接字超时被拒绝那样,因为挂起在别人的流的操作里的纤程,握着的是其属主随时可能释放的指针。phpredis 也在客户端层被逐个方法地守护,正是出于这个原因。客户端层认领的是连接本身而不是持有它的 PHP 对象,所以经由多个 PDO 对象访问的持久连接算作同一条;用 PDO::connect() 打开的连接——它返回驱动自己的子类而不是 PDO——也和其他连接一样被覆盖。

因此,钩子的收益应当理解为属于纤程为自己打开的连接:自己发 HTTP 或数据库调用的异步任务、自己开客户端的请求。共享连接得到的,是等待期间把工作线程还回来,让其他请求去跑不在这条连接上的工作;它自己的往来交换一个接一个地运行,和钩子关闭时完全一样。有四条边界值得了解:

  1. 查询过一次的纤程会把连接保留到它的请求结束,因为请求的结束是第一个确定越过了一次交换结尾的时刻。它停在任何别的东西上时同样保留着连接,所以一个已经查询过、又在等待自己那份需要同一条连接的工作的请求,是在等待它自己;两者靠下面那条界限分开,而不是继续推进。

  2. 等待总是有界的:以 max_execution_time 和 default_socket_timeout 中较小者为界。两者都不设时这个界是 30 秒,因为服务器 SAPI 采用引擎的默认值:前者 30、后者 60;只有 max_execution_time 为 0 时才是 default_socket_timeout 的 60。max_execution_time 按请求当前的值读取,因为 set_time_limit() 就是请求声明自己可以运行多久的方式;default_socket_timeout 按进程启动时的值读取,因为它是套接字操作的默认截止时间,一个请求为自己的某次调用把它收窄(库常这么干,还常忘了恢复),不应缩短同一工作进程上后续请求的这个界限。过了这个界,调用回落到无守护的行为,并在服务器日志里说明原因:对 PDO 和 mysqli,它被交给客户端,客户端对交换中途下发命令的拒绝,正是应用早已在处理的那个错误;对 phpredis——它没有这种拒绝,反而会读到别人的应答——则抛出 RedisException,什么也不发送。

    有一个 PDO 调用是例外,在这里以及对于在任何纤程之外运行的代码(见下文)都是如此:在另一个纤程持有的持久连接上调用 PDO::rollBack(),如果发现其上有打开的事务,它什么也不发送并返回 false,因为那个事务属于持有者,在其语句之间发出的 ROLLBACK 会把它结束。它按错误模式所规定的方式报告的错误,是连接上已经记录的那个错误,可能属于持有者。PDO 自己的对象析构也被限定给持有者。当某个请求或任务持有一条持久连接时,丢弃该连接上的某个句柄,只有在持有者是最后一个使用该句柄的一方(构建了它,或此后通过它调用过 OxPHP 守护的某个 PDO 方法)时,才会回滚其上打开的事务并运行驱动的请求结束清理,无论该句柄在何处被销毁,PHP 的循环回收器在另一个请求中运行时也包括在内。其他任何句柄两者都会跳过。在没有人持有的连接上,每个句柄都会像以前一样执行这两者。

    套接字层的冲突没有自己的界,因为它从不等待:操作像超时一样立刻失败,stream_get_meta_data() 报告 timed_out。这可能比那次调用本身代价更大:客户端可以把一次被拒绝的操作理解为比命令失败更糟的情况,而 mysqlnd 在一次交换的两侧都是这样理解的。什么也没发出去的写入会把连接标记为已断开并关闭它,读空的读取会就地把连接标记为已断开,此后在它上面的每一条命令都会依据这个状态作答,而不触及线路。损失的是整条连接,而不只是那一次调用,只要还有任何东西持有该句柄就一直如此。上文的客户端层认领,正是让受守护客户端的命令根本不会碰到这种拒绝的东西;在原始套接字上手写的协议,应当像对待已关闭的连接那样对待任何一种被拒绝的操作。两个纤程各自握着对方等待的东西时,因此在这个界上分开,而不是永远互相等待。

    在任何纤程之外运行的代码根本无法等待认领,只有套接字层适用于它。在启用 RUNTIME_HOOKS=streams 的工作进程模式下,这主要是工作进程循环在请求之间回收循环引用时运行的析构函数,此时其他请求正停放着。它所做的、本会等待的套接字操作,如果所在连接的持有者正停在该连接上等待应答,就会像超时一样立刻失败,并且什么也不发送;在其他任何时刻它都会照常进行。客户端层不适用于那里,因为它的认领会持续到持有者的请求结束:它会拒绝这类代码使用一条持有者此刻并未在用的连接,而大多数时候正是如此。一次拒绝的代价取决于客户端,如上所述。phpredis 让该调用失败,持有者保住自己的应答。mysqlnd 要么依据自身状态拒绝该命令,要么在持有者的交换使该状态处于就绪时,其写入被拒绝并关闭连接,持有者的请求随即按第 4 点所述结束。因此,析构函数根本不是使用共享客户端的好地方。

  3. 有些情况被有意地不覆盖。 跨请求保留的语句或结果对象——没有任何被认领的调用先于它:在先前请求里 prepare 的语句上调用 PDOStatement::execute(),其行为与完全没有认领时一样,而且代价比那次调用更大:只要有任何纤程停在该连接上,它触及套接字时就会在那里被拒绝,而对 mysqlnd 来说,一次拒绝就会丢掉整条连接(如上所述),无论是在获取无缓冲结果下一行的读取上,还是在发送命令的写入上。在原始套接字上手写的协议(写一条命令、挂起在别的东西上、稍后再读应答),因为套接字层只在持有者停在应答本身上时才拒绝,在这两个时刻之间,另一个纤程会把连接接管过去;上面三个客户端靠客户端层认领得到覆盖,手写协议没有等价物。

    持久连接是被覆盖的;参见下文持久 PDO 连接。

  4. 在另一个请求停在读取上时关闭共享连接,会终结那个请求,以 500 和一条点名事由的日志行收场。认领能把两次交换隔开;它不能让连接活着,而停着的请求的应答在一条已不存在的连接上——所以诚实的回答是终结它,而不是把释放后的内存现在装着的东西交回去。哪些调用能造成这种局面取决于客户端,其中只有一个会等待:mysqli::close() 和 mysqli_close() 是被认领的调用,会等待持有者、等完之后再关闭;Redis::close() 同样被认领,但抛出 RedisException 而不是关闭。其余的都立即关闭——原始流上的 fclose(),还有重要的 PDO:它根本没有 close() 方法,连接靠丢弃句柄的最后一个引用来释放(unset($pdo)、重新赋值、让它离开作用域),而那条路径在引擎自己的对象析构里运行,那里在关闭连接之前不查询任何认领。所以重新赋值共享 PDO 句柄的重连助手会终结停在旧连接上的请求,得不到 mysqli 等价物那样的有界等待。请求在关闭发生时就被立刻告知,而不是等到它自己的读取截止——对 mysqlnd 那将是 mysqlnd.net_read_timeout,默认长达一天。为了强制重连而关闭共享连接的应用(WordPress 的 $wpdb->check_connection() 是最常见的)应当预期:那一刻停在连接上的请求会失败,而不是返回错误的数据。

还有一条边界:把 streams 钩子跑在用户态纤程调度器(AMPHP、Revolt)之下会回落到阻塞式 I/O。这类调度器启动的纤程运行在它自己的上下文上,OxPHP 的调度器无法恢复它,所以钩子检测到这种情况后走原生路径,而不是把任何一个调度器搞坏。这说的是请求内部由用户态调度器启动的纤程。请求自己的纤程由 OxPHP 驱动,在工作进程模式下它是一个真正的 Fiber(请求内部的 Fiber::getCurrent() 会返回它),所以只需要区分并发请求的库无需任何回退就能工作。同一个事实也是 Revolt 拒绝在工作进程模式请求内部运行其事件循环的原因。

持久 PDO 连接

PDO::ATTR_PERSISTENT 把驱动的连接保存在整个工作进程共享的池里,而 PDO 分两步访问这个池,这两步经不起交错:它在连接之前查找池化连接,在之后登记自己建好的连接。两个重叠的构造函数都会错过池,第二次登记会替换第一个条目,从而在正读取其应答的那个请求底下关掉那条连接。重叠是寻常情况,因为在第一次连接完成之前到达的每个请求都会执行到 static $pdo ??= new PDO(...)。

因此,请求同一个数据源持久连接的构造函数在每个工作进程内一次只运行一个,第二个会找到第一个登记的连接。数据源是 PDO 解析出来的那个(php.ini 中 pdo.dsn.* 的别名算作它所指的 DSN),而通向不同数据源的构造函数通常不会互相等待,因此一个卡在连接某台接受连接却从不应答的服务器上的构造函数,不会拖住通向健康服务器的构造函数。有两种情况等待得更多而不是更少:

  • 在冒号之前读起来相同的 DSN。 DSN 只比较到其驱动前缀之后的第一个冒号为止(IPv6 主机就是带有这种冒号的一种情况),因为 PDO 用冒号把 DSN、用户名和密码拼接成池键,且不做任何转义,所以一个以 :… 扩展的 DSN 可能与不带扩展的那个指向同一个池条目。在那个冒号之前读起来相同的数据源共享同一个等待,卡住的那个也不例外:首段数字组相同的两个 IPv6 主机会互相等待,就像以前一个工作进程上的所有构造函数那样。
  • uri: DSN 以及不是字符串的 DSN。 不重复 PDO 对它们所做的处理就无法解析它们,所以这样的构造函数会一直等到其工作进程上没有任何通向任意数据源的持久构造函数在运行,而在它运行期间,其他构造函数都要等待。它在等待时不持有任何东西,所以不会拖住其他构造函数;但只要一个通向从不应答的服务器的构造函数还在运行,它就找不到那个时刻,而当它的等待耗尽时,它会不加串行化地继续执行。所以上面那种服务器卡死的情况仍会波及 uri: 和对象形式的 DSN,而一个指向那台卡死服务器本身的此类构造函数,会在其连接建立持续期间拖住其工作进程上的所有持久构造函数,与以前完全一样。

这个等待和上文的等待一样有界,但等待耗尽时的代价不是客户端抛出的错误:预算耗尽的构造函数会不加串行化地继续执行,并在服务器日志里说明;以这种方式重叠的两个构造函数会像以前那样互相替换池条目。这会终结正在被替换的连接上读取应答的请求,而且由于池化句柄的释放与还有多少 PDO 对象指向它无关,其中某个对象之后被销毁时可能会终结整个工作进程。在用户态纤程调度器自己的纤程内部,这个预算会立刻耗尽:这样的上下文无法被挂起,所以发现另一个构造函数正在运行的构造函数会立即放弃。

另一步是 PDO 在交出池化连接之前所做的存活检查:在连接本身上发一次 ping。如果在持有者停着等应答时发出,它会被拒绝,PDO 会把连接读作已断开并打开第二条,于是应用本想共享的东西就不再共享了。所以,另一个纤程持有的连接会在不 ping 的情况下被报告为存活,但只对那些会让它原封不动的构造函数如此(在驱动不提供存活检查的地方,比如 pdo_sqlite,同样的答案会代替 PDO 自己的答案)。PDO 会把错误模式、自动提交标志以及其余选项写到它交出的池化句柄上,而在正被使用的连接上,这些写入会落到持有者身上。因此,只有当构造函数不会往连接上写任何新东西时,连接才会与它共享:

  • 它传入的每个选项都是 PDO 存储在句柄本身上的(PDO::ATTR_ERRMODE、PDO::ATTR_CASE、PDO::ATTR_ORACLE_NULLS、PDO::ATTR_DEFAULT_FETCH_MODE;点名池的 PDO::ATTR_PERSISTENT 条目不算),并且
  • 每个选项的值都与连接现有的值相同。

需要告知驱动的选项无论取什么值都会被拒绝,PDO::ATTR_AUTOCOMMIT 和 PDO::ATTR_STRINGIFY_FETCHES 也不例外,对所有驱动都是如此,第三方驱动也一样。不满足任一条件的构造函数会被告知连接已断开,并得到一条它自己的连接,服务器日志最多每秒说明一次。对于选项中带有 PDO::ATTR_EMULATE_PREPARES 或 PDO::ATTR_STRINGIFY_FETCHES 的池键(Laravel 的就是如此),这意味着:在请求之间共享,在先构造后查询的纤程之间也共享,但在另一个请求持有连接时到达的构造函数会打开它自己的连接。一次认领从持有者的第一次查询持续到其请求结束。

没有被使用的连接照旧检查,包括持有者已丢弃最后一个句柄的连接,所以真正已经断开的连接仍然会被替换。这个检查和其他命令一样是一条命令,也像其他命令一样被认领:在它进行期间经由受守护客户端到达的查询会等待它,在通常的界限之内。上文未覆盖的情况在这里同样不被覆盖。

启用与开销

1、true 和 all 启用所有类别,streams 和 net 也在内。已经为 sleep 钩子设置了 RUNTIME_HOOKS=1 的部署,升级后无需任何改动就会开始钩住套接字;如果那不是你想要的,请显式列出类别(RUNTIME_HOOKS=sleep,或用 RUNTIME_HOOKS=sleep,streams 保留 0.12.0 所启用的内容)。启用 streams 还会在启动时于内存中修补 PHP 流 ops 表的一个条目;那一页会被按原样放回,但在无法确定其原始保护属性的平台上,它会保持可写——一点硬化上的小损失,会在服务器日志中报告。启用 net 会以同样的条件修补其他套接字传输以及 OpenSSL 的 ops 表中同类的条目,并让桥接库为 PHP 自身代码发起的调用应答 poll();其他所有调用者,包括服务器自己,都会被直接透传给 libc。

开销,实测:在没有其他停放者的工作进程上,每次套接字往返约 3–5 µs;有 64 个纤程停在描述符上时约 5–6 µs;200 个时约 7–11 µs。就绪状态通过内核在两次等待之间保留的集合解析,所以这个数字追踪的是有多少描述符变为就绪,而不是有多少在等待。空闲的工作进程会在那些描述符上等待,而不是睡一个固定间隔、在下一个 tick 才注意到就绪——这一点非常重要:盲睡的代价大约是每次往返 2 ms。

宽的 stream_select() 带有窄情形没有的开销,因为调用点名的每个描述符都要在等待前注册、等待后移除。把等待本身剔除后测量——每个描述符都已可读,调用立即返回,只剩开销——1 个描述符时约 6 µs 对无钩子的 5 µs,64 个时 56 µs 对 9 µs,200 个时 130–150 µs 对 16–21 µs:大约每个描述符 0.65 µs。

请把它理解为每次调用的固定成本,而不是同一份工作的变慢。真正会等待的 stream_select()——select 循环本来就是为此写的——让它相形见绌:一毫秒的等待就让 200 描述符的数字降到整个调用的十分之一左右,而它买回的是工作线程——未钩住的调用会把线程整段等待都占着。有两种形态是例外,对它们这个类别最好保持关闭:跨很多描述符、几乎从不等待的调用,因为没有线程时间可回收;以及请求本身就是事件循环的情况,那里线程反正没有别的可跑。在单条连接上等待的客户端——mysqlnd、phpredis、HTTP 流封装器——位于表格顶端,开销在一微秒上下。

RUNTIME_HOOKS 与 oxphp_sleep() 的关系

钩子不取代第一方原语——它们覆盖不同的情形:

  • 在你自己写的代码里用 oxphp_sleep() / oxphp_usleep()。它们在工作进程模式下挂起纤程,无需设置任何环境标志,而且 oxphp_sleep() 接受小数秒(oxphp_sleep(0.25)),这是原生 sleep()(整数秒)表达不了的精度。它们并不能豁免于纤程无法挂起的地方:在那里它们和被钩住的内置函数一样占着工作线程。
  • 为你无法编辑的代码启用 RUNTIME_HOOKS=sleep,比如直接调用原生 sleep()/usleep() 的框架或第三方库。它默认关闭,在纤程之外不起任何作用,并保持原生的返回契约:被钩住的 sleep() 仍接受 int,挂起时返回 0,回落时返回内置函数的返回值。参数检查是它自己的,如上所述。

让你自己的处理器依赖 RUNTIME_HOOKS 来获得协作性,等于把它们的协作性绑在一项部署设置而不是代码上;在那里请优先使用显式的 oxphp_sleep()。

共享状态

进程内并发原语(OxPHP\Shared\Counter、Map、Channel、Mutex、Once、Pool、Atomic、Flag、Registry)。API 概览参见共享状态。

Variable Default Description
SHARED_ENABLED true 布尔——参见布尔值。整个 OxPHP\Shared\* 子系统的总开关。设为 false 时,它的类一个都不会注册:new OxPHP\Shared\Counter() 会抛出 Error: Class "OxPHP\Shared\Counter" not found,/__ox_shared/* 和 oxphp_shared_* 指标也不存在。在两种情况下都必须运行的代码里,用 class_exists('OxPHP\Shared\Counter') 来守护。不要用 interface_exists('OxPHP\Shared\Shareable') 来守护:它始终为真,因为扩展无论如何都会注册这个接口
SHARED_MAX_ENTRIES 100000 存活的共享对象数量的全局上限(每个 Counter、Map、Channel……各算一个)。超过后创建对象会以 CapacityException 失败。Map 内部的键不计入其中;要约束 Map,请使用 maxEntries
SHARED_MAX_BYTES 1073741824(1 GiB) 所有共享对象估算内存的全局上限,只在创建对象时检查(CapacityException)。已存在的对象可以继续增长越过它,所以总量读数可能高于上限
SHARED_SOFT_LIMIT_RATIO — 已弃用并被忽略;设置它会输出一条启动 WARN。它从未起过作用:使用量接近 SHARED_MAX_BYTES / SHARED_MAX_ENTRIES 时什么都不会被丢弃,这两者只会在达到后拒绝创建新的共享对象。如需提前预警,请对 oxphp_shared_capacity_saturation 设置告警,它在 SHARED_METRICS_ENABLED 开启时发布(参见 Shared State 可观测性)
SHARED_METRICS_ENABLED true 布尔。切换 oxphp_shared_* Prometheus 暴露
SHARED_INTROSPECTION_ENABLED true 布尔。切换内部服务器上的 /__ox_shared/* 内省 API
SHARED_INTROSPECTION_PREVIEW_ENABLED true 布尔。切换内省响应中的值预览(当预览可能泄露敏感数据时禁用)
SHARED_CYCLE_DETECT_DEPTH 16 环检测期间的 BFS 深度。对合法的深层图可调高
SHARED_CYCLE_DETECT_EDGES 10000 环检测期间遍历的边数。对合法的稠密图可调高
SHARED_MAX_VALUE_SIZE 1048576(1 MiB) 每个值的大小上限。插入更大的值会快速失败
SHARED_MAX_CHANNEL_BYTES 67108864(64 MiB) 每个 channel 的负载(请求体)总量上限
SHARED_POISON_STRICT — 已弃用并被忽略;设置它会输出一条启动 WARN。它从未起过作用,也不存在可供它设置的全局污染策略:Shared\Mutex 闭包内部抛出的异常绝不会污染该互斥锁,而失败的 Shared\Once 工厂是否永久禁用该单元,是按实例选择的;向构造函数传入 Once\FailureMode::Poison(参见 Shared\Once)
SHARED_LOCK_DIAGNOSTICS warn(二进制的 debug 构建中为 strict) Shared\Mutex 的跨线程死锁检测器:off、warn(记录日志并统计环)或 strict(还会以 DeadlockException 打破环)。仅限小写;任何其他值都会静默地得到默认值。参见死锁检测器
SHARED_LOCK_POLL_INTERVAL_MS 100 死锁检测器的扫描频率,以毫秒计。SHARED_LOCK_DIAGNOSTICS=off 时不使用
SHARED_PREVIEW_STRING_LIMIT 256 /__ox_shared/preview 预览中每个字符串的截断长度,以字节计(在字符边界处截断)
SHARED_PREVIEW_ARRAY_LIMIT 20 /entry?id=… 预览中采样的条目数

性能分析

发出 xhprof / speedscope 追踪的采样性能分析器。输出格式和查看器集成参见性能分析。

Variable Default Description
PROFILER_ENABLED false 布尔——参见布尔值。总开关。所有其他 PROFILER_* 变量在启动时仍会被解析,以便拼写错误立即暴露
PROFILER_SAMPLE_RATE 0.0 请求被采样的概率(0.0–1.0)。范围之外的值会被钳制
PROFILER_INTERNAL false 布尔。为真时,对内部服务器(/health、/metrics、插件端点)的请求也有资格被采样
PROFILER_AUTH_TOKEN (未设置) 显式触发器(X-OxPHP-Profile 头部、OXPROF cookie、__oxprof 查询参数)必须携带的密钥,也是内部服务器上 /__profiler/* 路由的 bearer 令牌。未设置或为空 = 不需要令牌:任何非空的触发值都能通过,/__profiler/* 路由无需 bearer 即可应答。PROFILER_SAMPLE_RATE 采样和 OxPHP\Profile\* PHP 函数从不查看它:在 PROFILER_ENABLED=true 下,调用 OxPHP\Profile\start() 的脚本会对自己的请求进行性能分析,不涉及任何令牌
PROFILER_MAX_SPANS 50000 每个请求的性能分析 span 上限。超过上限的性能分析会被截断
PROFILER_MAX_DEPTH 0(无上限) 记录的调用的最大嵌套深度;更深的调用以及(除非是超过 32 帧的自递归)它所调用的一切都不会出现在性能分析中。它们的时间仍在;它会被算作处于上限处那个调用的独占时间。被丢弃的调用仍计入 PROFILER_MAX_SPANS。会被钳制到 32,即引擎观察器跟踪的深度;在 PROFILER_ENABLED=true 时,更大的值会在启动时报告为已被钳制
PROFILER_OUTPUT_DIR /tmp/oxphp-profiles 磁盘上性能分析文件的目录
PROFILER_OUTPUT_FORMATS xhprof,speedscope 逗号分隔的要写入磁盘的输出格式列表
PROFILER_DISK_MAX_PER_SEC 10 每秒写入磁盘的性能分析文件的限流
PROFILER_RETENTION_COUNT 100 PROFILER_OUTPUT_DIR 中保留的最大性能分析文件数。较旧的文件会被清理
PROFILER_EXPORT_URL (未设置) 用于 POST 性能分析的远程端点。设置后,除非 PROFILER_OUTPUT_FORMATS 为空,否则仍会写入磁盘
PROFILER_EXPORT_FORMAT xhprof PROFILER_EXPORT_URL POST 的传输格式
PROFILER_EXPORT_AUTH_TOKEN (未设置) 随每次导出请求一起发送的可选 bearer 令牌
PROFILER_EXPORT_XHGUI (自动检测) 布尔。强制对导出负载进行 XHGui 兼容封装。未设置 = 当 PROFILER_EXPORT_URL 路径以 /run/import 结尾时自动检测(不匹配 host/query 提示)
PROFILER_EXPORT_BUGGREGATOR (自动检测) 布尔。强制使用 Buggregator 信封。未设置 = 当 PROFILER_EXPORT_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;格式错误的 token、空键或重复键都是启动错误

示例配置

开发

bash
LISTEN_ADDR=127.0.0.1:8080 DOCUMENT_ROOT=./public LOG_LEVEL=debug ACCESS_LOG=all PHP_WORKERS=1 INTERNAL_ADDR=127.0.0.1:9090

生产(框架模式)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php PHP_WORKERS=8 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error MAX_CONNECTIONS=10000 INTERNAL_ADDR=127.0.0.1:9090 RATE_LIMIT=100 RATE_WINDOW_SECONDS=60 TRUSTED_PROXIES=private HEADER_TIMEOUT_SECONDS=5 DRAIN_TIMEOUT_SECONDS=25 COMPRESSION_BROTLI_LEVEL=5 COMPRESSION_ZSTD_LEVEL=6 COMPRESSION_GZIP_LEVEL=6 STATIC_MAX_AGE=30d

生产(工作进程模式)

bash
LISTEN_ADDR=0.0.0.0:80 DOCUMENT_ROOT=/var/www/html/public WORKER_MODE_ENABLED=true ENTRY_FILE=../worker.php PHP_WORKERS=8 WORKER_MAX_MEMORY_MIB=128 QUEUE_CAPACITY=1024 LOG_LEVEL=warn ACCESS_LOG=error INTERNAL_ADDR=127.0.0.1:9090

TLS

bash
LISTEN_ADDR=0.0.0.0:443 TLS_CERT=/etc/ssl/oxphp/cert.pem TLS_KEY=/etc/ssl/oxphp/key.pem DOCUMENT_ROOT=/var/www/html/public ENTRY_FILE=index.php

查看当前生效的配置

当内部服务器运行时,查询 /config 端点以查看已解析的配置:

bash
curl -s http://localhost:9090/config | jq .
json
{ "access_log": "all", "async_in_flight_cap": 0, "async_max_fibers": 256, "async_queue_capacity": 0, "async_workers": 0, "brotli_level": 5, "deny_file": {"loaded": false}, "document_root": "/var/www/html/public", "drain_timeout_seconds": 25, "entry_file": "/var/www/html/public/index.php", "executor_type": "sapi", "gzip_level": 6, "header_timeout_seconds": 5, "listen_addr": "0.0.0.0:443", "log_level": "warn", "max_connections": 10000, "max_query_body": 524288, "php_workers": "8", "plugins": { "apm": { "db_capture_params": false, "enabled": true, "hooks_registered": 34, "slow_query_ms": 100 }, "otel": { "enabled": true, "protocol": "grpc", "service_name": "oxphp" } }, "queue_capacity": 1024, "queue_max_waiting": 1024, "queue_max_waiting_bytes": 67108864, "queue_wait_timeout_ms": 1000, "rate_limit": 100, "rate_window_seconds": 60, "runtime_hooks": [], "static_max_age": 2592000, "static_revalidate": false, "superglobals_enabled": true, "tls_enabled": true, "tls_min_version": "1.2", "tokio_workers": 4, "trace_context": true, "trusted_proxies": false, "worker_max_memory_mib": 0, "worker_mode_enabled": false, "zstd_level": 6 }
Note

对外提供的 /config 响应会清除内部 Config 表示所携带的少数几个键:TLS 证书和密钥路径永远不会被发出(tls_enabled 表示 TLS 是否处于激活状态),internal_addr 和 error_pages_dir 也会被移除——这些部署拓扑和文件系统路径有利于攻击者,且指标抓取器并不需要它们。

给你的智能体的提示词

这些提示词假定你的智能体已连接 OxPHP 文档 MCP 服务器——需要哪些页面它会自己去读。

prompt
帮我为这个项目调优 OxPHP。通过 OxPHP 文档 MCP 服务器读取 `operations/configuration` 页面。 看看这个仓库跑的是什么——框架、它负担得起的工作进程数量、它要处理的请求形态——然后给我一份生产环境的环境变量配置块,并为你选的每一个值说明理由。
prompt
检查我的 OxPHP 配置。通过 OxPHP 文档 MCP 服务器读取 `operations/configuration` 页面。 把这个仓库设置的环境变量和默认值逐一对照,告诉我哪些覆盖是白设的、哪些有风险,以及哪些默认值我本该覆盖却没有覆盖。

另请参阅

发现错误?报告问题 →