Rust in Everything
基于 pingora-core 0.9.0

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
H2H1set_http_version(2, 1)h2 池h1 池v2::new_http_session
H2set_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。

❗IMPORTANT
动脑筋:同一个只说 HTTP/1.1 的 TLS 上游,ALPN 换成 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 换算出来的多。
📝NOTE
冷知识:新连接一条流也开不出来,以前会 panic

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 会话,且每个请求都新建连接。

💬 关联讨论

发起讨论
加载中...