Rust in Everything
基于 pingora-proxy 0.9.0

一次请求

主线位置:请求主线全程。从监听器 accept 一条连接,到响应写回下游、两侧连接各自回收;每一步只讲到看清全貌的深度,细节链到 03 协议、04 连接上游和 05 代理。
sequenceDiagram
  participant C as curl
  participant L as accept 循环
  participant T as 连接任务
  participant P as HttpProxy
  participant U as 你的 ProxyHttp
  participant Pool as 上游连接池
  participant S as 上游
  C->>L: TCP 连接
  L->>T: spawn handshake set_buffer
  T->>P: process_new_http
  P->>U: new_ctx early_request_filter request_filter
  P->>U: upstream_peer
  P->>Pool: get_http_session
  Pool-->>P: 新建或复用
  P->>U: connected_to_upstream upstream_request_filter
  P->>S: 请求头
  S-->>P: 响应头和 body
  P->>U: 响应四个 filter
  P-->>C: 响应
  P->>U: logging persist_connection_context
  P->>Pool: release_http_session
  T->>P: 同一连接的下一个请求

程序

实验 intro-run 有两个二进制:

  • upstream:hyper 写的 HTTP/1.1 上游,监听 127.0.0.1:18101,每来一条连接、每来一个请求都打印对端(也就是代理一侧)的端口,响应体是 upstream saw proxy port <端口>\n。
  • hooks:代理 127.0.0.1:18100 → 18101,ProxyHttp 实现覆盖了 23 个方法,每个都只打一行日志;除了 persist_connection_context 返回 Some、fail_to_proxy 固定回 502,返回值都和默认实现相同。CTX 是请求序号。

日志函数和其中两个钩子:

fn log(req: usize, hook: &str, extra: String) {
  let n = SEQ.fetch_add(1, Ordering::SeqCst) + 1;
  let t = std::thread::current();
  eprintln!("{n:>3} req{req} [{}] {hook} {extra}", t.name().unwrap_or("?"));
}

#[async_trait]
impl ProxyHttp for Logged {
  type CTX = usize;

  fn new_ctx(&self) -> usize {
    let id = REQS.fetch_add(1, Ordering::SeqCst) + 1;
    log(id, "new_ctx", String::new());
    id
  }

  async fn connected_to_upstream(
    &self,
    _s: &mut Session,
    reused: bool,
    _peer: &HttpPeer,
    fd: std::os::unix::io::RawFd,
    digest: Option<&Digest>,
    ctx: &mut usize,
  ) -> Result<()> {
    let local = digest
      .and_then(|d| d.socket_digest.as_ref())
      .and_then(|s| s.local_addr().map(|a| a.to_string()))
      .unwrap_or_default();
    log(*ctx, "connected_to_upstream", format!("reused={reused} fd={fd} local={local}"));
    Ok(())
  }

  fn persist_connection_context(&self, _s: &Session, ctx: &usize) -> Option<Box<dyn Any + Send + Sync>> {
    log(*ctx, "persist_connection_context", String::new());
    Some(Box::new(*ctx))
  }
  // ... 其余 20 个方法同样只打日志
}

persist_connection_context 返回了 Some,这样同一条下游连接的下一个请求会触发 on_connection_reuse。客户端是两次 curl:第一次在一条连接上发 /a、/b 两个请求,第二次新开一条连接发 /c:

$ curl -s -w 'local_port=%{local_port}\n' http://127.0.0.1:18100/a http://127.0.0.1:18100/b
$ curl -s -w 'local_port=%{local_port}\n' http://127.0.0.1:18100/c

平台 M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0(openssl,开了 upstream_modules),tokio 1.53.2,debug,默认配置(Server::new(None))。

输出

curl:

upstream saw proxy port 62491
local_port=62490
upstream saw proxy port 62491
local_port=62490
upstream saw proxy port 62491
local_port=62492

上游:

UPSTREAM conn #1 from 127.0.0.1:62491
UPSTREAM req GET /a on conn from port 62491
UPSTREAM req GET /b on conn from port 62491
UPSTREAM req GET /c on conn from port 62491

