架构概览
OxPHP 是一个单二进制 HTTP 服务器,用于替代传统的 nginx + PHP-FPM 技术栈。它在单个进程中完成 HTTP 解析、TLS 终止、路由、PHP 执行、压缩和可观测性,运行时无需任何外部依赖。
OxPHP 的工作原理
OxPHP 在单个进程中融合了两个运行时层:
- 异步 HTTP 层。 一个事件驱动的网络层负责接受 TCP 连接、执行 TLS 握手、解析 HTTP 请求并发送响应。它使用非阻塞 I/O 处理数千个并发连接,因此一个慢速客户端永远不会阻塞另一个客户端。
- PHP 工作进程池。 一组专用的 PHP 工作进程执行你的 PHP 脚本。在标准模式下,每个工作进程一次处理一个请求。在启用了纤程多路复用的工作进程模式下,单个工作进程可以同时服务多个并发请求:当脚本调用
oxphp_sleep()或oxphp_async_await()时,纤程让出线程,工作进程切换到下一个请求。 - 异步池(可选)。用于执行通过
oxphp_async()提交的任务的独立操作系统线程。通过设置ASYNC_WORKERS > 0启用。它与工作进程池相互隔离,因此后台任务不会阻塞 HTTP 请求处理。
这两层通过一个有界队列进行通信。当一个需要执行 PHP 的 HTTP 请求到达时,异步层将其放入队列。空闲的 PHP 工作进程取出该请求,执行脚本,并将响应返回给异步层以交付给客户端。
这种分离意味着网络 I/O(接受连接、读取请求头、压缩响应、服务静态文件)永远不会与 PHP 执行争抢资源。每一层都能独立扩展。
工作进程池
PHP 工作进程池决定了可以并发执行多少个 PHP 脚本。OxPHP 支持两种池模式。
静态池
固定数量的工作进程在启动时创建,并在服务器的整个生命周期内保持运行。这是默认模式。
PHP_WORKERS=8 # exactly 8 workers
PHP_WORKERS=0 # auto-detect (default): half of available CPU cores, minimum 1动态池
工作进程根据需求上下伸缩。用冒号分隔指定最小和最大数量:
PHP_WORKERS=2:16 # start with 2, scale up to 16 under load当所有当前工作进程都在忙碌时,OxPHP 会创建新的工作进程,直至达到最大数量。当某个工作进程空闲时间超过 PHP_WORKERS_IDLE_SECONDS(默认:30 秒)时,它会被回收,使数量向最小值靠拢。
回收发生在工作进程手头没有任何在途请求的时刻,因此它仍在处理的请求总能跑完并给出自己的响应。同一条规则也划定了边界:持有一个永不结束的请求(一个打开的流、一次对不再应答的对端的读取)的工作进程永远等不到这样的时刻,在它持有该请求期间不会被回收。
队列与背压
在异步 HTTP 层和工作进程池之间是一个有界队列。它的容量默认为初始工作进程数乘以 128,可通过 QUEUE_CAPACITY 覆盖。对于静态池,初始数量就是配置的工作进程数。对于动态池(MIN:MAX),初始数量是最小值。
到达已满队列的请求不会被直接拒绝。它会等待一个空位,最长等待 QUEUE_WAIT_TIMEOUT_MS(默认:1000 毫秒),一旦有工作进程取走了排在它前面的请求,它就会被放行。等待中的请求按到达顺序放行。这份预算是在到达时就盖上的单一截止时间,并且会跟随请求进入队列:工作进程在截止时间过后才轮到的请求,会在取出时被拒绝而不是被执行。因此该预算约束的是整段等待,而不只是其中的准入一半——这一点很重要,因为队列足够深,在缓慢的进程池上排到队尾所花的时间,远超任何运维人员会设置的预算。负载卸除依据的是已经流逝的等待时间,而不是请求到达那一刻的队列深度:几微秒就能排空的突发流量会被正常服务,而真正跟不上的进程池仍然会卸除负载。
预算不约束的是执行本身。负载下的响应时间等于等待时间(先等准入,再在队列中等待)加上处理器运行的时长,而预算对后一部分不置一词。
QUEUE_MAX_WAITING 限制同时可以有多少请求在等待(默认:初始工作进程数 × 128,上限为 MAX_CONNECTIONS 的一半);超过它,准入就退回为立即拒绝。等待不是免费的。等待中的请求在被放行或预算耗尽之前,一直占着它的连接以及已经缓冲好的请求体,因此一个不设上限的等待集合会在持续过载下吃光所有连接许可,堵住接受循环,让服务器对新连接只能丢弃而无法应答。默认值近似于进程池在预算内实际能放行的请求数:超出这个数量的等待,只是抱着这些资源把一次拒绝往后推。MAX_CONNECTIONS / 2 这个上限只约束等待集合本身,它本身并不等于给接受循环留出的余量:排在队列里的请求在被工作进程取走之前一直占着连接,正在运行的请求同样占着(它的队列槽位在被取走时就已释放),而 QUEUE_CAPACITY 完全没有从连接数推导出的上限。所以 PHP_WORKERS + QUEUE_CAPACITY + QUEUE_MAX_WAITING 必须保持在 MAX_CONNECTIONS 之下。一旦总和达到该预算,接受循环就会在所有许可被占满的情况下停摆,此时到来的客户端得到的不是 529,而是完全没有响应。服务器会在启动时对此发出警告。消除警告是必要条件而非充分条件:从未到达 PHP 的连接同样占用许可。注意这些项以连接数计,而预算却由请求消耗:在 HTTP/2 上一条连接承载许多请求,因此以 h2 为主的部署只用连接预算的一小部分就能触及上限,应当显式设置 QUEUE_MAX_WAITING,并且总和高于预算也可能是合理的。
等待集合被限定了两次,因为按请求数计的上限对这些请求占用的内存不置一词:同样数量的等待者,在无请求体的 GET 上不花什么内存,在上传上却能花掉数 GB。QUEUE_MAX_WAITING_BYTES(默认 64 MiB)限定同时停放的请求体字节总量。超过它之后,携带请求体的请求会被立即拒绝而不是停放,而无请求体的请求照常等待。有两样东西它不覆盖,且都在等待预算出现之前就存在:已经交给队列的请求体——QUEUE_CAPACITY 以请求数约束它们,却没有任何东西以字节约束;以及请求体仍在从连接上被读取时占用的内存——没有任何聚合限制约束它。
当一个请求已经无法被服务、而不只是被推迟时——等待预算耗尽,或上面某个上限拒绝给它一个等待的位置——OxPHP 会返回带 Retry-After 头的 529 Site is Overloaded 响应。状态码 529(非标准,Cloudflare 等厂商在使用)能清晰地将过载与应用错误(500)和维护(503)区分开来,从而更容易配置告警和负载均衡器。设置 QUEUE_WAIT_TIMEOUT_MS=0 会禁用等待,在队列一满时就直接拒绝。
由于等待中的请求占用的是连接而不是队列槽位,过载之下的并发上限由 MAX_CONNECTIONS 而非 QUEUE_CAPACITY 决定。在 HTTP/2 上则是 MAX_CONNECTIONS 乘以 H2_MAX_CONCURRENT_STREAMS,因为每个流各自承载一个请求。设定等待预算时要把这一点考虑进去:请求到达队列时其请求体已经缓冲完毕,所以预算越长,真正过载的服务器在应答之前在内存里持有的请求——连同它们的请求体——就成比例地越多。
请求流程
无论是服务静态文件还是执行 PHP,每个请求都会经过相同的流水线:
graph TD
Client(["Client"]) --> TLS["TLS termination<br/>(if configured)"]
TLS --> Parse["HTTP parsing + Request ID"]
Parse --> Proxy["Trusted proxy resolution<br/>(if TRUSTED_PROXIES set)"]
Proxy --> Rate["Rate limiting check"]
Rate --> Route{"Route resolution"}
Route -->|Static file| Cache["File cache / disk read"]
Cache --> Compress["Compression + Response headers"]
Route -->|PHP request| Queue["Bounded queue<br/>(529 past the wait budget)"]
Queue --> Worker["PHP worker executes script"]
Worker --> Normal["Normal response"]
Worker --> SSE["SSE streaming (chunked)"]
Worker --> Early["Early response (finish_request)<br/>+ background work"]
Compress --> Deliver(["Response to client"])
Normal --> Deliver
SSE --> Deliver
Early --> Deliver
- TLS 终止。 如果配置了
TLS_CERT和TLS_KEY,OxPHP 会直接处理 TLS,无需单独的反向代理。 - HTTP 解析与请求 ID。 请求被解析,并生成一个唯一的请求 ID(或保留传入的
X-Request-ID头)。 - 可信代理解析。 如果设置了
TRUSTED_PROXIES且连接方 IP 受信任,OxPHP 会从Forwarded(RFC 7239)或X-Forwarded-*头中提取真实的客户端 IP、协议和主机。解析出的 IP 会用于后续所有步骤,包括限流和访问日志。参见可信代理。 - 限流。 如果设置了
RATE_LIMIT,客户端的 IP 会与每个 IP 的请求计数器进行核对。超出限制的请求会立即收到429 Too Many Requests响应。 - 路由解析。 URL 会与配置的路由模式(传统、框架或 SPA)进行匹配。结果是一个静态文件、一个 PHP 脚本或一个 404。工作进程模式(如果启用)会改变 PHP 执行已解析脚本的方式,但不会改变路由解析本身。
- 静态文件。 直接从内存缓存(针对频繁访问的文件)提供,或从磁盘流式传输。OxPHP 会自动添加
ETag、Last-Modified和Cache-Control头。 - PHP 执行。 请求被放入有界队列,由空闲的工作进程取出。如果队列已满,客户端会立即收到 529。
- 压缩。 当客户端发送
Accept-Encoding: br时,基于文本的响应会在发送前用 Brotli 压缩(可通过COMPRESSION_LEVEL配置)。 - SSE 流式传输。 如果脚本设置了
Content-Type: text/event-stream或调用了oxphp_stream_flush(),OxPHP 会切换到流式模式:每次flush()调用都会立即向客户端发送一个数据块,而不会缓冲整个响应。在工作进程模式下,SSE 与纤程多路复用协同工作。 - 提前响应。 调用
oxphp_finish_request()会立即将 HTTP 响应发送给客户端。脚本会在后台继续执行(写日志、更新缓存、发送通知),而无需保持连接打开。 - 响应交付。 完成的响应通过连接发回,如果启用了访问日志,还会写入一条日志记录。
工作进程模式 vs 标准模式
OxPHP 支持两种 PHP 执行模型:
为每个请求创建一个全新的 PHP 环境。自动加载器、配置和数据库连接在每个请求上初始化,并在之后销毁。这种模型开箱即用地兼容所有 PHP 应用。
让 PHP 进程在多个请求之间保持存活。你的应用只引导一次(加载自动加载器、配置并建立数据库连接),然后进入请求循环。在两个请求之间,OxPHP 会自动重置超全局变量、输出缓冲区和响应头,同时保留已引导的状态。
工作进程模式消除了每个请求的启动开销,对于引导成本高昂的框架类应用(Laravel、Symfony 等),这可以显著缩短响应时间。
要启用工作进程模式,请设置 WORKER_MODE_ENABLED=true,并让 ENTRY_FILE 指向一个调用 oxphp_worker() 的 PHP 脚本:
<?php
require __DIR__ . '/../vendor/autoload.php';
$app = new MyApp\Application();
oxphp_worker(function () use ($app) {
$app->handle();
});有关详细指南,请参见工作进程模式。
内部服务器
如果设置了 INTERNAL_ADDR 变量,OxPHP 会在指定端口上启动一个独立的 HTTP 服务器。它提供三个端点:
| 端点 | 描述 |
|---|---|
GET /health |
JSON 格式的健康状态(运行时长、请求计数器、连接数、工作进程状态)。正常运行时返回 200,性能降级时返回 503。 |
GET /metrics |
Prometheus 格式的指标——请求计数器、响应时间、队列等待时间、工作进程统计、压缩节省量。 |
GET /config |
JSON 格式的当前活动配置快照。TLS 文件路径会被脱敏。 |
内部服务器不经过 PHP 工作进程池或有界队列。它直接从异步 HTTP 层响应,因此即使 PHP 池满载,它仍然可以访问。这使得 /health 适合用于 Kubernetes 的存活/就绪探针。
有关详情,请参见内部服务器。
安全性
OxPHP 提供了多项保障,让你的应用在生产环境中可靠运行:
- 请求隔离。 如果一个 PHP 脚本崩溃或触发致命错误,只有那一个请求会受到影响。服务器会继续正常处理所有其他请求。崩溃的工作进程会被自动替换为一个全新的工作进程。
- 工作进程自动重生。 OxPHP 会监控所有 PHP 工作进程的健康状况。如果某个工作进程意外死亡,会自动启动一个新的工作进程来接替它,无需手动干预。
- 背压保护。 有界请求队列可防止过载。当服务器达到容量上限时,新请求会收到一个带有
Retry-After头的 529 响应,而不是无限期排队并引发级联超时。 - 路径遍历保护。 所有 URL 路径在访问文件系统之前都会经过清理。百分号编码的遍历尝试、
..片段以及逃逸出文档根目录的路径都会被阻止。 - 优雅关闭。 收到 SIGTERM 或 SIGINT(Ctrl+C)时,OxPHP 会停止接受新连接,并等待处理中的请求完成(最长等待一个可配置的排空超时时间),然后再退出。