Rust in Everything
基于 pingora-pool 0.9.0

连接池

主线位置:请求主线的两头。第四步 get_stream 先到这里找一条空闲的上游连接;最后一步响应转发完,上游连接经 release_http_session 回到这里,等下一个请求。中间这段时间,每条空闲连接都有一个任务盯着,对端一关就把它清出去。

flowchart TB
  rel["release_stream"] --> t1{"test_reusable_stream"}
  t1 -- 已关或有数据 --> drop1["丢弃"]
  t1 -- 可用 --> put["ConnectionPool::put"]
  put --> lru["Lru::add:可能挤掉最旧的"]
  put --> node["PoolNode::insert"]
  put --> watch["spawn idle_poll"]
  watch --> sel{"select"}
  sel -- 被取走 --> quit["任务退出"]
  sel -- 被挤掉 --> quit
  sel -- 对端关闭或超时 --> pop["pop_closed"]
  get["reused_stream"] --> g["ConnectionPool::get"]
  g --> node
  g --> t2{"matches_fd 与 test"}
  t2 -- 通过 --> reuse["复用"]
  t2 -- 不通过 --> newc["新建连接"]

连接池是一个独立的 crate:pingora-pool,src/ 下 1484 行,其中 connection.rs 1200 行(一半是单元测试),lru.rs 256 行。它不知道 HTTP,也不知道 TCP:池里放的是任意 S,键是 u64,连接的身份是一个 i32(Unix 上就是 fd)。pingora-core 的 TransportConnector 把它用在 HTTP/1 上,v2::Connector 把它用在 HTTP/2 的空闲连接上。

实验平台:M2 Max、macOS 27、rustc 1.99.0,pingora 0.9.0(对照组用 pingora-pool 0.8.0),tokio 钉在 1.53.2(原因见怎么读这一卷),release 构建。

一个服务几个池

TransportConnector 持有 Arc<ConnectionPool<Arc<Mutex<Stream>>>>。HttpProxy 的 http::Connector 里有一个 v1::Connector 和一个 v2::Connector,各自 new 一个 TransportConnector,v2::Connector 另外还有一个放 h2 连接的 idle_pool。h1 连接最终都还给 v1::Connector 的那个池(h2 连接器 ALPN 协商出 h1 时也一样,见 HTTP 连接器)。

池的大小来自 ConnectorOptions::from_server_conf:

            keepalive_pool_size: server_conf
                .upstream_keepalive_pool_size
                .saturating_mul(server_conf.threads.max(1)),

upstream_keepalive_pool_size 默认 128,乘以服务的线程数,才是池子的上限。为什么要乘,下面讲 0.8.0 时再说。不经过 ServerConf、直接 TransportConnector::new(None) 时是 DEFAULT_POOL_SIZE = 128,不乘。

数据结构

ConnectionPool 两个字段:

pub struct ConnectionPool<S> {
    // Concurrent per-key pool index; each value handles per-key connection storage.
    pools: DashMap<GroupKey, Arc<Pool<S>>>,
    lru: Lru<ID, ConnectionMeta>,
}
  • pools:按 GroupKey(就是 peer.reuse_hash())分组,每组一个 PoolNode。DashMap 内部按键分片加锁,不同上游的连接不抢同一把锁。
  • lru:所有组共用的一个 LRU,按连接 ID 记录 ConnectionMeta(组键加 ID),只用来决定「满了先挤掉谁」,不存连接本身。

PoolNode 里是一个 16 格的无锁队列 hot_queue(crossbeam 的 ArrayQueue),满了再放进一个 Mutex<HashMap<ID, T>>。get_any 先从队列头部弹,队列空了才去锁 HashMap,从里面随便拿一个。ArrayQueue 是先进先出,所以同一组里,最早放回的那条先被复用。往池里依次放 20 条连接再全部取出:

fd1 fd2 fd3 fd4 fd5 fd6 fd7 fd8 fd9 fd10 fd11 fd12 fd13 fd14 fd15 fd16 fd19 fd20 fd18 fd17

前 16 条按放入顺序出来,后 4 条是 HashMap 的迭代顺序。hyper-util 的空闲列表正好相反,后进先出,最近才空闲的先用(见 hyper 卷的连接池)。pingora 这里的选择出于锁:热路径只碰一个无锁队列。代价是先拿到的是空闲最久、离对端 keep-alive 超时最近的连接。

