process_request
主线位置:请求主线从「HTTP/1 服务端读请求头」到「两侧连接各自回到复用」的骨架。
pingora-core的 keep-alive 循环每读一个请求就调一次process_new_http,它先在handle_new_request里读完请求头,再交给process_request按顺序调用ProxyHttp的钩子,最后finish把下游连接交回循环。上游连接的取还在proxy_to_upstream里。
flowchart TB
pnh[process_new_http] --> hnr[handle_new_request]
hnr -->|"400 / 405 / 关闭"| none([不复用])
hnr --> ctx[new_ctx]
ctx --> early[early_request_filter]
early --> dmod[下游模块]
dmod --> rf{request_filter}
rf -->|已回应| fin[finish]
rf --> pc{proxy_cache}
pc -->|命中| fin
pc --> puf{proxy_upstream_filter}
puf -->|不去| fin
puf --> loop[重试循环]
loop --> ptu[proxy_to_upstream]
ptu -->|可重试| loop
ptu --> stale[stale-if-error]
stale --> ftp[fail_to_proxy]
stale --> fin
ftp --> fin
fin --> reuse([交回下游连接])
process_request 本身是 lib.rs 第 1148 行 起的 218 行。pingora-proxy/src/ 一共 11921 行,lib.rs 2630 行,其中一半是测试和 Session、ProxyServiceBuilder 这些外围类型。每个钩子的默认实现见 ProxyHttp;实验环境同那一页(exp-pingora/proxy-lab,release,tokio 1.53.2)。
入口:process_new_http
HttpProxy 实现了 pingora-core 的 HttpServerApp。HTTP/1 连接在 apps/mod.rs 的 process_new 里被包成 ServerSession、设好 60 秒 keep-alive,然后反复调 process_new_http,直到它返回 None:
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;
}
HTTP/2 连接上每个流 spawn 一个任务,各调一次 process_new_http,没有这个循环。连接怎么来、h2c 怎么识别,见 HTTP/1 服务端 和 HTTP/2。
process_new_http 做五件事:取出上一个请求留下的用户上下文(take_connection_user_context),handle_new_request 读请求头,服务正在停机时 set_keepalive(None),new_ctx,有上下文就调 on_connection_reuse;然后 process_request。
handle_new_request:读头之前的两道关
handle_new_request 在读请求头的同时等停机信号:
let res = tokio::select! {
biased; // biased select is cheaper, and we don't want to drop already buffered requests
res = downstream_session.read_request() => { res }
_ = self.await_shutdown() => {
// service shutting down, dropping the connection to stop more req from coming in
return None;
}
};
一个空闲的 keep-alive 连接大部分时间就停在这里。biased 让 read_request 先被 poll:缓冲区里已经有完整请求头时,它第一次 poll 就返回,停机分支根本不会被创建(select! 的展开见 tokio 卷的 select! 与 join!)。停机时 http_cleanup 置位 shutdown_flag 并唤醒所有等待者,这些连接直接被丢掉。
读到的结果分三种:Ok(true) 继续;Ok(false)(对端在请求之间关了连接)返回 None;Err 时,InvalidHTTPHeader 回 400,其他错误只记日志,然后关连接。读成功之后还有一道关:方法是 CONNECT 且 HttpServerOptions::allow_connect_method_proxying 没开,就回 405 并关连接。这条默认拒绝是 d3d2cf5(「Disable CONNECT method proxying by default」,首次进入 0.8.0)加的,提交说明给的理由是 CONNECT 和 upgrade 一样改变了请求、响应的流程,而代理逻辑并不支持它。
这两道关发生在 new_ctx 之前,所以不会调用任何钩子,包括 logging。proxy-lab 的 tools/raw_req.py 往装了 15 个钩子的 hooks 代理发原始字节:
b'HTTP/1.1 405 Method Not Allowed'
b'HTTP/1.1 400 Bad Request'
== log
第一个是 CONNECT example.com:443 HTTP/1.1,第二个带一行没有冒号的 Bad Header。钩子日志一行都没有;RUST_LOG=warn 下 pingora 自己也没打日志(400 那条是 debug!,405 那条没有日志)。靠 logging 统计请求数的代码看不到这两类请求。
Notify 变成按线程分片0.9.0 之前,HttpProxy 里是一个 shutdown: Notify,每个停在 read_request() 的请求都要在它上面注册、注销一次等待者,两次操作都要拿 Notify 内部的互斥锁。b01edce(「Shard the HttpProxy shutdown Notify to cut lock contention」,同步自 PR #969,修 issue #844,首次进入 0.9.0)的说明里写,#844 在一台 128 核的 NUMA 机器上测到约 65% 的 off-CPU 时间耗在这把锁的 futex 上。现在是 ShardedNotify:分片数是 conf.threads 向上取到 2 的幂、最多 256,每片 #[repr(align(128))] 避免伪共享,线程按一个线程局部编号挑分片,停机时通知所有分片。同一个提交还修了一个丢唤醒:旧代码不检查 shutdown_flag,通知如果恰好发生在 read_request() 返回 Pending 之后、等待者注册之前,就被错过,连接要等到宽限期结束;现在 await_shutdown 先注册、再查 shutdown_flag。CHANGELOG 0.9.0 对应的一条是「Shard proxy shutdown notifications to reduce lock contention and close a lost-wakeup race during graceful shutdown」。
请求阶段:三个钩子之间夹着下游模块
process_request 的第一段:
early_request_filter。出错走handle_error。allow_spawning_subrequest返回true时,给session装一个SubrequestSpawner(持有Arc<HttpProxy>,可以从当前请求派生子请求,缓存的后台重新验证用它)。- 下游模块的
request_header_filter,按order()从大到小。压缩模块、gRPC-web 模块在这一步看请求头,见 HTTP 模块与压缩。 request_filter。Ok(true)时调logging、finish下游会话并返回可复用的连接;出错走handle_error。
early_request_filter 和 request_filter 的区别只在模块的前后。trait 文档的建议是:访问控制、限流这类逻辑尽量放 request_filter,因为模块可能就是做访问控制和限流的,放在它们前面就绕过了它们。
缓存分支(简述)
if let Some((reuse, err)) = self.proxy_cache(&mut session, &mut ctx).await {
// cache hit
return self.finish(session, &mut ctx, reuse, err).await;
}
// either uncacheable, or cache miss
proxy_cache 依次调 request_cache_filter(默认不开缓存)、cache_key_callback、is_purge(是清除请求就在这里处理完返回),没开缓存时返回 None,process_request 继续往下。开了缓存就查找;命中时它自己把响应写给下游,返回 Some,process_request 直接 finish;未命中或需要重新验证时返回 None,请求照常去上游,响应在双向转发里边转发边写缓存。查找、缓存锁、过期这些状态怎么转,见 缓存状态机。
缓存分支之后有一句 self.cleanup_sub_req(&mut session):如果这是一个子请求,释放它从父请求继承来的缓存写锁。注释写「there should not be a write lock in the sub req ctx after this point」。
proxy_upstream_filter:去不去上游
Ok(proxy_to_upstream) => {
if !proxy_to_upstream {
// The hook can choose to write its own response, but if it doesn't, we respond
// with a generic 502
if session.cache.enabled() {
// drop the cache lock that this request may be holding onto
session.cache.disable(NoCacheReason::DeclinedToUpstream);
}
if session.response_written().is_none() {
match session.write_response_header_ref(&BAD_GATEWAY, true).await {
它在缓存未命中之后才调用,用来把「只有真去上游才需要做的检查」(限流、鉴权)推迟到这里,命中缓存的请求省掉这一步。返回 Ok(false) 时要先关掉缓存:这个请求可能正拿着缓存锁,别的请求在等它填缓存。然后 finish(session, ctx, true, None),下游连接可以复用。
重试循环
let mut retries: usize = 0;
let mut server_reuse = false;
let mut proxy_error: Option<Box<Error>> = None;
while retries < self.max_retries {
retries += 1;
let (reuse, e) = self.proxy_to_upstream(&mut session, &mut ctx).await;
server_reuse = reuse;
match e {
Some(error) => {
let retry = error.retry();
// ...
proxy_error = Some(error);
if !retry {
break;
}
}
None => {
proxy_error = None;
break;
}
};
}
每一轮都从 upstream_peer 重新开始,所以故障转移就是在 CTX 里记下失败过的上游,下一轮 upstream_peer 换一个。max_retries 来自 ServerConf,默认 16,实际是「最多尝试几次」而不是「最多重试几次」。错误怎么被标成可重试、各种情况下试几次、max_retries: 0 会怎样,见 错误、重试与故障转移。
proxy_to_upstream:一次尝试
proxy_to_upstream 是一次完整的尝试:
upstream_peer。出错直接返回,不经过fail_to_connect,也不经过error_while_proxy。self.client_upstream.get_http_session(&*peer):从连接池取一个,或者新建(TCP、TLS、ALPN),返回(会话, 是否复用)。连接器和池见 HTTP 连接器 和 连接池。失败时把错误标成Upstream,交给fail_to_connect。- 按会话类型分三路:
proxy_to_h1_upstream、proxy_to_h2_upstream、proxy_to_custom_upstream,返回(下游能否复用, [上游能否复用,] 错误)。 - 还连接:h1 只有上游半边说能复用时才
release_http_session;h2 和自定义协议总是交回(h2 的连接是共享的,单个流出错不代表连接坏了)。 - h2 返回
H2Downgrade或InvalidH2时,如果 peer 允许 HTTP/1(ALPN 没设,或者最低版本是 1),prefer_h1(&*peer)记下这个上游以后都用 h1;不允许(比如 gRPC 要求 h2)就把错误标成不可重试。 - 有错误就交给
error_while_proxy,由它决定最终的重试标记。
收尾:stale-if-error、fail_to_proxy、finish
循环结束后,如果有错误且缓存里有可以「出错时用旧的」的资产(can_serve_stale_error),handle_stale_if_error 尝试用过期的缓存响应顶上,成功就不算错误。剩下的错误:
- 关掉缓存(释放缓存锁),原因按错误来源分
UpstreamError或InternalError; fail_to_proxy写错误响应,返回状态码和下游能否复用;- 打一条 error 日志
Fail to proxy: ..., status: ..., tries: ..., retry: false, ...,retry这一项写死是false(代码注释「we never retry here」)。
最后是 finish:
self.inner
.logging(&mut session, error.as_deref(), ctx)
.await;
if let Some(e) = error {
session.downstream_session.on_proxy_failure(e);
}
if reuse {
// TODO: log error
let mut persistent_settings = HttpPersistentSettings::for_session(&session);
if let Some(uc) = self.inner.persist_connection_context(&session, ctx) {
persistent_settings.set_user_context(uc);
}
session
.downstream_session
.finish()
.await
.ok()
.flatten()
.map(|s| ReusedHttpStream::from_reusable_stream(s, persistent_settings))
reuse 是双向转发返回的「下游能否复用」。downstream_session.finish() 在 HTTP/1 上走到 reuse:不 keep-alive 就关连接;否则读完剩下的请求体,可以复用就把底层 Stream(和开了 pipelining 时多读到的下一个请求的字节)交出来,回到 apps/mod.rs 的循环里读下一个请求。
把 handle_new_request 也算上,一个请求有五种出口,各自调用的收尾不同:
| 出口 | logging | fail_to_proxy | 下游复用 |
|---|---|---|---|
handle_new_request 拒绝(400、405、读错) | 否 | 否 | 否 |
early_request_filter / 下游模块 / request_filter / proxy_upstream_filter 出错(handle_error) | 是 | 是 | 看 FailToProxy::can_reuse_downstream,默认 false |
request_filter 返回 Ok(true) | 是 | 否 | 是 |
缓存命中、proxy_upstream_filter 返回 Ok(false)、正常转发完 | 是(finish) | 否 | 依次看 proxy_cache 的返回值、恒为是、看双向转发的返回值 |
| 重试用完仍出错 | 是(finish) | 是 | 看转发结果;但 respond_error 已经关了 keep-alive |
最后一行的「但」来自 respond_error 里的 set_keepalive(None):只要 fail_to_proxy 真写了错误响应,finish() 就不会交出可复用的连接。
小结
process_new_http每个 HTTP/1 请求调一次:handle_new_request读头 →new_ctx→on_connection_reuse→process_request;finish返回的流回到apps/mod.rs的 keep-alive 循环。- 读头时和停机通知
biased地select!;0.9.0 的ShardedNotify把这把锁按线程分片,并修了一个丢唤醒。 - 畸形请求头(400)和默认禁止的
CONNECT(405)在new_ctx之前就被拒,任何钩子都不调用,包括logging。 - 顺序是
early_request_filter→ 下游模块 →request_filter→ 缓存 →proxy_upstream_filter→ 重试循环;每轮proxy_to_upstream都从upstream_peer开始。 - 出错时先试 stale-if-error,再
fail_to_proxy,最后finish调logging并决定下游连接能否复用。