HTTP 连接器
主线位置:请求主线的第四步和最后一步。
proxy_to_upstream拿到 peer 后调client_upstream.get_http_session(&peer),得到一个 h1 会话或者一条 h2 流,外加「是不是复用的」;转发结束后调release_http_session,h1 连接回池,h2 流释放、连接留着给别的请求用。
flowchart TB
start["get_http_session"] --> cus{"ALPN Custom?"}
cus -- 是 --> custom["custom 连接器"]
cus -- 否 --> h1only{"最高版本是 1?"}
h1only -- 是 --> h1get["v1:池里取或新建"]
h1only -- 否 --> h2re["h2 池里开一条流"]
h2re -- 有 --> h2s["H2 会话"]
h2re -- 没有 --> h2only{"只要 h2?"}
h2only -- 否 --> h1re["h1 池里取"]
h1re -- 有 --> h1s["H1 会话"]
h1re -- 没有 --> newh2["v2::new_http_session"]
h2only -- 是 --> newh2
newh2 --> alpn{"协商结果"}
alpn -- h2 --> h2s
alpn -- 其他 --> h1s
源码在 connectors/http/ 下:mod.rs 是分派,v1.rs 是一层很薄的包装,v2.rs 1104 行,一半是测试,另一半是 h2 连接的共享。连接上的 HTTP/1 和 HTTP/2 协议本身在协议部分。
实验直接调 http::Connector,跑在 #[tokio::main] 的默认多线程运行时上;平台 M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建。上游是同一个进程里的三个服务:hyper 的 h2c 服务(每个请求延迟 100ms 再回)、只说 HTTP/1.1 的 TLS 服务(openssl,不设 ALPN 回调,所以从不选协议)、明文 HTTP/1.1 服务,各自数 accept 的次数。
get_http_session:先查哪个池
Connector::get_http_session 去掉自定义 ALPN 那一支以后:
// We assume no peer option == no ALPN == h1 only
let h1_only = peer
.get_peer_options()
.is_none_or(|o| o.alpn.get_max_http_version() == 1);
if h1_only {
let (h1, reused) = self.h1.get_http_session(peer).await?;
Ok((HttpSession::H1(h1), reused))
} else {
// the peer allows h2, we first check the h2 reuse pool
let reused_h2 = self.h2.reused_http_session(peer).await?;
if let Some(h2) = reused_h2 {
return Ok((HttpSession::H2(h2), true));
}
let h2_only = peer
.get_peer_options()
.is_some_and(|o| o.alpn.get_min_http_version() == 2)
&& !self.h2.h1_is_preferred(peer);
if !h2_only {
// We next check the reuse pool for h1 before creating a new h2 connection.
// This is because the server may not support h2 at all, connections to
// the server could all be h1.
if let Some(h1) = self.h1.reused_http_session(peer).await {
return Ok((HttpSession::H1(h1), true));
}
}
let session = self.h2.new_http_session(peer).await?;
Ok((session, false))
}
按 peer 的 ALPN 分三种:
options.alpn | 怎么来 | 先查 | 再查 | 都没有 |
|---|---|---|---|---|
H1(默认) | 不改,或 set_http_version(1, x) | h1 池 | — | v1:新建 TCP / TLS |
H2H1 | set_http_version(2, 1) | h2 池 | h1 池 | v2::new_http_session |
H2 | set_http_version(2, 2) | h2 池 | 不查 h1 池(除非已记成 prefer_h1) | v2::new_http_session |
H2H1 要查 h1 池,注释说得清楚:上游可能根本不支持 h2,那它的连接全是 h1,不查就永远复用不上。v2::new_http_session 也不一定返回 h2,下一节看。
new_http_session:ALPN 说了算
v2::Connector::new_http_session 先用自己的 TransportConnector 建一条新连接,再看 TLS 握手协商出了什么:
let stream = self.transport.new_stream(peer).await?;
// check alpn
match stream.selected_alpn_proto() {
Some(ALPN::H2) => { /* continue */ }
Some(_) => {
// H2 not supported
return Ok(HttpSession::H1(Http1Session::new_with_options(
stream, peer,
)));
}
None => {
// if tls but no ALPN, default to h1
// else if plaintext and min http version is 1, this is most likely h1
if peer.tls()
|| peer
.get_peer_options()
.is_none_or(|o| o.alpn.get_min_http_version() == 1)
{
return Ok(HttpSession::H1(Http1Session::new_with_options(
stream, peer,
)));
}
// else: min http version=H2 over plaintext, there is no ALPN anyways, we trust
// the caller that the server speaks h2c
}
}
明文连接没有 ALPN,selected_alpn_proto() 是 None。于是明文上游只有 set_http_version(2, 2) 才会说 h2(h2c,直接发连接前言,即 prior knowledge);H2H1 在明文上等于 h1。对 h2c 服务用默认 ALPN 和 H2H1 各发一个请求:
h2c server, default ALPN (H1), plaintext
H1 reused=false -> error InvalidHTTPHeader context: buf: \x00\x00\x18\x04\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\xc8\x00\x04\x00\x10\x00\x00\x00\x05\x00\x00@\x00\x00\x06\x00\x00@\x00 cause: invalid HTTP version
h2c server, ALPN H2H1 (set_http_version(2, 1)), plaintext
H1 reused=false -> error InvalidHTTPHeader context: buf: \x00\x00\x18\x04\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\xc8\x00\x04\x00\x10\x00\x00\x00\x05\x00\x00@\x00\x00\x06\x00\x00@\x00 cause: invalid HTTP version
两次都是 h1 会话。h2 服务端一连上就发自己的 SETTINGS 帧,\x00\x00\x18\x04 就是「长度 24、类型 4(SETTINGS)」的帧头,h1 客户端把它当响应状态行解析,报 invalid HTTP version。
TLS 上游有 ALPN。对只说 HTTP/1.1、不选协议的 TLS 服务,H2H1 发两个请求:
ALPN H2H1
H1 reused=false -> h1 conn #55
H1 reused=true -> h1 conn #55
第一次 h2 池和 h1 池都是空的,走 new_http_session,握手后 selected_alpn_proto() 是 None、又是 TLS,返回 h1 会话。用完 release_http_session 按 HttpSession::H1 交给 v1::Connector,进的是 h1 池。第二次先查 h2 池(空),再查 h1 池,拿到同一条连接 #55。
H2(set_http_version(2, 2)),连发两个请求,各是 h1 还是 h2?第二个是不是复用的?对照上面那张表的最后一行想。答案在文末。h1:整条连接复用
v1::Connector 只有一个 TransportConnector,取连接就是 get_stream / reused_stream(见连接池),还连接是 release_http_session:
pub async fn release_http_session<P: Peer + Send + Sync + 'static>(
&self,
mut session: HttpSession,
peer: &P,
idle_timeout: Option<Duration>,
) {
session.respect_keepalive();
if let Some(stream) = session.reuse().await {
self.transport
.release_stream(stream, peer.reuse_hash(), idle_timeout);
}
}
respect_keepalive 按响应决定能不能保持:101 / 升级过的不能,close-delimited 的不能,多读了字节的不能,HTTP/1.0 带 Transfer-Encoding 的不能;请求头里写了 Connection: close 就不看响应;否则看响应的 Connection 头,没有就按版本(1.1 保持、1.0 不保持)。reuse 再看一眼 body:没读完就不复用,shutdown 掉。
所以代理侧的 body 必须读完,连接才回得了池。对明文 h1 服务发四个请求,前两个只读响应头就 release,后两个读完 body 再 release:
plain h1 server: release with or without reading the body
reused=false, read_body=false, new connections 1
reused=false, read_body=false, new connections 1
reused=false, read_body=true, new connections 1
reused=true, read_body=true, new connections 0
只读头的那两次,连接都没回池,下一个请求新建;第三次读完了 body,连接回池,第四次复用。
respect_keepalive 还会解析上游的 Keep-Alive: timeout=N,存成 KeepaliveStatus::Timeout(N 秒),但这个值之后只用来区分「保持 / 不保持」:release_stream 用的空闲超时是 peer.idle_timeout(),上游声明的 N 秒没有传进连接池。想让代理在上游之前关掉空闲连接,只能自己按上游的配置设 idle_timeout。
h2:一条连接分给多个请求
ConnectionRef 是 h2 连接的共享句柄,Arc 里放着 h2 的 SendRequest、连接关闭的 watch、max_streams(允许的并发流数)和 current_streams(正在跑的流数)。开一条流是 spawn_stream:先 fetch_add 占一个名额,超了就退回去返回 Ok(None),没超再 SendRequest::ready().await。
v2::Connector 有两个池:
in_use_pool(InUsePool):有流在跑、还能再开流的连接。idle_pool:一条流都没有的连接,用的是上一页的ConnectionPool,被 LRU 挤掉、连接关闭或空闲超时就清出去。
reused_http_session 先从 in_use_pool 取(过滤掉已关闭的),没有再从 idle_pool 取,然后 spawn_stream,连接还有余量就放回 in_use_pool。注意「取」是真的拿出来:
// NOTE: We grab a conn from the pools, create a new stream and put the conn back if the
// conn has more free streams. During this process another caller could arrive but is not
// able to find the conn even the conn has free stream to use.
// We accept this false negative to keep the implementation simple. This false negative
// makes an actual impact when there are only a few connection.
流结束时,release_http_session 在连接的 release_lock 下 drop 掉流(current_streams 减一),把连接从 in_use_pool 拿出来:已关闭或正在关闭就扔掉;一条流都没有了就进 idle_pool,起一个 idle_timeout 任务;还有流就放回 in_use_pool。
并发流数的上限是 PeerOptions::max_h2_streams,默认 1。handshake 再和 h2 连接当前的 max_concurrent_send_streams() 取较小值,作为这条连接的 max_streams;决定连接还放不放回 in_use_pool 的 more_streams_allowed 另外还看 h2 的 current_max_send_streams()。握手还设了几样:不接受服务端推送、max_frame_size 建议 64 KiB、两个接收窗口默认 8 MiB(H2_WINDOW_SIZE,注释说 h2 库的内部缓冲没有上限,下游比上游慢时窗口就是内存上限,并算了一笔「8 Mbytes = 80 Mbytes X 100ms」的带宽时延积)。
对 h2c 服务(set_http_version(2, 2)),每种 max_h2_streams 做两组:空池时同时发 10 个请求;新建一个连接器,先发 1 个请求预热,再同时发 10 个。每个请求在上游耗时 100ms,10 个请求的时间完全重叠:
h2c, ALPN H2 (set_http_version(2, 2))
max_h2_streams = 1
cold pool:
10 concurrent requests: 10 new upstream connections, 0 sessions reused
warm-up: 1 request, 1 new connection
10 concurrent requests: 9 new upstream connections, 1 sessions reused
max_h2_streams = 5
cold pool:
10 concurrent requests: 10 new upstream connections, 0 sessions reused
warm-up: 1 request, 1 new connection
10 concurrent requests: 7 new upstream connections, 3 sessions reused
max_h2_streams = 100
cold pool:
10 concurrent requests: 10 new upstream connections, 0 sessions reused
warm-up: 1 request, 1 new connection
10 concurrent requests: 3 new upstream connections, 7 sessions reused
这是 3 次里的第二次。空池那组每次都是 10 条新连接,max_h2_streams = 1 预热后每次都是 9 条;5 预热后三次是 5、7、5 条,100 预热后是 4、3、1 条。本页其余输出都取自这一次运行。
- 默认值 1 等于不复用流。 h2 连接一次只跑一个请求,10 个并发请求要 10 条连接,和 h1 没区别。
- 空池时没有「正在建连」的锁。 10 个请求同时发现两个池都是空的,各自
new_http_session,建了 10 条。hyper-util 的池在这里有一把 connecting 锁,同一个键同时只建一条 h2 连接,其他请求等它(见 hyper 卷的连接池)。 - 预热后也做不到「一条连接全包」。
max_h2_streams = 100时理论上 1 条连接就够,实测还要多建 1 到 4 条,就是上面注释说的假阴性:一个请求把连接从in_use_pool拿出来,spawn_stream里ready().await的那一小段时间,别的请求看不到它,只好新建。5的那组理论上应该新建 1 条(预热那条跑 5 个,再建 1 条跑另外 5 个),实测 5 到 7 条。 - 第二组的新连接建好后,第一条流用完,它们都会进
idle_pool;之后的请求会复用它们,连接数不会无限涨,但上游看到的并发连接数比max_h2_streams换算出来的多。
new_http_session 握手完立刻 spawn_stream。上游如果刚握手就发 GOAWAY(NO_ERROR),spawn_stream 走优雅关闭的分支,mark_shutdown 后返回 Ok(None)。0.9.0 之前这里写的是:
let h2_stream = conn
.spawn_stream()
.await?
.expect("newly created connections should have at least one free stream");
提交 5e0f216(「Return error on new conn h2 spawn stream」)把 expect 换成 or_err(H2Error, "newly created connection has no free streams (server may have sent GOAWAY)"),说明写的是:「As opposed to panicking on an error while spawning a new stream, which may happen in rare situations if a server returns GOAWAY immediately upon creating the connection.」同一个提交加了一个测试 test_spawn_stream_goaway_no_error_returns_none,用 h2::server 握手后立刻 graceful_shutdown。首次发布在 0.9.0。
h2 出错之后:prefer_h1
proxy_to_upstream 在 h2 请求出错后看错误类型(HttpProxy::proxy_to_upstream):
if let Some(e) = error.as_mut() {
// try to downgrade if A. origin says so or B. origin sends an invalid
// response, which usually means origin h2 is not production ready
if matches!(e.etype, H2Downgrade | InvalidH2) {
if peer
.get_alpn()
.is_none_or(|alpn| alpn.get_min_http_version() == 1)
{
// Add the peer to prefer h1 so that all following requests
// will use h1
self.client_upstream.prefer_h1(&*peer);
} else {
// the peer doesn't allow downgrading to h1 (e.g. gRPC)
e.retry = false.into();
}
}
}
prefer_h1 写进 TransportConnector 的 PreferredHttpVersion:一个 RwLock<HashMap<u64, u8>>,键是 reuse_hash。之后 new_stream 给这个 peer 建连接时 ALPN 只发 http/1.1,get_http_session 里 h2_only 也因为 h1_is_preferred 变成假。这张表没有过期,也没有大小上限,源码上方留着两条 TODO:「shard to avoid the global lock」和「limit the size of this」。上游后来修好了 h2,这个进程也一直用 h1,直到重启。
release_http_session
Connector::release_http_session 按会话类型分派:h1 交给 v1(async,不能复用时要 shutdown);h2 交给 v2(同步,只动计数和池);自定义协议交给 custom 连接器。代理在 proxy_to_upstream 里:h1 只有 proxy_to_h1_upstream 说连接还能用(client_reuse)时才 release,否则直接 drop;h2 总是 release,流 drop 掉,连接是否还能用由 release_http_session 自己判断。idle_timeout 传的都是 peer.idle_timeout()。
动脑筋答案
ALPN H2
H1 reused=false -> h1 conn #56
H1 reused=false -> h1 conn #57
两个都是 h1,第二个没有复用,又建了一条新的 TLS 连接 #57。连跑 3 次都是这样。
- 两次 h2 池都是空的(从来没建成过 h2 连接)。
h2_only为真(最低版本 2,且没有prefer_h1记录),所以不查 h1 池,直接new_http_session。- 握手时客户端只提供
h2,服务端不选,selected_alpn_proto()是None,又是 TLS,于是返回 h1 会话:「只要 h2」在这里并没有被强制。 - 用完后这条 h1 连接进了 h1 池,可下一次
h2_only依然不查 h1 池。每个请求都多一次 TCP 加 TLS 握手,池里的 h1 连接一条也用不上,只能等对端关闭或者被 LRU 挤掉。prefer_h1只在H2Downgrade/InvalidH2错误时才记,这里请求都成功了,不会触发。
小结
get_http_session按 ALPN 分派:H1只用 h1 池;H2H1先 h2 池、再 h1 池、再新建;H2不查 h1 池。新建时由握手结果决定协议:TLS 无 ALPN 或协商出 h1 都给 h1 会话;明文只有H2才说 h2c。- h1:
respect_keepalive加reuse决定连接能否回池,body 没读完就不回;上游的Keep-Alive: timeout不进连接池。 - h2:
ConnectionRef记流数,in_use_pool放还有余量的连接,idle_pool放空闲连接;max_h2_streams默认 1。实测空池并发时每个请求各建一条,预热后仍有假阴性。 H2Downgrade/InvalidH2让这个 peer 永久改用 h1(进程内),表没有上限也不过期。- 实测 ALPN
H2对不支持 h2 的 TLS 上游:拿到 h1 会话,且每个请求都新建连接。