Lru 在 0.9.0 是 16 个分片:

pub struct Lru<K, T>
where
    K: Send,
    T: Send,
{
    /// Per-shard caches. Individually unbounded; global cap enforced via `len`.
    lrus: [Mutex<LruCache<K, Node<T>>>; N_SHARDS],
    /// Total live entries across shards. Lower `order` = older entry.
    len: AtomicUsize,
    /// Monotonic stamp assigned at insert; used to compare age across shards.
    order: AtomicU64,
    /// Global capacity. Eviction runs when `len` exceeds this.
    size: usize,
    drain: AtomicBool,
}

ID 哈希到一个分片,分片本身是不限大小的 LruCache,全局上限靠原子计数 len。每个节点进来时领一个全局递增的 order。put 把 len 加到超过 size 时调 evict_lru:一次锁住全部 16 个分片,反复比较各分片最旧节点的 order,弹出全局最旧的那个,notify_close 通知它,直到 len 降到 size - headroom。headroom 是 min(16, size / 4),一次多挤掉几个,后面几次超额插入就不用再锁全部分片。

每个节点带一个 Arc<Notify>(close_notifier),put 把它返回给调用方,被挤掉时 notify_one。盯着这条连接的任务靠它知道自己被挤掉了。

0.8.0:每个线程一个 LRU

0.8.0 的 Lru 只有三个字段:

pub struct Lru<K, T>
where
    K: Send,
    T: Send,
{
    lru: RwLock<ThreadLocal<RefCell<LruCache<K, Node<T>>>>>,
    size: usize,
    drain: AtomicBool,
}

每个线程第一次 put 时在 ThreadLocal 里得到一个自己的 LruCache,put 只看本线程这一份是否超过 size。这样做没有跨线程的锁竞争,但带来两个问题:

  1. 上限是每个线程一份。 size 是 128,四个工作线程都往池里还连接,池里最多可以有 512 条。
  2. 在别的线程取走,记录留在原线程。 连接 A 在线程 1 上 put,记录进了线程 1 的 LRU;线程 2 上的请求 get 到它,self.lru.pop(&id) 弹的是线程 2 那一份,线程 1 里的记录还在,成了「过期条目」(stale entry)。它占着线程 1 的名额,迟早被挤出去,挤出去时按 ID 去 PoolNode 里删。ID 是 fd,fd 号会被内核复用:这时候同一个 fd 号可能已经是另一条刚放回池里的活连接。

