Rust in Everything
基于 reqwest 0.13.5

只看状态码就丢掉响应,连接没法复用

主线位置:响应主线的最后一步,连接什么时候回到池里。逐种情况(读完、drop 小响应、drop 大响应、拿着不读)的实测和 HTTP/2 的对照见 响应体 的「连接什么时候回池」;hyper 那一层的代码见 hyper 卷的 连接池。这一页看一个常见写法:健康检查、批量探测这类只要状态码的请求。

flowchart TB
  r["拿到 Response"] --> st["只看 status"]
  st --> d["drop Response"]
  d --> v{"HTTP 版本"}
  v -->|HTTP/1.1| dr["试着排空一次"]
  dr -->|排空| pool["回池复用"]
  dr -->|没排空| close["关掉连接"]
  close --> next["下个请求 新连接"]
  v -->|HTTP/2| rst["只重置这个流"]
  rst --> pool

实验 crate:exp-reqwest/pita-lab,src/bin/p04_body.rs。服务端是本机 testsrv 的 HTTPS,?size=N 回一个 N 字节的 body,Proto::H1 / Proto::H2 让 ALPN 只给一种协议。每种组合起一个新服务器、一个新 Client,先用一个临时客户端预热 aws-lc-rs。M2 Max、macOS 27、rustc 1.99.0,release。

现象

for _ in 0..n {
  let r = c.get(&url).send().await.unwrap();
  assert!(r.status().is_success()); // all the caller wanted
  if read {
    r.bytes().await.unwrap();
  }
}

20 个请求,body 100 字节或 1 MiB,读完或直接丢掉:

https HTTP/1.1  body     100 B  dropped      20 requests: conns=1  tls=1     4.0ms
https HTTP/1.1  body     100 B  read to end  20 requests: conns=1  tls=1     4.2ms
https HTTP/1.1  body 1048576 B  dropped      20 requests: conns=20 tls=20   30.9ms
https HTTP/1.1  body 1048576 B  read to end  20 requests: conns=1  tls=1    21.0ms
https HTTP/2    body     100 B  dropped      20 requests: conns=1  tls=1     4.8ms
https HTTP/2    body     100 B  read to end  20 requests: conns=1  tls=1     3.9ms
https HTTP/2    body 1048576 B  dropped      20 requests: conns=1  tls=1    23.1ms
https HTTP/2    body 1048576 B  read to end  20 requests: conns=1  tls=1    22.6ms

连跑三遍,连接数和握手数每遍都一样,耗时在 ±3 ms 内(uptime 负载 5.4)。

  • HTTP/1.1 上丢掉 1 MiB 的响应:20 个请求 20 条连接、20 次握手。 读完的话只要 1 条。
  • 丢掉 100 字节的响应没事,照样复用。
  • HTTP/2 上怎么丢都只有 1 条连接。
  • 在回环上,丢掉反而比读完慢(31 ms 对 21 ms):省下的 1 MiB 传输抵不上 19 次额外的握手。真实网络上每次新连接还要多付 TCP 和 TLS 的往返。

原因

reqwest 在这里什么也没做,Response 被 drop 时,它包着的 hyper body 也被 drop,后面全是 hyper 的事。

HTTP/1.1 一条连接同一时间只跑一个请求。 上一个响应的 body 没读完,连接上还躺着没读的字节,下一个请求不能用它。body 的接收方被丢掉后,hyper 的客户端调 Conn::poll_drain_or_close_read:再试着读一次 body,读到结尾(剩下的字节已经在读缓冲里)就算排空,连接照常 keep-alive;读不完就 close_read,连接作废。100 字节在读响应头时就一起进了缓冲,一次就排空;1 MiB 不可能。连接作废以后,池里没有可用连接,下一个请求新建。

HTTP/2 一条连接上跑很多流。 一个响应只占一个流,hyper-util 拿到响应头就把连接的引用还给池了。丢掉没读完的响应只重置那个流,连接照常给下一个请求用。

拿着 Response 既不读也不丢是第三种情况:HTTP/1.1 上连接被它占着,后面的请求同样只能新建,见 响应体。

修法

先确定要不要那个 body:

  • 只要状态码,服务器支持 HEAD: 用 client.head(url)。响应没有 body,HTTP/1.1 上连接直接回池。同一个 1 MiB 的地址换成 HEAD(src/bin/p04_head.rs):

    https HTTP/1.1  HEAD, body 1048576 B  20 requests: conns=1 tls=1
    
  • 只要状态码,但只能 GET,响应不大: 读完再丢,let _ = r.bytes().await;。上面 read to end 那几行,1 条连接。

  • 响应很大,确实不需要内容: 读完要传完整个 body,关连接只多一次握手,哪个划算取决于 body 大小和往返时间。这时丢掉是可以接受的选择,只要知道它会关连接。

  • 服务器支持 HTTP/2: 这个问题不存在。0.13 默认 TLS 带 ALPN,HTTPS 上服务器同意就会走 HTTP/2,见 连接池与 HTTP 版本。

不要在 HTTP/1.1 上只读一部分(比如 chunk() 读一块)就丢掉:读一半和完全不读一样,连接照样被关,见 响应体 里「1 chunk then drop」那一行。

怎么检查

  • 看服务端或负载均衡的新建连接数: 健康检查、批量探测类的任务,如果新建连接数和请求数一样多,先看是不是这个坑。
  • 看日志: RUST_LOG=hyper_util=debug 下,每个请求都有 connecting to、没有 reuse idle connection,而 Client 是同一个,就说明连接没回池。
  • 看 r.version(): 是 HTTP/2.0 的话可以不管;HTTP/1.1 的才要考虑读完。

小结

  • HTTP/1.1 上丢掉一个没读完的大响应,hyper 排不空就关连接,下一个请求新建连接、重新握手。
  • 小响应在读头时已经进了缓冲,丢掉也能复用;HTTP/2 上丢掉只重置一个流,连接不受影响。
  • 只要状态码时用 HEAD,或者把 body 读完;body 很大时,「读完」和「关连接」之间按大小和往返时间取舍。

💬 关联讨论

发起讨论
加载中...