突发请求打到新主机,N 条连接 N 次握手
主线位置:请求主线和连接主线的交点。请求到池里找连接,找不到就走连接主线新建一条;HTTPS 连接是 HTTP/1.1 还是 HTTP/2,要等 TLS 握手里的 ALPN 结果出来才知道。池子怎么配、ALPN 怎么决定版本,见 连接池与 HTTP 版本;池子本身见 hyper 卷 连接池。
sequenceDiagram participant R as 100 个请求 participant P as 连接池 participant S as 服务器 R->>P: 同时 checkout P-->>R: 没有空闲连接 R->>S: 100 次 TCP 加 TLS S-->>R: ALPN 都是 h2 R->>P: 第一条登记为 h2 P-->>R: 其余 99 条 Canceled R->>S: 100 个流走 1 条连接
实验 crate:exp-reqwest/pitb-lab(src/bin/burst.rs)。M2 Max、macOS 27、rustc 1.99.0,reqwest 0.13.5,release 构建,uptime 负载约 4.3。服务器是 testsrv 的 TLS 模式(ALPN h2、http/1.1),每个请求晚 50 毫秒回响应头;它数接受的 TCP 连接、完成的 TLS 握手和收到的请求。每轮新起一个服务器和一个 Client,客户端 .no_proxy().tls_certs_only([ca])。计时前先对另一个服务器发过一个请求,付掉 aws-lc-rs 的初始化开销。
现象
服务启动后第一件事就是并发调一个上游,比如启动时批量拉取配置,或者队列积压的任务同时开工:
let vs = join_all((0..N).map(|_| {
let c = c.clone();
let url = srv.url("/?head_ms=50");
// ...
async move {
let _permit = sem.acquire().await.unwrap();
let r = c.get(url).send().await.unwrap();
// ...
}
}))
.await;
N = 100,每种配置 3 轮;信号量 sem 平时有 100 个许可,等于不限;最后一组只给 10 个:
default round 1: HTTP/2.0 tcp=100 tls=100 reqs=100 106ms
default round 2: HTTP/2.0 tcp=100 tls=100 reqs=100 171ms
default round 3: HTTP/2.0 tcp=100 tls=100 reqs=100 148ms
one warm-up request first round 1: HTTP/2.0 tcp=1 tls=1 reqs=101 57ms
one warm-up request first round 2: HTTP/2.0 tcp=1 tls=1 reqs=101 54ms
one warm-up request first round 3: HTTP/2.0 tcp=1 tls=1 reqs=101 55ms
http2_prior_knowledge round 1: HTTP/2.0 tcp=1 tls=1 reqs=100 66ms
http2_prior_knowledge round 2: HTTP/2.0 tcp=1 tls=1 reqs=100 58ms
http2_prior_knowledge round 3: HTTP/2.0 tcp=1 tls=1 reqs=100 61ms
warm-up, idle 300ms, wait 1s round 1: HTTP/2.0 tcp=101 tls=101 reqs=101 144ms
warm-up, idle 300ms, wait 1s round 2: HTTP/2.0 tcp=101 tls=101 reqs=101 132ms
warm-up, idle 300ms, wait 1s round 3: HTTP/2.0 tcp=101 tls=101 reqs=101 177ms
semaphore(10), no warm-up round 1: HTTP/2.0 tcp=10 tls=10 reqs=100 564ms
semaphore(10), no warm-up round 2: HTTP/2.0 tcp=10 tls=10 reqs=100 551ms
semaphore(10), no warm-up round 3: HTTP/2.0 tcp=10 tls=10 reqs=100 560ms
- 默认配置:100 次 TCP、100 次 TLS 握手。 响应都是 HTTP/2,100 个请求其实可以共用一条连接。服务器那一侧,是一个客户端瞬间打来 100 个握手。
- 墙钟时间 106 到 171 毫秒,预热过的只要 55 毫秒左右(其中 50 毫秒是服务器故意的延迟)。本机握手便宜,跨公网时每次握手还要加上往返。
- 预热不是一劳永逸。 最后一组先预热,再让连接在池里闲置超过
pool_idle_timeout(这里设成 300 毫秒,默认是 90 秒),下一波突发又是 100 次握手(加上预热那一次,共 101)。
原因
池子按「主机」加「是否 HTTP/2」管理连接。HTTP/2 连接可以多路复用,所以 hyper-util 对 HTTP/2 有一把「正在连接」的锁:已经有一条在建,别的请求不再建,等它好了共用。但这把锁只在事先知道是 HTTP/2 时才生效。默认配置下,Client 的版本偏好是「看 ALPN」,建连接之前不知道结果,每个 checkout 失败的请求都会进 connect_to 自己建一条,各做一次完整的 TLS 握手。握手完成、ALPN 说是 h2 时才检查:
// If ALPN is h2 and we aren't http2_only already,
// then we need to convert our pool checkout into
// a single HTTP2 one.
let connecting = if connected.alpn == Alpn::H2 && !is_ver_h2 {
match connecting.alpn_h2(&pool) {
Some(lock) => {
trace!("ALPN negotiated h2, updating pool");
lock
}
None => {
// Another connection has already upgraded,
// the pool checkout should finish up for us.
let canceled = e!(Canceled, "ALPN upgraded to HTTP/2");
return Either::Right(future::err(canceled));
}
}
第一条拿到 h2 锁,其余 99 条在这里被丢弃,它们的请求从池里拿到第一条连接的克隆。握手已经做完了,丢掉的只是结果。reqwest 自己不改这段逻辑,它只是用 hyper-util 的 legacy Client,并且默认 ALPN 同时报 h2 和 http/1.1。
池里有一条已知是 HTTP/2 的空闲连接时,checkout 直接拿到它,不进 connect_to,所以预热有效。连接离开池子以后(比如上面最后一组的空闲超时),这个主机又回到「没见过」的状态。
修法
一:突发之前先发一个请求。 对已知会突发的上游(启动时、长时间空闲之后),先 await 一个便宜的请求,比如健康检查,让池里有一条 h2 连接,再放开并发。上面第二组就是这样:1 次握手。它对 HTTP/1.1 的上游没有坏处,只是不会少建连接(HTTP/1.1 一条连接同时只跑一个请求)。
二:上游确定说 HTTP/2 时,用 http2_prior_knowledge()。 版本事先定死,hyper-util 的「正在连接」锁一开始就生效,第三组也是 1 次握手。代价:这个 Client 的所有主机都只能是 HTTP/2,对只说 HTTP/1.1 的 HTTPS 服务器握手直接失败(连接池与 HTTP 版本 里有报错原文)。适合一个 Client 只对一个已知上游的场合。
三:限制并发不能代替预热。 最后一组用信号量把同时在途的请求压到 10:第一批 10 个照样各建一条连接、各做一次握手。信号量本身还把 100 个请求分成 10 批,用了 560 毫秒左右。
想让预热的效果维持得久一点,可以调大 pool_idle_timeout(默认 90 秒),但服务器那一侧也有自己的空闲超时,先到的那个说了算。
怎么检查
- 服务器侧: 看 TLS 握手数和连接数。一个客户端进程在启动后一秒内打来几十次握手,然后只有一条连接在干活,就是这个坑。
- 客户端侧: 被丢弃的连接在
connect_to里以Canceled结束,它们的请求改用第一条连接,本身不会失败,所以应用日志里什么也看不到。上面每组 100 个请求全部成功。 - 复现时每轮都要换新的
Client:同一个Client第二轮之前池里已经有连接了。
小结
- 默认 ALPN 下,对一个池里没有连接的 HTTPS 主机同时发 N 个请求,就是 N 次 TCP、N 次 TLS 握手,最后只留 1 条 HTTP/2 连接。
- 原因是 hyper-util 要到 ALPN 出结果才知道连接能不能共用,「正在连接」的锁对默认配置不生效。
- 修法:突发之前先发一个请求;上游确定说 HTTP/2 时用
http2_prior_knowledge()。限制并发不能替代预热。 - 空闲连接过期(默认 90 秒)后,下一次突发会重来一遍。