上游 h2 不复用连接
主线位置:请求主线第四步,连接器给
HttpPeer取一个上游会话。ALPN 决定查哪个池,max_h2_streams决定一条 h2 连接上能同时开几条流。机制见 HTTP 连接器,默认值表在 Peer。
flowchart TB
get["get_http_session"] --> h2p{"h2 池有余量"}
h2p -->|有| reuse["复用 开新流"]
h2p -->|没有| only{"ALPN 是 H2"}
only -->|H2H1| h1p["再查 h1 池"]
only -->|H2| newc["新建连接"]
h1p -->|空| newc
newc --> alpn{"握手选了 h2"}
alpn -->|是| h2s["h2 会话"]
alpn -->|否| h1s["h1 会话 进 h1 池"]
实验 crate exp-pingora/pitb-lab,bin p16_proxy(代理,19310)和 p16_up(两个上游),脚本 tools/p16.sh(debug);M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0,tokio 钉在 1.53.2(原因见怎么读这一卷)。上游一是 hyper 的 h2c 服务(19311),每个请求睡 100 ms;上游二是只说 HTTP/1.1 的 TLS 服务(19312,openssl,不设 ALPN 回调)。响应体写着服务它的是第几条上游连接。
现象
代理对 /h2c/* 用 set_http_version(2, 2)(明文上只有它才说 h2c),对 /tls/* 按环境变量 ALPN 设:
if s.req_header().uri.path().starts_with("/h2c") {
peer = HttpPeer::new("127.0.0.1:19311", false, String::new());
peer.options.set_http_version(2, 2);
if let Some(n) = std::env::var("STREAMS").ok().and_then(|v| v.parse().ok()) {
peer.options.max_h2_streams = n;
}
} else {
peer = HttpPeer::new("127.0.0.1:19312", true, "h1.example".to_string());
peer.options.verify_cert = false;
peer.options.verify_hostname = false;
match std::env::var("ALPN").as_deref() {
Ok("h2") => peer.options.set_http_version(2, 2),
Ok("h2h1") => peer.options.set_http_version(2, 1),
_ => {}
}
}
客户端先发一个 /h2c/0 预热,再用 curl -Z --parallel-max 10 同时发 10 个 /h2c/*;然后对 /tls/* 顺序发 3 个(三次独立的 curl)。不设 STREAMS,ALPN=h2(out/p16-h2.txt):
warm-up: h2c conn #1
2 h2c conn #1
1 h2c conn #2
1 h2c conn #3
1 h2c conn #4
1 h2c conn #5
1 h2c conn #6
1 h2c conn #7
1 h2c conn #8
1 h2c conn #9
10 parallel took 261 ms
h2c upstream connections: 9
tls #1: tls h1 conn #1
tls #2: tls h1 conn #2
tls #3: tls h1 conn #3
tls upstream connections: 3
不设 STREAMS 的 h2c 部分一共跑了 5 次(包括 ALPN 取其他值的几轮),每次都是 9 条连接;不设 ALPN(默认 H1)时,tls 的三个请求都落在 conn #1。
- h2 打开了,却和 h1 一样多的连接。 11 个 h2 请求用了 9 条上游连接。预热的那条连接被 10 个并发请求里的一个拿走,其余的看到 h2 池里没有余量,各自新建;
conn #1出现两次,是第二个请求在第一个结束后才拿到它。 - ALPN
H2对只会 h1 的 TLS 上游:每个请求一条新的 TLS 连接。 三个请求三条连接,请求都成功,日志里什么都没有。
出处
max_h2_streams 在 PeerOptions::new 里是 1,字段注释是「How many concurrent h2 streams are allowed in the same connection.」。h2 连接器握手时把它和服务端的 max_concurrent_streams 取小,作为这条连接的流数上限;spawn_stream 占满一个名额后,这条连接不再放回 in_use_pool,下一个请求看不到它(HTTP 连接器的「h2:一条连接分给多个请求」)。上限是 1,就等于「h2 连接,h1 的用法」。
第二个现象在 Connector::get_http_session 里:
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?;
ALPN 是 H2 时 h2_only 为真,不查 h1 池,直接新建。握手时服务端没有选协议,v2::Connector::new_http_session 看到 TLS 且 ALPN 为空,返回一个 h1 会话;用完以后这条连接进了 h1 池,可下一个请求又不查 h1 池。只有 H2Downgrade / InvalidH2 错误才会让连接器记住「这个 peer 用 h1」,这里请求都成功了,不会触发。
修法
h2 上游把 max_h2_streams 调到服务端允许的并发数附近;ALPN 用 H2H1(set_http_version(2, 1)),除非上游真的只能说 h2(比如 gRPC):
peer.options.set_http_version(2, 1); // h2 if the server picks it, h1 otherwise
peer.options.max_h2_streams = 100;
STREAMS=100(h2c 仍是 (2, 2),明文上 H2H1 等于 h1):
warm-up: h2c conn #1
10 h2c conn #1
10 parallel took 238 ms
h2c upstream connections: 1
...
ALPN=h2h1 对 TLS h1 上游:
...
tls #1: tls h1 conn #1
tls #2: tls h1 conn #1
tls #3: tls h1 conn #1
tls upstream connections: 1
- 11 个 h2 请求共用 1 条连接。总时间 238 ms,和 9 条连接那次的 261 ms(另一次是 239 ms)差不多:每个请求在上游要 100 ms,瓶颈在上游,多开连接没有换来速度,只多了 8 次 TCP 握手和 8 份连接状态。
- 这次 10 个并发请求全落在预热的那条连接上。直接调连接器、请求真正同时到达时,
max_h2_streams = 100仍会多建 1 到 4 条,原因是连接器取连接时有一段「拿出来还没放回去」的窗口,见 HTTP 连接器的实测。 H2H1下第一次握手就落到 h1,之后先查 h2 池(空)、再查 h1 池,拿到同一条连接。
怎么确认没中招
- 搜
set_http_version(2:每一处问两句,max_h2_streams设了没有;最低版本是 2 的那些,上游是不是真的一定说 h2。 - 看上游的连接数:同一个代理进程到同一个 h2 上游,并发 N 个请求就有 N 条连接,就是
max_h2_streams还是 1。 - TLS 上游的访问日志里每个请求一次新握手,代理却配了 h2:ALPN 很可能是
H2,而上游不支持 h2。
小结
PeerOptions::max_h2_streams默认 1:打开 h2 以后一条连接同时只跑一个请求,并发请求各开一条连接。- ALPN
H2对不支持 h2 的 TLS 上游:拿到 h1 会话,但h2_only不查 h1 池,每个请求新建一条 TLS 连接,没有任何报错。 - 修法:
max_h2_streams调大;ALPN 用set_http_version(2, 1),只有确定上游说 h2 时才用(2, 2)。