代理(第一个请求全部,后两个只留开头和不同的行):

  1 req0 [main] init_downstream_modules 
  2 req0 [main] init_upstream_modules 
  3 req1 [Pingora HTTP Proxy Service] new_ctx 
  4 req1 [Pingora HTTP Proxy Service] early_request_filter client_port=62490 /a
  5 req1 [Pingora HTTP Proxy Service] allow_spawning_subrequest 
  6 req1 [Pingora HTTP Proxy Service] request_filter 
  7 req1 [Pingora HTTP Proxy Service] request_cache_filter 
  8 req1 [Pingora HTTP Proxy Service] is_purge 
  9 req1 [Pingora HTTP Proxy Service] proxy_upstream_filter 
 10 req1 [Pingora HTTP Proxy Service] upstream_peer 
 11 req1 [Pingora HTTP Proxy Service] connected_to_upstream reused=false fd=15 local=127.0.0.1:62491
 12 req1 [Pingora HTTP Proxy Service] upstream_request_filter 
 13 req1 [Pingora HTTP Proxy Service] request_body_filter len=None eos=true
 14 req1 [Pingora HTTP Proxy Service] adjust_upstream_modules status=200 OK eos=false
 15 req1 [Pingora HTTP Proxy Service] upstream_response_filter status=200 OK
 16 req1 [Pingora HTTP Proxy Service] response_filter status=200 OK
 17 req1 [Pingora HTTP Proxy Service] upstream_response_body_filter len=Some(30) eos=true
 18 req1 [Pingora HTTP Proxy Service] response_body_filter len=Some(30) eos=true
 19 req1 [Pingora HTTP Proxy Service] logging error=None
 20 req1 [Pingora HTTP Proxy Service] persist_connection_context 
 21 req2 [Pingora HTTP Proxy Service] new_ctx 
 22 req2 [Pingora HTTP Proxy Service] on_connection_reuse prev_req=1
 23 req2 [Pingora HTTP Proxy Service] early_request_filter client_port=62490 /b
...
 30 req2 [Pingora HTTP Proxy Service] connected_to_upstream reused=true fd=15 local=127.0.0.1:62491
...
 40 req3 [Pingora HTTP Proxy Service] new_ctx 
 41 req3 [Pingora HTTP Proxy Service] early_request_filter client_port=62492 /c
...
 48 req3 [Pingora HTTP Proxy Service] connected_to_upstream reused=true fd=15 local=127.0.0.1:62491
...
 57 req3 [Pingora HTTP Proxy Service] persist_connection_context 

每个请求调到 18 个方法(第二个请求多一个 on_connection_reuse)。23 个打了日志的方法里,没被调到的只有 fail_to_connect、fail_to_proxy 两个出错时才走的。下面按日志的顺序走一遍。

逐步走

1. 启动时:模块钩子在 main 线程上调一次

第 1、2 行是 req0,线程名是 main。http_proxy_service 建 HttpProxy 时调 handle_init_modules,它调 init_downstream_modules 和(开了 upstream_modules 时)init_upstream_modules,把模块注册进服务级的 HttpModules。这发生在 run_forever 之前,还没有任何运行时。之后每个请求按这份注册表各建一份模块上下文。默认的 init_downstream_modules 注册了一个关着的压缩模块(ResponseCompressionBuilder::enable(0)),hooks 照抄了这一行。模块怎么参与请求,见 HTTP 模块与压缩。

2. accept 与握手:还没有 HTTP

从第 3 行起,线程名都是 Pingora HTTP Proxy Service:这是 http_proxy_service 给服务起的默认名字,也是这个服务的运行时的线程名(一个 Server 的一生)。

在它之前,run_endpoint 的循环 accept 到 curl 的连接,把它包成一个 Stream,spawn 一个任务。任务里先在 60 秒的超时里做 handshake:set_buffer 换上 64 KiB 的读缓冲,有 TLS 就握手;然后进 handle_event。这一段见服务与监听器和 L4 流。

3. keep-alive 循环:每个请求一个新的 CTX

handle_event 调 ServerApp::process_new。它没看到 h2c 前言、也没有 ALPN,于是建一个 HTTP/1 的 ServerSession,把 keep-alive 设成 60 秒,然后:

            let mut result = self.process_new_http(session, shutdown).await;
            while let Some((stream, persistent_settings)) = result.map(|r| r.consume()) {
                let mut session = ServerSession::new_http1(stream);
                if let Some(persistent_settings) = persistent_settings {
                    persistent_settings.apply_to_session(&mut session);
                }

                result = self.process_new_http(session, shutdown).await;
            }

一条下游连接上的每个请求都是一次 process_new_http,都在同一个任务里顺序执行。HttpProxy::process_new_http 先 handle_new_request 读请求头(和停机通知做 select!,CONNECT 方法默认回 405),读到了才调 new_ctx。所以第 3 行的 new_ctx 出现在请求头读完之后;第 21 行 req2 的 new_ctx 也是:CTX 是每个请求一个,不是每个连接一个。