同一段程序分别链接 pingora-pool 0.8.0 和 0.9.0。第一段:池大小 2,四个线程同时活着,每个放两条;第二段:池大小 1,常驻线程 A 放进 fd 7,主线程取走它,再以同一个 fd 号放回一条新连接 M-fd7,然后线程 A 放进 fd 8:

  // 2. a connection taken out on another thread, then its fd number reused
  let pool = Arc::new(ConnectionPool::<String>::new(1));
  let (tx, rx) = mpsc::channel::<(i32, &'static str)>();
  let (done_tx, done_rx) = mpsc::channel();
  let p = pool.clone();
  // thread A lives on, so its thread-local state (0.8.0) persists
  std::thread::spawn(move || {
    for (fd, name) in rx {
      let (notify, _use) = p.put(&ConnectionMeta::new(KEY, fd), name.to_string());
      done_tx.send(notify).unwrap();
    }
  });
  tx.send((7, "A-fd7")).unwrap();
  let n_a7 = done_rx.recv().unwrap();
  println!("main get -> {:?}", pool.get(&KEY));
  let (n_m7, _use) = pool.put(&ConnectionMeta::new(KEY, 7), "M-fd7".to_string());
  tx.send((8, "A-fd8")).unwrap();
  let _n_a8 = done_rx.recv().unwrap();
  println!("evicted notice to old A-fd7: {}", n_a7.notified().now_or_never().is_some());
  println!("evicted notice to M-fd7:     {}", n_m7.notified().now_or_never().is_some());

pingora-pool 0.8.0:

size 2, 4 threads x 2 puts -> left in pool: 8 ["t0-fd1", "t0-fd2", "t1-fd3", "t1-fd4", "t2-fd5", "t2-fd6", "t3-fd7", "t3-fd8"]
main get -> Some("A-fd7")
evicted notice to old A-fd7: true
evicted notice to M-fd7:     false
left in pool: ["A-fd8"]

pingora-pool 0.9.0:

size 2, 4 threads x 2 puts -> left in pool: 5 ["t0-fd2", "t1-fd3", "t2-fd6", "t3-fd7", "t3-fd8"]
main get -> Some("A-fd7")
evicted notice to old A-fd7: false
evicted notice to M-fd7:     true
left in pool: ["A-fd8"]
  • 第一段,0.8.0 留下全部 8 条:四个线程各有一个大小为 2 的 LRU,谁都没超。0.9.0 留下 5 条,超过了上限 2,这是下面「并发放回时的上限」一节的问题;连跑 8 次是 2 到 5 条不等。
  • 第二段,两个版本最后池里都只剩 A-fd8,M-fd7 都被挤掉了,区别在通知发给了谁。0.8.0 里线程 A 的 LRU 还留着 A-fd7 的过期条目,A 放 fd 8 时它超额,被挤掉的是这个过期条目:通知发给了早就被取走的 A-fd7,再按 ID 7 去 PoolNode 删,删掉的却是主线程刚放回的 M-fd7。M-fd7 自己没收到通知,它在主线程那份 LRU 里的记录也没人删,又成了一个新的过期条目。0.9.0 里 A-fd7 被 get 时已经从全局 LRU 弹出,被挤掉的是真正最旧的 M-fd7,通知也发给了它。

第一段要让四个线程同时活着:thread_local 会把已退出线程的槽位交给新线程。第一版程序 spawn 一个、join 一个,0.8.0 也只剩 2 条,四个线程用的是同一份 LRU。

📝NOTE
冷知识:一个 LRU 换成全局 LRU,配置项的含义却没变

提交 3d55df5 的标题把原因说全了:「Shard connection pool and use a true global LRU rather than ThreadLocal to avoid stale entries in LRU, address other outstanding TODOs」。它删掉 thread_local 依赖,加上 dashmap;Lru 换成上面的 16 分片加全局 order;ConnectionPool 的 RwLock<HashMap> 换成 DashMap(0.8.0 这个字段上方的注释是 TODO: n-way pools to reduce lock contention)。

上限从「每线程」变成「全局」,要是别处不动,同样写 upstream_keepalive_pool_size: 128 的多线程服务,池子会一下子小到原来的几分之一。所以同一个提交改了 ConnectorOptions::from_server_conf,乘上 threads,字段注释写明是为了让「operator-facing config keeps its per-worker meaning」;docs/user_guide/conf.md 这一行也改成「The number of idle upstream connections to keep per tokio worker. The pool's effective ceiling is upstream_keepalive_pool_size × threads. Eviction is globally consistent across workers.」。首次发布在 0.9.0,CHANGELOG 的 Highlights 第一条:「Reworked connection pooling with sharded storage and a true global LRU, addressing stale entries and race windows.」

同一个版本里还有几个跟着修的提交:0f12481 让组空了之后从 pools 里删掉 PoolNode(提交说明算过,每个空节点因为预分配的 ArrayQueue 占约 1 KB,连过很多不同上游的进程会一直涨);6e2158d「Fix PoolNode race window」引入了现在的 insert_pool_connection,在持有 DashMap 条目守卫期间插入,不让清理空节点的代码在中间把节点删掉;b9257e7「Do not treat replaced connections as evicted」。

放回池子:release_stream

h1 的响应转发完,v1::Connector::release_http_session 先 respect_keepalive(看响应头和 HTTP 版本决定能不能保持连接),再 reuse()(body 没读完或者多读了字节就不复用,1ed40d4 在 0.9.0 加的「Prevent reuse after incomplete H1 responses」),通过了才调 TransportConnector::release_stream:

    pub fn release_stream(
        &self,
        mut stream: Stream,
        key: u64, // usually peer.reuse_hash()
        idle_timeout: Option<Duration>,
    ) {
        if !test_reusable_stream(&mut stream, &self.unexpected_data_conn_count) {
            return;
        }
        let id = stream.id();
        let meta = ConnectionMeta::new(key, id);
        debug!("Try to keepalive client session");
        let stream = Arc::new(Mutex::new(stream));
        let locked_stream = stream.clone().try_lock_owned().unwrap(); // safe as we just created it
        let (notify_close, watch_use) = self.connection_pool.put(&meta, stream);
        let idle_meta = IdleConnection::new(meta);
        let pool = self.connection_pool.clone(); //clone the arc
        let keepalive_pool_callback = self.keepalive_pool_callback.clone();
        let rt = pingora_runtime::current_handle();
        rt.spawn(async move {
            if pool
                .idle_poll(
                    locked_stream,
                    &idle_meta.connection,
                    idle_timeout,
                    notify_close,
                    watch_use,
                )
                .await
            {
                if let Some(callback) = keepalive_pool_callback {
                    callback(idle_meta.elapsed());
                }
            }
        });
    }
  • 放回之前先 test_reusable_stream:非阻塞地读 1 个字节。读到 EOF(对端已关)、读到数据(服务端不该在空闲连接上主动发东西)或出错,都不放回,连接随 stream drop 关掉;只有「现在没东西可读」才算可用。读到数据时 unexpected_data_conn_count 加一,0.9.0 起可以从连接器读这个计数。
  • 池里存的是 Arc<Mutex<Stream>>,同时把锁的 OwnedMutexGuard 交给监视任务。连接在池里的整段时间,锁都在监视任务手里。
  • idle_timeout 就是 peer.idle_timeout(),代理在 proxy_to_upstream 里传进来。默认 None。
  • 监视任务 spawn 在 pingora_runtime::current_handle() 上:NoSteal 运行时下随机挑一个线程,否则是当前的 tokio 运行时。
  • keepalive_pool_callback 是 0.9.0 新加的(eb9259a),只在 idle_poll 返回 true 时调用,而 idle_poll 只在连接被 LRU 挤掉时返回 true(b803372「only set evicted on true evictions」)。ConnectorOptions 上这个字段的注释写的是「before leaving the keep-alive pool without reuse」,对端关闭和空闲超时也是「没被复用就离开」,但这两种情况不会调用它。

idle_poll:每条空闲连接一个任务

ConnectionPool::idle_poll:

        let evicted = notify_evicted.notified();
        tokio::pin!(evicted);

        let read_result = tokio::select! {
            biased;
            event = watch_use => {
                return match event {
                    Ok(_) => {
                        debug!("idle connection is being picked up");
                        false
                    }
                    // ...
                    Err(_) => evicted.now_or_never().is_some(),
                };
            },
            _ = &mut evicted => {
                debug!("idle connection is being evicted");
                // TODO: gracefully close the connection?
                return true
            },
            read_result = read_with_timeout(connection , timeout) => read_result
        };

        match read_result {
            Ok(n) => {
                if n > 0 {
                    warn!("Data received on idle client connection, close it");
                } else {
                    debug!("Peer closed the idle connection or timeout");
                }
            }

            Err(e) => {
                debug!("error with the idle connection, close it {:?}", e);
            }
        };
        // connection terminated from either peer or timer
        self.pop_closed(meta);
        false

三个分支对应连接在池里的三种结局:

分支谁触发之后
watch_use 收到 trueget 取走了它,PoolConnection::release 发的任务退出,drop 锁守卫,取走的人拿到锁
evictedLRU 满了挤掉它任务退出;put 那边的 pop_evicted 已经把它从 PoolNode 删掉,Arc 最后一个引用一 drop,连接就关了
read_with_timeout 返回对端关闭(读到 0)、对端发了数据、读出错,或 idle_timeout 到期(read_with_timeout 把超时也当成读到 0)pop_closed:从 PoolNode 和 LRU 里都删掉

biased 让 select! 按书写顺序检查:同时就绪时「被取走」优先。读那个分支一直挂在 socket 上,所以对端一发 FIN,这个任务就醒了,不用等下一次取连接。Err(_) 那一支处理的是发送端被 drop 的情况:被挤掉时 pop_evicted 删掉 PoolConnection,发送端跟着 drop,watch_use 先于 evicted 就绪也会落到这里,所以要再看一眼 evicted 有没有信号(5220b5a,「Ensure watch_use also polls notify_evicted so we do not miss eviction events」)。

h2 的空闲连接不用 idle_poll,用的是 idle_timeout:h2 连接有自己的后台任务在读 socket(drive_connection),连接关闭时它会翻一个 watch,池子等这个信号和超时就行。

取出来:get 和 reused_stream

ConnectionPool::get 从 PoolNode 拿一条、从 LRU 删掉记录、给监视任务发 true。真正交给 HTTP 层之前,TransportConnector::reused_stream 还有三道手续:

        match self.connection_pool.get(&peer.reuse_hash()) {
            Some(s) => {
                debug!("find reusable stream, trying to acquire it");
                {
                    let _ = s.lock().await;
                } // wait for the idle poll to release it
                match Arc::try_unwrap(s) {
                    Ok(l) => {
                        let mut stream = l.into_inner();
                        // test_reusable_stream: we assume server would never actively send data
                        // first on an idle stream.
                        #[cfg(unix)]
                        if peer.matches_fd(stream.id())
                            && test_reusable_stream(&mut stream, &self.unexpected_data_conn_count)
                        {
                            Some(stream)
                        } else {
                            None
                        }
                        // ...
  1. 等锁。 监视任务收到 true 后才会退出并放开守卫,这里 lock().await 等它放。
  2. matches_fd。 用 getpeername 查这个 fd 现在连的是谁,和 peer 的地址比(InetSocketAddr 的实现),不一致就打一条 Crit: FD mismatch 的错误日志。连接的 ID 是 fd 号,下面的实验里连续三个请求用到的上游连接都是 fd 15,其中后一条是新连接:fd 号复用很常见,这道检查防的是池子的账和真实 socket 对不上。
  3. 再测一次 test_reusable_stream。 放回时测过一次,空闲期间 idle_poll 一直在读;这里补的是「监视任务退出到现在」这段空档。

任何一道不过都返回 None,get_stream 转去新建连接。它不会回头再去池里拿下一条:池里同组还有别的空闲连接,这次也用不上。

📝NOTE
冷知识:now_or_never 曾经会把死连接判成活的

test_reusable_stream 用 now_or_never 轮询一次 read:Some 表示立即有结果(EOF、数据或错误),None 表示「还没东西」,连接可用。问题在 tokio 的协作调度预算(见 tokio 卷的 coop budget):当前任务的预算用完时,tokio 的 I/O 资源一律返回 Pending,哪怕 socket 上已经有 EOF。now_or_never 于是得到 None,一条对端已经关闭的连接被当成可用的交出去,请求写上去才失败。

提交 9ceca4e(「Handle now_or_never soft preemption cases where None may be returned even if the future is ready」)把这一行包进 tokio::task::unconstrained,这次轮询不受预算限制,注释写着「tokio::task::unconstrained because now_or_never may yield None when the future is ready」。同一个提交在 h2 客户端、proxy_h1.rs、proxy_h2.rs 里改了同样的三处,首次发布在 0.5.0。

上游关掉空闲连接之后

用一个手写的上游看连接怎么被复用。它在每条连接上回带 Content-Length 的 HTTP/1.1 响应(默认 keep-alive),打印每条连接的客户端端口。代理在 connected_to_upstream 钩子里打印这次用的上游连接、本地端口、是不是复用的:

  async fn connected_to_upstream(
    &self,
    s: &mut Session,
    reused: bool,
    _peer: &HttpPeer,
    fd: std::os::unix::io::RawFd,
    digest: Option<&Digest>,
    _: &mut (),
  ) -> Result<()> {
    let local = digest
      .and_then(|d| d.socket_digest.as_ref())
      .and_then(|sd| sd.local_addr())
      .map(|a| a.to_string())
      .unwrap_or_default();
    println!("proxy: {} {} -> upstream fd {fd}, local {local}, reused {reused}",
      s.req_header().method, s.req_header().uri.path());
    Ok(())
  }

情形一:上游空闲 200ms 就关。 客户端连发 /a、/b,等 500ms 再发 /c(每个请求是一个新的 curl 进程,下游连接各不相同):

== upstream
  841ms up: accept #1 from port 62872
  841ms up: #1 got "GET /a HTTP/1.1"
  862ms up: #1 got "GET /b HTTP/1.1"
 1065ms up: #1 idle 200ms, close
 1407ms up: accept #2 from port 62875
 1407ms up: #2 got "GET /c HTTP/1.1"
 1609ms up: #2 idle 200ms, close
== proxy
[... DEBUG pingora_core::connectors] No reusable connection found for addr: 127.0.0.1:18485, scheme: HTTP
[... DEBUG pingora_core::connectors::l4] connected to new server: 127.0.0.1:18485
proxy: GET /a -> upstream fd 15, local 127.0.0.1:62872, reused false
[... DEBUG pingora_core::connectors] Try to keepalive client session
[... DEBUG pingora_core::connectors] find reusable stream, trying to acquire it
[... DEBUG pingora_pool::connection] idle connection is being picked up
proxy: GET /b -> upstream fd 15, local 127.0.0.1:62872, reused true
[... DEBUG pingora_core::connectors] Try to keepalive client session
[... DEBUG pingora_pool::connection] Peer closed the idle connection or timeout
[... DEBUG pingora_pool::connection] evict fd: 15 from key 6657565455465906728
[... DEBUG pingora_core::connectors] No reusable connection found for addr: 127.0.0.1:18485, scheme: HTTP
[... DEBUG pingora_core::connectors::l4] connected to new server: 127.0.0.1:18485
proxy: GET /c -> upstream fd 15, local 127.0.0.1:62875, reused false
...

(RUST_LOG=pingora_pool=debug,pingora_core::connectors=debug,时间戳只到秒,这里略去。)/b 复用了端口 62872 那条连接。1065ms 上游关掉它,idle_poll 的读分支读到 EOF,打出 Peer closed the idle connection or timeout,pop_closed 把它删掉(evict fd: 15 是 pop_evicted 里的日志,名字里有 evict,走的却是关闭的路)。/c 来的时候池里已经空了,新建端口 62875。三个请求都是 200,没有一个撞上死连接。

情形二:上游在下一个请求到达时关。 这次上游不等超时:每条连接回完第一个请求,读到第二个请求就直接关掉,不回任何字节。这模拟的是「上游的 keep-alive 超时恰好和代理发请求撞在同一时刻」:代理取连接时它还活着,test_reusable_stream 和 idle_poll 都拦不住。客户端发 GET /a、GET /b:

conn #1
 -> 200
conn #2
 -> 200
== upstream
  810ms up: accept #1 from port 62868
  810ms up: #1 got "GET /a HTTP/1.1"
  818ms up: #1 got "GET /b HTTP/1.1"
  818ms up: #1 close without answering
  818ms up: accept #2 from port 62870
  818ms up: #2 got "GET /b HTTP/1.1"
== proxy
proxy: GET /a -> upstream fd 15, local 127.0.0.1:62868, reused false
proxy: GET /b -> upstream fd 15, local 127.0.0.1:62868, reused true
[2026-10-29T04:55:34Z WARN  pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18485, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /b, Host: 127.0.0.1:18484
proxy: GET /b -> upstream fd 15, local 127.0.0.1:62870, reused false

/b 先拿到复用的连接 62868,请求写出去了,读响应头时读到 EOF。h1 客户端在 read_response 里给这种错误标 RetryType::ReusedOnly:只有连接是复用来的才值得重试,新连接上出这种错说明上游真有问题。代理的 error_while_proxy 用 client_reused = true 把它定成 retry: true,回到重试循环,再调一次 upstream_peer、再取连接,这次池里没有,新建 62870,上游第二次收到 GET /b,回 200。客户端感觉不到任何异常,上游却收到了两次同一个请求。重试循环本身、max_retries 和 fail_to_connect 在错误、重试与故障转移。

❗IMPORTANT
动脑筋:同一个上游,第二个请求换成 POST /b(curl -X POST -d x=1),curl 拿到的状态码是多少?上游一共收到几次 POST /b?

提示:error_while_proxy 的默认实现先看请求方法是不是幂等的。答案在文末。

并发放回时的上限

上面的对照实验里,0.9.0 的池子大小为 2,四个线程同时放 8 条,最后留下了 5 条。把压力加大看得更清楚:池大小 16,8 个线程同时各放 1 万条(同一个组键,没有任何 get),然后主线程再单线程放 1000 条。LRU 记着的条目,都会在这 1000 次里被挤掉;最后还留在 PoolNode 里、又来自并发阶段的,就是 LRU 已经不知道的连接:

# shared key
size 16: 5435 left, 5422 of them from the concurrent phase
size 16: 5302 left, 5289 of them from the concurrent phase
size 16: 5305 left, 5289 of them from the concurrent phase
# PER_THREAD_KEY=1
size 16: 3367 left, 3355 of them from the concurrent phase
size 16: 3585 left, 3572 of them from the concurrent phase
size 16: 2302 left, 2286 of them from the concurrent phase

同一个程序链接 0.8.0:共享组键时剩 178、190、190 条,每线程各用一个组键时三次都是 128 条,其中 112 条来自并发阶段,正好是 7 × 16:主线程最后那 1000 次 put 落进了某个已退出线程留下的那份 LRU,把它的 16 条挤掉了,其余 7 个线程的 LRU 各留 16 条。

原因在 ConnectionPool::put 的顺序:先 lru.add,超额时当场 evict_lru 并 pop_evicted,最后才 insert_pool_connection 把连接放进 PoolNode。LRU 是全局的以后,线程甲在 evict_lru 里挤掉的可能是线程乙刚加进 LRU、还没来得及放进 PoolNode 的条目。乙把 len 加到超额时自己也要进 evict_lru,而甲正锁着全部分片,乙就停在插入之前,窗口因此不止几条指令那么宽。甲的 pop_evicted 在 PoolNode 里找不到它,什么也没删;乙随后照常插入。这条连接留在 PoolNode 里,LRU 里却没有它,以后再也不会被挤掉;它的 close_notifier 已经被通知过,盯着它的 idle_poll 也已退出,对端关闭或空闲超时都没人处理,只能等某次 get 把它取出来,在 test_reusable_stream 那里被丢掉。每线程一个组键时,各线程不再往同一个 PoolNode 里插,留下的仍有几千条,所以主因不在同组 PoolNode 上的争用,而在「挤掉」和「插入」的先后。0.8.0 每个线程只挤自己 LRU 里的条目,而本线程的 put 是顺序执行的,没有这个窗口。

把它放回代理里试:threads = 4、upstream_keepalive_pool_size = 1(上限 4)、idle_timeout = 1s,上游每个响应延迟 50ms,curl 以 64 并发打 900 个请求。连跑 3 次,上游一共接受了 181、224、323 条连接,压测刚结束时还开着的是 4、3、4 条,3 秒后全部关闭,没有出现超额或泄漏。这个窗口需要多个线程同时在池满时放回连接;这次的负载没有触发它,pool 层面的压测则每次都触发。

动脑筋答案

conn #1
 -> 200
 -> 502
== upstream
  817ms up: accept #1 from port 62874
  817ms up: #1 got "GET /a HTTP/1.1"
  829ms up: #1 got "POST /b HTTP/1.1"
  829ms up: #1 close without answering
== proxy
proxy: GET /a -> upstream fd 15, local 127.0.0.1:62874, reused false
proxy: POST /b -> upstream fd 15, local 127.0.0.1:62874, reused true
[2026-10-29T04:55:35Z ERROR pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:18485, scheme: HTTP cause:  context: while reading response headers, bytes already read: 0, status: 502, tries: 1, retry: false, POST /b, Host: 127.0.0.1:18484

502,上游只收到一次 POST /b。错误和 GET 那次一模一样,同样是复用连接上的 ConnectionClosed,但 error_while_proxy 的默认实现在方法不幂等时直接 set_retry(false),根本不看 client_reused:POST 可能已经被上游处理过了,重发可能造成重复下单这类后果,所以宁可回 502。上游其实一个字节都没处理,可代理没法知道这一点。要减少这种 502,办法在池子这一侧:让 idle_timeout 比上游的 keep-alive 超时短,连接在上游关之前先被代理关掉。

小结

  • ConnectionPool = DashMap<组键, PoolNode> + 全局 Lru。PoolNode 是 16 格无锁队列加 HashMap,同组内先进先出;Lru 16 个分片、全局 order 和原子 len,满了锁住全部分片挤掉全局最旧的。
  • 0.8.0 的 LRU 每个线程一份:上限按线程算,跨线程取走的连接在原线程留下过期条目,按 fd 号挤掉时可能删掉同号的活连接。0.9.0(3d55df5)改成全局 LRU,keepalive_pool_size 相应地乘上 threads。
  • 放回前和取出后各做一次非阻塞的 1 字节读(test_reusable_stream,0.5.0 起包在 unconstrained 里),取出后还用 getpeername 核对 fd。
  • 每条空闲 h1 连接一个 idle_poll 任务:被取走、被挤掉、对端关闭或 idle_timeout 到期,三种结局。idle_timeout 默认 None;keepalive_pool_callback 只在被挤掉时调用。
  • 实测:上游关闭空闲连接,下一个请求新建连接,无错误;上游关在请求到达时,GET 被重试(上游收到两次),POST 回 502。
  • 0.9.0 的池在多线程同时放回、池已满时,会留下 LRU 不知道的连接,pool 层面压测稳定复现,代理层面的一次压测未复现。

💬 关联讨论

发起讨论
加载中...