Rust in Everything
基于 pingora 0.9.0

设计取舍

主线位置:三条主线的汇总。请求主线上的取舍是「自己写 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 版 TlsSettings Deref 到 SslAcceptorBuilder,openssl 的配置全都能用;certificate_callback 这种只有 openssl 系才有的能力,不必为了一个公共 trait 砍掉(TLS)。

付出。

  • 两个后端同时开就编不过:error[E0252]: the name tls is 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 的设计取舍)。

📝NOTE
冷知识:0.8.1 是从 0.8.0 拉出来的一条岔路

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 断言,第一个连接就 panic5b30d71(2026-10-19)L4 流、坑 tokio 1.54
h2 下游的空 END_STREAM 让 h1 上游收到两个结束块10a8345HTTP/1 消息体
Estimator 计数溢出在 debug 下 panicc1c4a0d(#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.mdNoSteal 是「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_proxyProxyHttp
phase.mdfeature 叫 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_v6bind_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 nstokio 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 个工作线程,每个连接一个 hyper http1 服务端,请求改写目标后交给一个共享的 hyper-util legacy Client,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
...
❗IMPORTANT
动脑筋:上面第一轮里,pingora 每个请求比直连多花约 38 µs,hyper 搭的代理多花约 35 µs。换成 64 个连接同时跑 8 秒,两者每秒的请求数差多少?平台 M2 Max、macOS 27、rustc 1.99.0,release。负载客户端、代理、上游都在同一台机器上,各 4 个工作线程。先猜一个百分比,再看文末。

单连接三轮的汇总:

代理p50p99rps线程RSS
直连上游20 µs39 µs44758——
pingora58–59 µs113–136 µs15334–16224716624 KiB
hyper + hyper-util55–56 µs128–141 µs16276–1660257216–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 µsHTTP/1 服务端、本页第 13 节
2. 固定阶段钩子18 个钩子之间框架包办转发、缓存、重试不能包住一段;Ok(true) 不检查;错误路由按阶段;没有就绪ProxyHttp、一次请求
3. 42 个方法的 trait25 → 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 处文档与代码不符。

💬 关联讨论

发起讨论
加载中...