想跨请求带状态,就是第 20、22 行这一对:上一个请求的 persist_connection_context 返回一个 Box<dyn Any>,存进 HttpPersistentSettings,跟着 Stream 回到上面的 while let;下一个请求的 new_ctx 之后,on_connection_reuse 把它交回来,日志里是 prev_req=1。第三个请求换了下游连接(客户端端口从 62490 变成 62492),所以没有 on_connection_reuse。

📝NOTE
冷知识:跨请求的用户上下文是 0.9.0 才有的

persist_connection_context 和 on_connection_reuse 来自 5f930d2(2026-03-16,「Add user-extensible context to HttpPersistentSettings」),首次发布于 0.9.0;提交说明只有一句「Enable persisting user-defined state across HTTP/1.x keepalive requests on the same downstream connection.」在这之前,HttpPersistentSettings 只有 keepalive_timeout 和 keepalive_reuses_remaining 两个字段,都是框架自己的设置,process_new 把它从上一个会话搬到下一个会话;用户的 CTX 每个请求重建,框架没有把用户状态带到下一个请求的通道。这个提交给 HttpPersistentSettings 加了一个 user_context: Option<Box<dyn Any + Send + Sync>> 字段,在 ProxyHttp 上加了这两个默认什么都不做的钩子。CHANGELOG 记作「Enable adding user context between sessions on the same connection with HttpPersistentSettings」。它只对 HTTP/1 有效:h2 的每个流是独立的 process_new_http,apps/mod.rs 里那句注释写的是「PersistentSettings not currently relevant for h2」。

4. 请求阶段:五个钩子,缓存那一段也走了

process_request 是请求阶段的全部流程:

  1. early_request_filter(第 4 行):最早的钩子,在下游模块之前。
  2. allow_spawning_subrequest(第 5 行):决定这个会话能不能派生子请求。
  3. 下游模块的 request_header_filter:不是 ProxyHttp 的方法,日志里看不到。
  4. request_filter(第 6 行):返回 Ok(true) 表示你已经自己写了响应,process_request 直接收尾,不连上游。
  5. proxy_cache:先调 request_cache_filter(第 7 行);缓存没打开,所以跳过 cache_key_callback;然后无论缓存开没开都调 is_purge(第 8 行),返回 true 就转去处理 PURGE。pingora-proxy 无条件依赖 pingora-cache,这一段每个请求都走(源码地图)。
  6. proxy_upstream_filter(第 9 行):返回 false 就不去上游,没写响应时回 502。

然后进入重试循环,最多 max_retries 次调 proxy_to_upstream。每一步出错时走哪里,见 process_request 和错误与重试。

5. 选上游、取连接

proxy_to_upstream 先调 upstream_peer(第 10 行),拿到 HttpPeer,交给 Connector::get_http_session。HttpPeer::new(addr, false, "") 没有 ALPN,按「h1 only」处理:h1 连接器先从池里找同一个 Peer 的空闲连接(reused_stream),找不到再 new_stream 建一条新的。返回值的第二项就是 connected_to_upstream 收到的 reused。

第 11 行 reused=false fd=15 local=127.0.0.1:62491:第一个请求新建了一条上游连接,本地端口 62491,上游那边记作 conn #1。第 30 行和第 48 行都是 reused=true fd=15 local=127.0.0.1:62491:同一个 fd、同一个端口,上游也只见过一条连接。第三个请求来自另一条下游连接,照样拿到这条上游连接,因为池在 HttpProxy 的 client_upstream 里,整个服务共用。Peer 怎么算「同一个」、池怎么组织,见 HttpPeer、连接池 和 HTTP 连接器。

6. 写请求头,开始双向转发

h1 上游走 proxy_to_h1_upstream:先调 connected_to_upstream,再进 proxy_1to1。后者复制一份下游的请求头,版本改成 HTTP/1.1,按 Peer 上的策略删掉逐跳头(解决什么问题里的实验),然后调 upstream_request_filter(第 12 行),这是改写发给上游的请求头的地方。写出请求头之后,tokio::try_join! 同时跑两个半边:proxy_handle_downstream 读下游的请求体、写下游的响应,proxy_handle_upstream 写上游的请求体、读上游的响应,两边用两个容量为 TASK_BUFFER_SIZE 的 mpsc 通道传 HttpTask。

第 13 行 request_body_filter len=None eos=true:GET 没有请求体,这个钩子仍被调了一次,body 是 None,end_of_stream 是 true。有请求体时它每收到一块调一次。两个半边怎么配合、谁先结束,见双向转发。

7. 响应:先 upstream_,后不带前缀的

上游的响应头到了,第 14 到 16 行依次是 adjust_upstream_modules、upstream_response_filter、response_filter;body 到了,第 17、18 行是 upstream_response_body_filter、response_body_filter,30 字节一块,eos=true。

