设计取舍
主线位置:三条主线的汇总。请求主线上的取舍是「自己写 HTTP/1」「固定阶段的钩子」「一个 trait 四十多个钩子」「服务级的上游连接池」「TLS 后端用 cfg 四选一」;服务主线上的是「每个服务一个运行时」「平滑升级靠传 fd」;「默认值不设上限」横跨请求主线和缓存主线;「所有 crate 同一个版本号」横跨全卷。每一条只说收益和代价,机制在链接指向的页面里。
flowchart TB h1["自己的 HTTP/1"] --> idle["空闲不超时"] hook["固定阶段钩子"] --> wrap["不能包住一段"] big["42 个方法"] --> dflt["默认实现即策略"] rt["每服务运行时"] --> ns["NoSteal 共用反应器"] pool["服务级连接池"] --> hash["reuse_hash 定生死"] tls["cfg 四选一"] --> same["同名不同行为"] fd["传 fd 升级"] --> linux["只在 Linux"] nolim["默认不设上限"] --> user["上限要自己配"] ver["同一个版本号"] --> lock["锁文件混版本"]
每一节回答三个问题:pingora 选了什么,换来了什么,付出了什么。依据只有三种:本卷各页和本页跑出来的数,tag 0.9.0 上的源码与提交记录,hyper、tower、tokio 三卷的页面。对照的版本以那几卷为准:hyper 1.12.0 加 hyper-util 0.1.21,tower 0.5.3,tokio 1.54.1。第 13 节是本页补的两组实测:同一台机器上 pingora 和用 hyper 搭的代理各转发同一组请求,以及从 git 历史里数出来的 ProxyHttp 演变和各 crate 的版本变动。平台是 M2 Max、macOS 27、rustc 1.99.0,tokio 1.53.2(原因见怎么读这一卷)。nginx 本机没有,只写 pingora 的文档和源码自己说了什么(第 10 节)。
1. 自己写 HTTP/1,不用 hyper
选择。 cargo tree -p pingora@0.9.0 里没有 hyper。HTTP/1 在 pingora-core/src/protocols/http/v1/(13212 行),解析交给 httparse,编码、keep-alive、消息体的状态机都是自己写的;HTTP/2 直接用 h2(源码地图)。hyper 的做法是把一条连接上的 HTTP 做成库,代理逻辑留给用户(hyper 解决什么问题)。
换来。
- 头名的大小写原样过。请求头
X-MiXeD-CaSe到上游一个字母不变,上游写的x-UpStReAm也原样回到客户端(pingora 解决什么问题)。hyper 默认按小写写出,要打开preserve_header_case才保留(解析与编码)。pingora 为此在HeaderMap旁边存了一张CaseMap,0.9.0 删掉DerefMut也是为了护住两张表的配对(请求头与错误)。 - 代理需要的协议决定都在框架手里:0.9.0 默认删十个逐跳头和
Connection点名的头,拒绝重复Host、userinfo 和与绝对形式目标不一致的Host(HTTP/1 服务端);上游连接能不能放回池里由respect_keepalive和reuse决定,不由下游的Connection: close决定(HTTP 连接器)。 - 消息体先解码再交给钩子和模块,
response_body_filter、压缩模块拿到的是去掉分块框架的数据,可以改长度(HTTP/1 消息体、HTTP 模块与压缩)。
付出。
- 自己写,也就自己背边界情况。下游 HTTP/1 连接处理完第一个请求后,
respect_keepalive把 keep-alive 设成Some(0),即无限:只连不发 60.0 秒被关,发过一个请求后 100 秒还开着(HTTP/1 服务端)。本站网关上每条这样的空闲 TLS 连接约 68 KiB,两千条就超过容器的 128 MiB(本站的网关)。hyper 的header_read_timeout默认 30 秒,也管 keep-alive 的空闲等待,只是没给 timer 时不生效(Executor 与 Timer)。 - pipelining 默认关,而且关的方式是只回第一个响应、然后关连接,后面的请求没有回应(HTTP/1 服务端)。hyper 对管线化的请求串行处理,三个各睡 200 ms 的请求一共约 600 ms,但都有回应(Dispatcher)。
- 每读一次就用栈上 256 个槽的
httparse::Request把整个缓冲重新解析一遍;超过 256 个头直接 400(HTTP/1 服务端)。 - 先解码再编码,chunk 扩展和响应方向的 trailer 都丢了;一个只透传字节的 491 行 mini 代理反而都留着(写一个迷你代理)。
Expect: 100-continue不处理,原样转发(HTTP/1 服务端)。
自己写不是为了快。第 13 节同机实测:单连接串行请求,pingora 每个请求的 p50 是 58 到 59 µs,hyper 搭的代理是 55 到 56 µs;64 个连接时两者都在每秒 4.1 万上下,pingora 低 1% 到 3%。
2. 固定阶段的钩子,而不是 Service 套 Service
选择。 阶段的顺序写死在 HttpProxy::process_request 里:early_request_filter → 下游模块 → request_filter → 缓存 → proxy_upstream_filter → 重试循环,每轮从 upstream_peer 开始(process_request)。下游模块 HttpModule 也不包裹下一层,只在固定位置按 order() 被调用(HTTP 模块与压缩)。tower 的中间件是 Service 套 Service,每一层都能在调用内层前后各做一件事,也能不调用(一个请求穿过一摞中间件)。
换来。
- 用户不写转发循环。一个普通 GET 依次调到 18 个方法,请求体和响应体的双向转发、上游连接的取用和回收都在钩子之间完成(一次请求)。h1 的双向转发是
try_join!里的两个select!循环加两条容量 4 的管道,上游慢读时背压稳定在约 1.1 MB(双向转发)。用 hyper 写代理,请求体怎么交给上游、响应体怎么流回来,由用户在Service里组织;第 13 节的实验是把Incoming原样交给 hyper-util 的Client,中间没有任何可以插代码的位置。 - 缓存可以插在阶段之间而不改用户代码:
proxy_cache在request_filter和proxy_upstream_filter之间,10 个、100 个并发未命中都只打上游一次(缓存锁)。 - 重试在框架里:失败后回到
upstream_peer,用户只决定换不换上游(错误、重试与故障转移)。
付出。
- 不能「包住」一段。tower 的
Timeout一层就包住内层的整个调用;pingora 要量一次上游往返,得在两个钩子里各记一笔、经CTX传过去。 - 钩子之间的约定框架不检查。
request_filter返回Ok(true)表示「已经回过响应」,只返回不写,客户端一直等到自己超时(ProxyHttp)。tower 的call必须交出一个响应 future,类型系统保证有回应。 - 出错时走哪条路由阶段决定:拿到上游连接之后的错误进
error_while_proxy,之前的直接fail_to_proxy,upstream_peer的错误两个都不经过;畸形请求头和CONNECT在new_ctx之前就被拒,logging看不到(ProxyHttp、process_request)。 - 名字里带「upstream」的钩子看到的不是上游的原样:
upstream_response_filter排在解压之后,已经没有Content-Encoding了(上游模块与自定义转发)。 - 没有就绪的概念。tower 的
poll_ready让一层在请求进来之前说「接不了」(tower 的取舍);pingora 的钩子在请求读完之后才被调用。accept 之后、读请求之前唯一的过滤点ConnectionFilter在 accept 循环里串行await,慢一点整个端口排队(上游模块与自定义转发)。
3. 一个 trait,四十二个方法
选择。 ProxyHttp 0.9.0 有 42 个方法,必须实现的只有 new_ctx 和 upstream_peer,12 个缓存钩子,17 个 async;每个请求一个新的 CTX(ProxyHttp)。另一种做法是拆成几个小 trait,或者 tower 那样每个关注点一个中间件。
换来。 加钩子不破坏实现者。第 13 节按 tag 数了这个 trait:0.1.0 有 25 个方法,0.9.0 有 42 个,八次发版里只加不删,0.9.0 新加的 6 个没有改动任何已有方法的签名。本站网关从 0.8.0 换到 0.9.0,一行不改、编译和测试都通过(本站的网关)。
付出。
- 改签名就是所有人一起改。0.7.0 一次改了三个已有方法:
cache_hit_filter的返回类型、upstream_response_filter改成async、upstream_response_body_filter改成返回Option<Duration>(第 13 节)。 - 默认实现就是策略,策略一变,行为跟着变。
error_while_proxy的默认实现在 0.9.0 才开始看 HTTP 方法:0.8.0 在复用连接失败时把 POST 发两次,0.9.0 回 502,没有覆盖这个钩子的程序升级后行为就变了,而 CHANGELOG 0.9.0 没有记这一条(错误、重试与故障转移)。默认的response_cache_filter一律不缓存,cache_key_callback是unimplemented!(键、元数据与可缓存性)。 - 不用缓存也带着缓存。12 个缓存钩子在每个实现者的 trait 上;
pingora-proxy无条件依赖pingora-cache,只开proxy的依赖树也有 160 个包,每个请求都会走一遍proxy_cache的开头(源码地图)。 - 同步还是异步按钩子定。响应体和尾部的四个钩子、
fail_to_connect、error_while_proxy是同步的,响应体钩子想限速只能返回一个Duration让框架代为sleep;main 上已经改成async,未发版(ProxyHttp)。
4. 每个服务一个运行时,默认工作窃取
选择。 run_service 给每个服务建一个以服务名命名的运行时。work_stealing: true(默认)时是一个 tokio 多线程运行时;false 时是 N 个单线程运行时,current_handle 均匀随机地挑一个线程放新连接(运行时)。tokio 自己选的是任务窃取(tokio 的设计取舍);hyper 不建运行时,用的是调用者给的执行器(hyper 的取舍)。
换来。
- 服务之间互不抢线程,
ps -M和sample里按服务名就能看出谁在忙;一个进程的线程数是1 + 1 + Σ 每个服务的 threads + 1(运行时)。 - 默认的 Steal 在负载不均时守得住:64 个轻连接加 2 个每次忙等 2 ms 的重连接,Steal 跑 5 次都是 0/64 个轻连接变慢,轻请求 p50 在 0.709 到 0.719 ms 之间(运行时)。
付出。
- NoSteal 的随机分配纠正不了不均:同样的负载 5 次是 64/64、14/64、36/64、32/64、38/64 个轻连接变慢。而且这些线程并不独立:tokio 的
accept把新 socket 注册在 accept 线程的反应器上,pingora 没有重新注册,accept 线程忙 2 ms,其他线程上的回显 p50 从 125 µs 变成 2013 µs,重新注册后是 126 µs(运行时,坑 在钩子里阻塞)。 - NoSteal 存在的理由在本机复现不出来。
pingora-runtime的 crate 文档说它「as efficient as the single-threaded runtime」,benches/hello.rs里记着 M1 上 wrk 测的 NoSteal 281689 对 Steal 216918 请求每秒;本卷 64 个连接、threads: 4时 Steal 是 89412 到 89732,NoSteal 是 77625 到 84443(运行时)。 - Steal 不会把连接摊开:1000 个依次到来的新连接全在同一个工作线程上处理,工作窃取只在有积压时起作用(运行时)。
- 在 tokio 之上又造了一层计时器。
pingora-timeout有自己的时钟线程,定时器按 10 ms 归格:登记加取消 144 ns 对 tokio 1.53.2 的 274 ns,8 线程并发时 372 ns 对 3127 ns;代价是到期晚 0 到 20 多毫秒,1 ms 的超时 p50 晚 9.8 ms(超时)。 - 钩子里的阻塞调用只拖慢自己所在的线程,但
HttpPeer::new本身就在调用线程上同步解析域名:一次慢解析把同一工作线程上的其他请求一起卡住(L4 连接)。hyper-util 的HttpConnector把getaddrinfo放进阻塞线程池(HttpConnector)。
5. 上游连接池按服务共享
选择。 HttpProxy 只持有一个 Connector,整个服务的所有下游连接、所有工作线程共用它的池。0.9.0 的 ConnectionPool 是 DashMap 分组加一个 16 分片的全局 LRU,组键是 HttpPeer::reuse_hash(连接池)。hyper-util 的 legacy Client 也有池,但要用户自己建 Client、自己决定共享范围(连接池)。
换来。
- 换了下游连接照样复用:第二、第三个请求用的是同一个上游 fd、同一个本地端口(一次请求)。
- 死连接在被取出之前就清掉了。每条空闲 h1 连接一个
idle_poll任务盯着,上游关掉空闲连接后下一个请求直接新建,没有错误;mini 代理没有这个任务,拿到死连接回 502(写一个迷你代理)。 - 复用判定是框架定的:地址、scheme、SNI、证书校验相关的字段、
group_key等进哈希,用户不用自己算(Peer)。
付出。
- 哈希漏一项就是串连接。每个 peer 自己的
ca不在reuse_hash里:按 CA X 校验过的连接,可以被要求 CA Y 的 peer 复用(Peer)。 - 0.9.0 换成全局 LRU 时,
upstream_keepalive_pool_size的意思从「每线程」变成「乘以threads的全局上限」;并发放回时 LRU 和PoolNode对不上,池大小 16、8 个线程各放 1 万条,最后留下 2302 到 5435 条 LRU 不知道的连接,0.8.0 是 128 到 190 条(连接池)。 - 上游在请求到达那一刻关连接时,复用连接上的失败按方法分:GET 被透明重试,上游收到两次;POST 在 0.9.0 回 502,在 0.8.0 发两次(连接池,坑 复用连接上的 POST)。hyper-util 只重发还没开始写的请求,开着重试的三次运行里仍有 167、195、194 次
IncompleteMessage(legacy Client)。两边选的是同一个问题的两头:pingora 替你重试幂等请求,hyper-util 一律不重发已经写出的。 - 空闲连接先进先出地轮流取用(一次请求);hyper-util 是后进先出,拿到的是空闲最短的那条,离对端的 keep-alive 超时最远(连接池)。
- h2 上游默认不多路复用:
max_h2_streams默认 1;设成 100 预热之后,并发下仍要多建 1 到 4 条(HTTP 连接器)。
6. TLS 后端四选一,用 cfg 拼出一套 API
选择。 pingora-core 的 lib.rs 按 feature 把 pingora_openssl、pingora_boringssl、pingora_rustls、pingora_s2n 之一改名成 tls,一个都不开时是空壳 noop_tls;按 any_tls、openssl_derived 写的 cfg 有 40 处,protocols/tls/mod.rs 把所选后端的模块 pub use ...::* 进来(怎么读这一卷、TLS)。没有 trait,没有动态分发。hyper 里没有任何 TLS 代码,TLS 流由别的 crate 包好再当 IO 交给它(hyper 解决什么问题)。
换来。
- 业务代码只认
pingora::tls一个路径。TLS 那页的实验用同一份main.rs分别编 openssl 和 rustls 两个后端,常规握手两边一致(TLS)。 - 每个后端的全部能力都露得出来:openssl 版
TlsSettingsDeref到SslAcceptorBuilder,openssl 的配置全都能用;certificate_callback这种只有 openssl 系才有的能力,不必为了一个公共 trait 砍掉(TLS)。
付出。
- 两个后端同时开就编不过:
error[E0252]: the nametlsis defined multiple times,后面还有 74 个连带错误,而lib.rs那段注释说同时开时「prefer boringssl」。feature 在依赖图里统一,所以整个依赖图里只能有一个(怎么读这一卷,坑 两个 TLS feature)。 - 一个都不开也编得过、启得动:下游握手时 panic「No tls feature was specified」,连 TLS 上游时写进一个吞掉所有数据的假流,客户端拿到 502(怎么读这一卷,坑 没有 TLS feature)。
- 同名的函数不保证同样的行为。openssl 的
intermediate是 Mozilla Intermediate v5、当场读证书;rustls 的只记路径,证书读不到时在服务线程里 panic,进程不退出,HTTPS 端口悄悄不在。客户端只提议未知 ALPN 时,openssl 继续握手,rustls 失败(TLS,坑 rustls 证书)。cfg 拼 API 只保证名字对得上,行为要逐个后端测。
7. 平滑升级靠传监听 fd
选择。 SIGQUIT 时旧进程经 Unix socket 的 SCM_RIGHTS 把所有监听 fd 交给带 -u 启动的新进程,自己再等 5 秒(CLOSE_TIMEOUT)后按 SIGTERM 的流程退出(平滑升级与停机)。hyper 那一侧没有进程管理,停机靠 hyper-util 的 GracefulShutdown 在一个进程里关连接(优雅停机)。
换来。 内核里的监听 socket 和它的 accept 队列一直都在,graceful.md 承诺的是「No request will see connection refused」。不带 -u、靠新进程重试 bind 的话,macOS 上两次实测各有 18、20 个探针被拒,空窗约半秒(平滑升级与停机)。
付出。
- 只在 Linux 上实现。macOS 上
send_fds_to什么都不做、返回Ok(0),旧进程照样打印「listener sockets sent」;新进程第一步就退出。从 SIGQUIT 后 5 秒起端口没人监听,比不带-u还糟(平滑升级与停机,坑 macOS 上升级)。 - 新旧两个进程要对上节拍:两边按常量
MAX_RETRY = 5每秒重试一次,最多等 5 秒左右;本该可配的upgrade_sock_connect_accept_max_retries没有接进来(配置与命令行)。0.9.0 的daemon_wait_for_ready只能配bootstrap_as_a_service,配普通的bootstrap()时父进程在超时(默认 600 秒)后以 1 退出,守护进程却活得好好的(平滑升级与停机)。 - 停机这一半是无条件的睡眠。宽限期是主线程上的
thread::sleep,默认EXIT_TIMEOUT= 300 秒,连接早就处理完也要睡满;超过宽限期的请求被切断,logging不调(一个 Server 的一生)。本站网关在 docker 下每次重启都等到 10 秒后被 SIGKILL;配上 5 秒宽限期和 1 秒停机超时后,6.54 秒自己退出(本站的网关,坑 宽限期 300 秒)。 main_loop只处理第一个信号:SIGTERM 之后再发 SIGINT、SIGQUIT 都没用,宽限期里想提前结束只剩 SIGKILL(平滑升级与停机)。
8. 默认值不设上限
选择。 一组和资源有关的默认值是「不限」。源码自己在 keepalive_request_limit 的注释里写明了态度:「Unlike nginx, the default behavior here is no limit.」
| 默认值 | 后果 | 页面 |
|---|---|---|
| HTTP/1 第一个请求之后的空闲等待:无限 | 发过一个请求的连接 100 秒后还开着 | HTTP/1 服务端、坑 永不空闲超时 |
| h2 握手、h2c 前言检测:没有超时 | TLS 上协商了 h2 却不发前言,连接一直开着;不足 24 字节的明文请求一直等 | HTTP/2、本站的网关 |
PeerOptions 的连接、读、写超时:全是 None | 上游不回应时连接永远挂着 | Peer、坑 没有连接超时 |
max_retries:16 次总尝试 | 设成 0 是一次都不连,客户端拿到空回复,没有日志 | 错误、重试与故障转移 |
| 宽限期:300 秒 | 容器停机等不到,被 SIGKILL | 一个 Server 的一生 |
缓存:不看方法、不删 Set-Cookie、不看 Vary | 开了缓存,POST 命中 GET 的条目,cookie 串给别人 | 键、元数据与可缓存性、坑 缓存的不安全默认 |
keepalive_request_limit:不限 | 一条下游连接能处理的请求数没有上限 | 上面引的注释 |
自带的缓存存储 MemCache:没有容量上限 | 文档写明只供测试,生产要自己实现 Storage | 存储与淘汰 |
换来。 框架不替用户猜。不限不会在某个隐藏的阈值上把合法流量切掉,适合把 pingora 当零件、上限由外层配置统一给的用法。
付出。 没配的人就没有上限。0.8.0 及以前,h2 的并发流数和头列表大小也是不限的,0.8.1 作为安全修复、0.9.0 作为「Potential Breaking Changes」才收紧成 100 和 64 KiB;而传 Some(H2Options::new()) 会把这两个上限一起丢掉(HTTP/2,坑 H2Options::new)。hyper 走的是另一条路:它在文档里声明默认值不受 semver 约束,好在小版本里收紧,v1.3.0 一次把 h2 头列表上限从 16 MB 降到 16 KB(hyper 的取舍)。pingora 0.9.0 也在收紧(逐跳头、h2 上限、POST 不重试),前两条进了 CHANGELOG 的破坏性变更,第三条没有。
9. 所有 crate 同一个版本号
选择。 工作区 22 个 crate 用同一个版本号,一起打一个 tag,tag 名就是版本号;crates.io 上的包和 tag 是同一个 commit(怎么读这一卷)。crate 之间的依赖写成 version = "0.9.0",也就是 ^0.9.0,不是 =0.9.0。
换来。
- 读者和用户只需要记一个数。本卷所有链接都指向 tag
0.9.0,不用像 embassy 那样一个 crate 一个 tag。 - 跨 crate 的改动一次落地:
DerefMut的删除同时改了pingora-http和pingora-proxy(请求头与错误),Prometheus 从pingora-core拆成pingora-prometheus,依赖方向一次反过来(源码地图)。
付出。
- 版本号不再说明 crate 有没有变。第 13 节按 tag 比了
src/:0.8.0 到 0.9.0,pingora、pingora-error、pingora-limits、pingora-ketama、pingora-memory-cache、TinyUFO六个 crate 的src/一行没改,照样升到 0.9.0;0.8.1 主要是安全修复,改了src/的是pingora-core、pingora-http、pingora-rustls三个,其余 17 个跟着升号。 - 「同一个版本号」只是发布时的约定,Cargo 并不强制。
^0.8.0的依赖允许一个锁文件里同时出现 0.8.0 和 0.8.1:本站网关的锁文件里pingora-openssl是 0.8.1,pingora-core还是 0.8.0,正好错过了 0.8.1 在pingora-core里的 h2 上限修复(本站的网关)。 - 补丁版要从旧 tag 拉分支。0.8.1 不在 0.9.0 的历史里,同一个修复提交了两次,0.9.0 的 CHANGELOG 里也没有 0.8.1 这一段(见下面的冷知识)。
- 外部依赖跟着工作区一起放开:
tokio = "1",所以今天新建的项目会解析到 tokio 1.54.1,pingora 0.9.0 接受第一个连接就 panic;修复5b30d71在 main 上,要等下一个总版本(怎么读这一卷,坑 tokio 1.54)。
embassy 走的是反方向:34 个 crate 各自发版,小 crate 能单独发补丁,代价是包、tag 和 CHANGELOG 互相对不上(embassy 的设计取舍)。
0.2.0 到 0.9.0 的 tag 都在 main 上,前一个是后一个的祖先;0.8.1 不是(最早的 0.1.0、0.1.1 两个 tag 指向各自的「Releasing」提交,也不在 main 上)。git merge-base 0.8.1 0.9.0 是 0.8.0 的 faac65b:0.8.1(719ef6c,「Release 0.8.1」,2026-06-04)是在 0.8.0 上另开的一段,main 上没有它。它的主要内容是 d193c8d「Bound default HTTP/2 server limits to mitigate memory exhaustion」(2026-06-04,只改 pingora-core/src/protocols/http/v2/server.rs,117 行增、3 行删),git tag --contains 只有 0.8.1。main 上前一天已经有一个同名提交 90890bb(2026-06-03,同一个文件、同样的增删,外加 .bleep),它首次发布于 0.9.0。两个提交都没有 PR 号,提交说明只有标题。所以 0.8.1 的 CHANGELOG 里那条「🔒 Security:Bound default HTTP/2 server limits to mitigate memory exhaustion」,在 0.9.0 的 CHANGELOG 里找不到出处:0.9.0 的 CHANGELOG 从 0.9.0 一节直接接到 0.8.0 一节,同一件事换了一种说法,出现在「Potential Breaking Changes」(「Default HTTP/2 server limits are bounded rather than unbounded」)和「Security & Hardening」里。只看 main 的 CHANGELOG,看不出 0.8.1 存在过。
10. 和 nginx 的对照:pingora 自己说了什么
本机没有 nginx,这一节只写 pingora 0.9.0 的文档和源码里能核实的。
- 文档里没有 nginx。
README.md和docs/在 tag 上git grep -i nginx一处都没有;「配置驱动对写代码」这个对照是本卷的说法(pingora 解决什么问题),不是上游的。 - 源码把 nginx 当行为基准。 非测试代码里提到 nginx 的注释,多数是「和 nginx 一样」:
pingora-ketama自称「Rust port of the nginx consistent hash function」,每个权重 160 个点的常量注释「copied from nginx」,1001 行 nginx 采样数据全部对上(选择算法);上游 SNI 为空时关掉证书校验,注释说「consistent with nginx's behavior」(connect);压缩时弱化 ETag「Follow nginx gzip filter's example」(HTTP 模块与压缩);conditional_filter.rs用 nginx 的 ETag 解析算法,并在注释里记下 nginx 先查If-Modified-Since的差别;带Upgrade的请求体「immediately switch interpretation to match nginx」;proxy_cache.rs的 Range 处理有五处注释对照 nginx。 - 明说和 nginx 不同的一处,就是上一节引的
keepalive_request_limit:nginx 的文档建议定期关连接释放内存,pingora 默认不限。 - 测试拿 nginx 当上游。
pingora-proxy/tests/utils/conf/origin/conf/nginx.conf是集成测试的源站配置,test_upstream.rs的注释描述的也是 nginx 源站的行为。
能从这些说出的只有:pingora 在协议细节上以 nginx 的行为为参照,在资源上限上故意不跟。nginx 本身怎么实现,本卷不讲。
11. 上游问题记录
本卷各页复现过的上游问题(和文档或代码自己的约定不一致,或者是会 panic、会泄漏的实现缺陷),以 2026-10-22 的 main(84f21ee)为准分两类。都没有向上游提交。
0.9.0 上有,main 上也没改:
| 问题 | 页面 |
|---|---|
| HTTP/1 下游第一个请求之后空闲等待没有超时(0.6.0 起;0.8.0 相同) | HTTP/1 服务端、坑 永不空闲超时 |
| h2 服务端握手、h2c 前言检测没有超时 | HTTP/2、本站的网关 |
| NoSteal 下所有下游 socket 留在 accept 线程的反应器上 | 运行时 |
| macOS 上 SIGQUIT 报「listener sockets sent」而什么都没发 | 平滑升级与停机、坑 macOS 上升级 |
upgrade_sock_connect_accept_max_retries 没有代码读(0.7.0 起) | 配置与命令行 |
| 并发放回时连接池留下 LRU 不知道的连接 | 连接池 |
每个 peer 的 ca 不在 reuse_hash 里 | Peer |
HttpModules 对 order() 取负,i16::MIN 在 debug 下 panic | 上游模块与自定义转发 |
max_retries: 0 一次都不连、不记日志 | 错误、重试与故障转移、坑 max_retries 为 0 |
缓存默认不调 request_cacheable,POST 命中 GET 的条目;最大文件大小在中途掐断分块响应 | 键、元数据与可缓存性、存储与淘汰 |
Rate 的窗口小于 1 秒、首秒内并发调用断言失败;权重全为 0 时除以零 | 限流、选择算法、坑 Rate 竞争、坑 权重为 0 |
RTCache 的写者被取消后锁永远留下 | TinyUFO 与内存缓存、坑 RTCache 取消 |
main 上已修,还没有发版:
| 问题 | main 上的改动 | 页面 |
|---|---|---|
tokio 1.54 起零容量 BufReader 断言,第一个连接就 panic | 5b30d71(2026-10-19) | L4 流、坑 tokio 1.54 |
h2 下游的空 END_STREAM 让 h1 上游收到两个结束块 | 10a8345 | HTTP/1 消息体 |
Estimator 计数溢出在 debug 下 panic | c1c4a0d(#1041) | 限流 |
12. 文档与代码不符
本卷各页核对出的上游文档、CHANGELOG、源码注释与 tag 0.9.0 上的代码不符之处。本页在 tag 上逐条复核过(git show 0.9.0:<文件>),又补了 3 处(标「本页」)。只缺内容、不说错话的地方(比如 phase.md 的「Cache filters」一节是「To be documented」)不列。
| 文档 | 说法 | 代码 | 页面 |
|---|---|---|---|
pingora-core/src/lib.rs crate 文档 | openssl「default if no TLS feature is specified」 | default = [],不开时是空壳 noop_tls | 怎么读这一卷 |
| 同一文件第 107 行的注释 | 同时开 openssl 和 boringssl 时「prefer boringssl」 | 四行 pub use ... as tls 并列,E0252 加 74 个连带错误 | 怎么读这一卷 |
docs/user_guide/internals.md | NoSteal 是「N isolated single threaded runtimes」 | 下游 socket 都在 accept 线程的反应器上,那个线程一忙全服务变慢 | 运行时 |
conf.md 的 work_stealing 一行 | 「See Pingora runtime (WIP) section」 | docs/ 里没有这一节,index.md 里这个标题没有链接 | 运行时 |
conf.md 的字段表 | 列出的就是配置项 | 漏了 grace_period_seconds、graceful_shutdown_timeout_seconds、max_retries 等 8 个字段 | 配置与命令行 |
conf.md 的 daemon_wait_for_ready | 父进程等守护进程的 SIGUSR1 | 只有配 bootstrap_as_a_service 才会发;配 bootstrap() 时父进程超时后以 1 退出 | 平滑升级与停机 |
start_stop.md | -c 的默认值是「empty string」;SIGTERM 后「wait for some preconfigured time」 | Option<String>,不给是 None;不配置时是 300 秒 | 配置与命令行、一个 Server 的一生 |
graceful.md | 「No request will see connection refused」 | 只在 Linux 上实现;macOS 上 -u 让端口在 SIGQUIT 5 秒后无人监听 | 平滑升级与停机 |
CHANGELOG 0.7.0、upgrade_sock_connect_accept_max_retries 的文档 | 「Add configurable retries for upgrade sock connect/accept」;字段「controls how many times send_fds_to will retry」 | 两个调用方都传 None,固定 5 次 | 配置与命令行 |
Opt 的 -u 帮助文字 | 「This is the base set of command line arguments for a pingora-based service」 | 这是整个命令的描述,写在了 upgrade 字段上 | 配置与命令行 |
listener_tasks_per_fd 的文档 | 「This allows for parallel accepts」 | NoSteal 下所有 accept 任务在同一个线程上 | 服务与监听器 |
panic.md | 请求里的 panic「does not affect ... the server's ability to handle other requests」 | 只对请求任务成立;accept 循环里的 panic(tokio 1.54)、服务启动时的 panic(rustls 证书、端口被占)让整个监听服务停掉,进程还活着 | 怎么读这一卷、TLS、服务与监听器 |
phase_chart.md(phase.md 里同一张图) | 图上没有 proxy_upstream_filter 和缓存钩子;request_body_filter 在「send request」之前;「any response filter error」都进 error_while_proxy | 请求头先发出、请求体和读响应同时进行;错误按阶段分别进 error_while_proxy 或 fail_to_proxy | ProxyHttp |
phase.md | feature 叫 adjust_upstream_modules;「Most filters are async」;logging「Every request will end up in this final phase」 | feature 叫 upstream_modules;42 个里 17 个 async;400 和 405 不调任何钩子 | ProxyHttp |
ProxyHttp 的文档 | 任何钩子返回 Err,「the request will fail」 | request_cache_filter 的 Err 只打 warn;h2 上游的 response_trailer_filter 的 Err 只记日志 | ProxyHttp |
errors.md 的例子 | request_filter 校验通过后 Ok(true) | Ok(true) 表示「已经回应」,照抄后每个合法请求都挂住 | ProxyHttp |
| CHANGELOG 0.9.0 | 没有提 error_while_proxy 的默认策略 | 6fa3835 起非幂等方法不再重试,0.8.0 会把 POST 发两次 | 错误、重试与故障转移 |
pooling.md | 两个 Peer「are the same if and only if」八项相同 | reuse_hash 还哈希 group_key、max_h2_streams、curves、second_keyshare;每个 peer 的 ca 不在里面 | Peer |
peer.md、Peer::connection_timeout 的文档 | 限制建立「a TCP connection」/「connect() call」 | 分别包住 TCP 连接和 TLS 握手 | L4 连接 |
ConnectorOptions 的 keepalive_pool_callback | 连接「before leaving the keep-alive pool without reuse」时调用 | 只在被 LRU 挤掉时调用,对端关闭和空闲超时不调 | 连接池 |
同一结构体的 bind_to_v4 / bind_to_v6 | bind_to_v4 的注释是「source IPv6 addresses」,bind_to_v6 的是「IPv4」 | 两句注释写反了 | 本页 |
pingora-pool 的 N_SHARDS | 「Independent of the N_SHARDS constant in crate::connection」 | connection.rs 里没有这个常量,只有 HOT_QUEUE_SIZE | 本页 |
ConnectionFilter 的文档示例 | should_accept(&self, addr: &SocketAddr);依赖写 version = "0.5" | 签名是 Option<&SocketAddr>,照抄编不过 | 上游模块与自定义转发 |
pingora-timeout 的 crate 文档 | 4 ns 对 tokio 的 107 ns | tokio 1.53.2 上不登记时两边只差 6 ns;登记加取消是 144 ns 对 274 ns | 超时 |
TinyUfo::new 的文档 | estimated_size 是「the given size limit of the ghost queue」 | TinyUFO 没有幽灵队列,这个数只定草图大小和哈希表预分配 | TinyUFO 与内存缓存 |
quick_start.md | 加上健康检查后「all requests succeed and the broken peer is never used」 | 只对一开始就坏的后端成立;运行中杀掉一个,下一次检查之前有 16 个 502 | 健康检查与后台服务 |
rate_limiter.md | 「any later requests that arrive within 1s of a previous request should fail」 | 所有键共用一个窗口,同一个 appid 隔 150 ms 两次都通过 | 限流 |
CHANGELOG(tag 0.9.0) | 从 0.9.0 一节直接接 0.8.0 一节 | 0.8.1 已经发布(tag 0.8.1,2026-06-04),不在 main 的历史里 | 本页 |
28 处里 15 处只在 docs/ 和 CHANGELOG 里,11 处只在源码注释、rustdoc 和命令行帮助里,2 处两边都有。前一类集中在两种:功能改了、文档没跟(phase_chart.md 停在缓存和上游模块加进来之前,conf.md 的表格漏了后来加的字段);文档描述的是 Linux 上的行为,没写平台。后一类多是注释比代码老:ConnectionFilter 的示例还写着 0.5,pingora-timeout 的基准数字来自旧版 tokio。
13. 实测:同机上的两个代理,以及从历史里数出来的数
pingora 对照 hyper 搭的代理
第 1 节需要一个数:自己写的 HTTP/1 加一整套阶段钩子,和 hyper 搭的同样功能的代理比,每个请求差多少。实验 exp-pingora/design-lab(release,tokio 1.53.2,hyper 1.12.0,hyper-util 0.1.21)有四个程序:
up:上游,hyper 的 HTTP/1 服务端,4 个工作线程,每个请求回ok\n,每 accept 一个连接打印一行;pgproxy:pingora 0.9.0,ProxyHttp只实现new_ctx和upstream_peer,threads: 4,127.0.0.1:19110;hyproxy:tokio 多线程运行时 4 个工作线程,每个连接一个 hyperhttp1服务端,请求改写目标后交给一个共享的 hyper-util legacyClient,127.0.0.1:19120;load:server-lab的负载客户端原样拷过来,另一个进程,4 个工作线程,每个连接上循环发GET /、读完响应再发下一个。
hyproxy 的转发部分:
let svc = service_fn(move |mut req: Request<hyper::body::Incoming>| {
let client = client.clone();
let upstream = upstream.clone();
async move {
let pq = req.uri().path_and_query().map(|p| p.as_str()).unwrap_or("/").to_string();
*req.uri_mut() = format!("http://{upstream}{pq}").parse::<Uri>().unwrap();
match client.request(req).await {
Ok(resp) => Ok::<Response<Body>, hyper::Error>(resp.map(|b| b.boxed())),
Err(_) => {
// ...
}
}
}
});
let _ = http1::Builder::new().serve_connection(TokioIo::new(stream), svc).await;
两边都不改头、不缓存、不重试。hyproxy 少做的事至少有:不删逐跳头、不保留头名大小写、不调任何钩子。所以下面的差别是「pingora 的整条请求主线」对「hyper 加 hyper-util 的最小转发」,不是两个 HTTP/1 解析器的对决。
每轮先起上游和代理、预热一个请求,再跑负载,结束时数上游 accept 了几次,用 ps 看代理的线程数和 RSS。先单连接串行 5 秒,直连上游一次作参照,两个代理交替各 3 次:
== direct load=6.66 5.91 5.97
light: n=223791 rps=44758 p50=0.020ms p90=0.026ms p99=0.039ms p99.9=0.089ms max=0.334ms
== pg-c1-r1 load=6.20 5.83 5.94
light: n=81120 rps=16224 p50=0.058ms p90=0.071ms p99=0.113ms p99.9=0.175ms max=0.314ms
upstream conns accepted: 1
proxy threads: 7 rss_kb: 16624
== hy-c1-r1 load=5.87 5.77 5.92
light: n=81630 rps=16326 p50=0.055ms p90=0.069ms p99=0.141ms p99.9=0.318ms max=64.984ms
upstream conns accepted: 1
proxy threads: 5 rss_kb: 7280
...
单连接三轮的汇总:
| 代理 | p50 | p99 | rps | 线程 | RSS |
|---|---|---|---|---|---|
| 直连上游 | 20 µs | 39 µs | 44758 | — | — |
| pingora | 58–59 µs | 113–136 µs | 15334–16224 | 7 | 16624 KiB |
| hyper + hyper-util | 55–56 µs | 128–141 µs | 16276–16602 | 5 | 7216–7280 KiB |
- 两个代理各自给每个请求加了 35 到 39 µs,pingora 多 3 µs 左右。这 3 µs 里有 18 次钩子调用、请求头的
CaseMap、逐跳头清理和proxy_cache的开头,本页没有拆开量。 - 线程数对得上运行时那页的公式:pingora 是主线程、
Server、4 个服务线程和pingora-timeout的时钟线程;hyproxy是主线程加 4 个工作线程。 - pingora 的 RSS 三轮都是 16624 KiB,单连接和 64 个连接一样,大头不随连接数变。这个数只说明两个进程启动后的常驻量,本页没有分析它由什么组成。
- 上游 accept 都是 1 次:两边的池都在复用那一条上游连接。
ProxyHttp 的演变与各 crate 的版本变动
第 3 节和第 9 节的数从 git 历史里来,不用编译。ProxyHttp 的方法数按 tag 数 pub trait ProxyHttp 块里缩进 4 格的 fn / async fn:
0.1.0 methods=25 async=10
0.2.0 methods=28 async=10
0.3.0 methods=31 async=12
0.4.0 methods=31 async=12
0.5.0 methods=32 async=12
0.6.0 methods=32 async=12
0.7.0 methods=36 async=16
0.8.0 methods=36 async=16
0.9.0 methods=42 async=17
相邻两个 tag 的方法名集合相减,每一版都只有「added」、没有「removed」;0.9.0 加的是 adjust_upstream_modules、init_upstream_modules、on_connection_reuse、persist_connection_context、purge_action、suppress_proxy_warn_log。再把每个方法的完整签名拿来比,0.6.0 到 0.7.0 有三个已有方法的签名变了:
0.6.0->0.7.0:
- async fn cache_hit_filter( ... ) -> Result<Option<ForcedInvalidationKind>> where ...
- fn upstream_response_body_filter( ... ) -> Result<()>
- fn upstream_response_filter( ... ) -> Result<()>
+ async fn cache_hit_filter( ... ) -> Result<Option<ForcedFreshness>> where ...
+ async fn upstream_response_filter( ... ) -> Result<()> where Self::CTX: Send + Sync,
+ fn upstream_response_body_filter( ... ) -> Result<Option<Duration>>
(参数列表在这里用 ... 省略。)0.7.0 到 0.8.0 只是给 request_cache_filter 加了一条 where Self::CTX: Send + Sync、把 cache_key_callback 的参数名 session 改成 _session,0.8.0 到 0.9.0 没有已有方法的签名变化。
各 crate 的 src/ 在两次发版之间改了多少,用 git diff --shortstat <旧 tag> <新 tag> -- <crate>/src:
pingora 0.8.1:[] 0.9.0:[] commits=0
pingora-core 0.8.1:[ 3 files changed, 123 insertions(+), 3 deletions(-)] 0.9.0:[ 67 files changed, 15870 insertions(+), 2456 deletions(-)] commits=107
pingora-error 0.8.1:[] 0.9.0:[] commits=0
pingora-limits 0.8.1:[] 0.9.0:[] commits=0
pingora-http 0.8.1:[ 1 file changed, 2 insertions(+), 2 deletions(-)] 0.9.0:[ 2 files changed, 1242 insertions(+), 69 deletions(-)] commits=11
pingora-rustls 0.8.1:[ 1 file changed, 12 insertions(+), 2 deletions(-)] 0.9.0:[ 1 file changed, 16 insertions(+), 3 deletions(-)] commits=2
pingora-ketama 0.8.1:[] 0.9.0:[] commits=0
pingora-memory-cache 0.8.1:[] 0.9.0:[] commits=0
tinyufo 0.8.1:[] 0.9.0:[] commits=0
...
两列都以 0.8.0 为起点,commits 是 git log 0.8.0..0.9.0 -- <crate>/src 的条数。一共 20 个 crate(pingora-prometheus、pingora-foundations 是 0.9.0 新加的,不在比较之列):0.9.0 有 6 个 src/ 没变,0.8.1 有 17 个没变。
总表
| 取舍 | 换来的 | 付出的 | 证据 |
|---|---|---|---|
| 1. 自己写 HTTP/1 | 头名大小写原样;逐跳头、请求目标、连接复用由框架决定;body 可改 | 第一个请求后不再空闲超时;pipelining 默认关且丢请求;chunk 扩展和 trailer 丢失;比 hyper 搭的代理每请求多约 3 µs | HTTP/1 服务端、本页第 13 节 |
| 2. 固定阶段钩子 | 18 个钩子之间框架包办转发、缓存、重试 | 不能包住一段;Ok(true) 不检查;错误路由按阶段;没有就绪 | ProxyHttp、一次请求 |
| 3. 42 个方法的 trait | 25 → 42 个方法只加不删,升级不改代码 | 改签名全体一起改(0.7.0 三个);默认实现变了行为跟着变 | 本页第 13 节、错误、重试与故障转移 |
| 4. 每服务运行时 | 服务互不抢线程;Steal 下重请求 0/64 影响 | NoSteal 共用 accept 线程的反应器(125 → 2013 µs)、本机不比 Steal 快;自己的计时器晚 0 到 20 多 ms | 运行时、超时 |
| 5. 服务级连接池 | 跨下游连接复用;idle_poll 先清死连接 | ca 不进哈希;并发放回留下 2302 到 5435 条孤儿;POST 0.9.0 回 502、0.8.0 发两次 | 连接池、Peer |
| 6. cfg 四选一 TLS | 一个路径、零分发、后端能力全露 | 开两个 E0252 加 74 个错误;不开能编能跑到握手才 panic;同名不同行为 | TLS、怎么读这一卷 |
| 7. 传 fd 升级 | Linux 上没有拒绝连接的空窗(macOS 不带 -u 约半秒) | 只在 Linux;macOS 上假装成功;宽限期无条件睡 300 秒 | 平滑升级与停机 |
| 8. 默认不设上限 | 不在隐藏阈值上切流量 | 空闲、握手、上游超时都要自己配;H2Options::new() 丢掉上限 | HTTP/2、Peer |
| 9. 同一个版本号 | 一个数、一个 tag,跨 crate 改动一次落地 | 6 个 crate 不变也升号;锁文件里混 0.8.0 与 0.8.1;补丁版是岔路;tokio = "1" 放进 1.54 | 本页第 13 节、本站的网关 |
| 10. nginx | 协议细节以 nginx 为参照,ketama 1001/1001 一致 | 资源上限故意不跟 nginx | 选择算法 |
| 11. 上游问题 | — | 12 类发布版和 main 上都在,3 处 main 已修未发版 | 本页第 11 节 |
| 12. 文档 | — | 28 处文档与代码不符 | 本页第 12 节 |
动脑筋答案
64 个连接时,pingora 比 hyper 搭的代理少 1% 到 3%。三轮交替跑的原文:
== pg-c64-r1 load=5.52 5.47 5.83
light: n=328130 rps=41016 p50=1.523ms p90=1.745ms p99=1.954ms p99.9=4.578ms max=8.290ms
upstream conns accepted: 64
proxy threads: 7 rss_kb: 16624
== hy-c64-r1 load=5.65 5.50 5.84
light: n=336768 rps=42096 p50=1.470ms p90=1.771ms p99=2.009ms p99.9=4.735ms max=8.433ms
upstream conns accepted: 64
proxy threads: 5 rss_kb: 12000
== pg-c64-r2 load=5.55 5.49 5.83
light: n=324266 rps=40533 p50=1.536ms p90=1.784ms p99=2.061ms p99.9=5.000ms max=14.137ms
upstream conns accepted: 64
proxy threads: 7 rss_kb: 16624
== hy-c64-r2 load=8.17 6.03 6.01
light: n=332692 rps=41586 p50=1.483ms p90=1.800ms p99=2.015ms p99.9=5.008ms max=9.532ms
upstream conns accepted: 65
proxy threads: 5 rss_kb: 11856
== pg-c64-r3 load=7.83 6.03 6.01
light: n=323980 rps=40498 p50=1.543ms p90=1.782ms p99=1.972ms p99.9=3.940ms max=9.825ms
upstream conns accepted: 64
proxy threads: 7 rss_kb: 16624
== hy-c64-r3 load=7.53 6.03 6.01
light: n=325045 rps=40631 p50=1.484ms p90=1.789ms p99=2.160ms p99.9=5.222ms max=148.116ms
upstream conns accepted: 64
proxy threads: 5 rss_kb: 11792
- 每秒请求数:pingora 40498 到 41016,hyper 搭的代理 40631 到 42096;同一轮里差 0.3% 到 2.6%。p50 是 1.523 到 1.543 ms 对 1.470 到 1.484 ms。
- 直连上游的同样负载是 92326 rps、p50 0.666 ms(同一台机器、同一个
load,另跑一次),两个代理都把吞吐压到一半以下。12 个工作线程加上主线程、时钟线程挤在 12 个核上,瓶颈是整台机器的 CPU,所以单连接时那 3 µs 的差,到了这里只剩几个百分点。 - 上游 accept 64 次(hyper 那边有一轮是 65):并发下两边都给每个下游连接配了一条上游连接。
- p99.9 和 max 两边都抖,hyper 那边有一次 148 ms 的最大值;本机同时有别的进程在跑(负载 5.5 到 8.2),这两列不作比较。
结论只到这里:在这台机器、这个最小转发上,pingora 的整条请求主线和 hyper 加 hyper-util 的最小转发是同一个量级,pingora 略慢。第 1 节说的取舍是功能和行为上的,不是性能上的。
小结
- 自己写 HTTP/1:头名大小写、逐跳头、连接复用都由框架决定;代价是第一个请求之后的空闲连接不再超时、pipelining 默认丢请求、chunk 扩展和 trailer 丢失。同机实测比 hyper 搭的代理每请求慢约 3 µs,并发下慢 1% 到 3%。
- 固定阶段的钩子:转发、缓存、重试都在框架里,一个 GET 调 18 个钩子;代价是不能包住一段、
Ok(true)的约定不检查、错误按阶段走不同的路。 - 42 个方法的
ProxyHttp:从 25 个长到 42 个只加不删,升级不用改代码;改签名和改默认实现时所有人一起受影响,0.9.0 的 POST 不重试就没进 CHANGELOG。 - 每服务一个运行时:默认 Steal,重请求不拖累别人;NoSteal 不独立、本机也不更快;
pingora-timeout用 10 ms 的精度换来更便宜的登记。 - 服务级连接池:跨下游连接复用,死连接提前清掉;
reuse_hash漏了ca,0.9.0 的全局 LRU 在并发放回时漏账。 - TLS 四选一:零分发、能力全露;两个后端编不过,没有后端到握手才 panic,同名函数各后端行为不同。
- 传 fd 升级:Linux 上没有空窗;只在 Linux 实现,macOS 上假装成功;宽限期默认无条件睡 300 秒。
- 默认值不设上限,源码明说「Unlike nginx」;空闲、握手、上游超时都要自己配。和 nginx 的对照只到这里:协议细节以 nginx 为参照,文档里一次都没提它。
- 同一个版本号:一个 tag 管全部;6 个 crate 没变也升号,锁文件可以混版本,0.8.1 是一条不在 main 历史里的岔路。本卷核对出 28 处文档与代码不符。