upstream_ 前缀和不带前缀的两组钩子在不开缓存时看起来是重复的。区别在缓存:upstream_response_filter 在写进缓存之前调,改动会被存起来;response_filter 在缓存之后调,命中缓存的响应也会经过它。上游原样转发的部分在 h1_response_filter 里,缓存那一段见 06 缓存。

8. 收尾:logging,然后两边各自回收

响应写完,finish 调 logging(第 19 行,error=None),再调 persist_connection_context(第 20 行),然后 downstream_session.finish():这条下游连接还能 keep-alive,就把 Stream 交回第 3 步的 while let。上游那一边早在 proxy_to_upstream 里处理完了:proxy_1to1 返回的第二个布尔值说上游连接能复用,就 release_http_session 放回池里,带上 Peer 的 idle_timeout。

第三个请求的最后一行也是 persist_connection_context:pingora 不知道 curl 不会再发了,照样准备复用;下一轮 read_request 读到 EOF,handle_new_request 返回 None,循环结束,任务退出。

两个并发请求,几条上游连接

❗IMPORTANT
动脑筋:上游每个请求要 300 ms。先同时发两个请求,等它们都结束,再一个接一个发三个请求(每个都是新的 curl,新的下游连接)。上游一共见到几条连接?后三个请求各用哪条?

把 upstream 换成 upstream 18101 300(每个请求先睡 300 ms),其余同上。

动脑筋答案

上游:

UPSTREAM conn #1 from 127.0.0.1:62523
UPSTREAM conn #2 from 127.0.0.1:62524
UPSTREAM req GET /p1 on conn from port 62523
UPSTREAM req GET /p2 on conn from port 62524
UPSTREAM req GET /s3 on conn from port 62523
UPSTREAM req GET /s4 on conn from port 62524
UPSTREAM req GET /s5 on conn from port 62523

代理里 connected_to_upstream 的几行:

 19 req1 [Pingora HTTP Proxy Service] connected_to_upstream reused=false fd=16 local=127.0.0.1:62523
 22 req2 [Pingora HTTP Proxy Service] connected_to_upstream reused=false fd=17 local=127.0.0.1:62524
 47 req3 [Pingora HTTP Proxy Service] connected_to_upstream reused=true fd=16 local=127.0.0.1:62523
 65 req4 [Pingora HTTP Proxy Service] connected_to_upstream reused=true fd=17 local=127.0.0.1:62524
 83 req5 [Pingora HTTP Proxy Service] connected_to_upstream reused=true fd=16 local=127.0.0.1:62523
  • 两条。 两个并发请求取连接时池是空的,各建一条(62523、62524)。之后不再新建:池里一直有空闲连接。
  • 轮流用,不是总用同一条。 /s3 拿到 62523,/s4 拿到 62524,/s5 又是 62523。每个 Peer 在池里对应一个 PoolNode,空闲连接先放进一个容量 16 的 crossbeam ArrayQueue(「hot queue」),get_any 先从它 pop。ArrayQueue 是先进先出:/p1 先结束,62523 先进队;/s3 取走 62523、用完放回队尾,/s4 取到的就是排在前面的 62524。hyper-util 的 legacy Client 正好相反,空闲连接后进先出(hyper 卷的连接池)。按先进先出的顺序,只要请求不断,两条空闲连接都会轮到。hot queue 满了以后怎么办、0.9.0 的连接池重写改了什么,见连接池。

小结

  • 启动时 init_downstream_modules / init_upstream_modules 在 main 线程调一次;之后所有钩子都跑在以服务名命名的运行时线程上。
  • 一条 HTTP/1 下游连接是一个任务里的 while let 循环,每个请求一次 process_new_http,每个请求一个新 CTX;跨请求带状态用 0.9.0 新增的 persist_connection_context / on_connection_reuse。
  • 一个普通 GET 依次调到:new_ctx → early_request_filter → allow_spawning_subrequest → request_filter → request_cache_filter → is_purge → proxy_upstream_filter → upstream_peer → connected_to_upstream → upstream_request_filter → request_body_filter → adjust_upstream_modules → upstream_response_filter → response_filter → upstream_response_body_filter → response_body_filter → logging → persist_connection_context。
  • 不开缓存时 request_cache_filter 和 is_purge 照样每个请求都调;GET 没有请求体,request_body_filter 也调一次(None,eos=true)。
  • 上游连接池在服务级共享:换了下游连接照样复用(同一个 fd、同一个本地端口);并发时按需新建,空闲连接先进先出轮流取用。

💬 关联讨论

发起讨论
加载中...