连接池
主线位置:请求主线的两头。第四步
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。这样做没有跨线程的锁竞争,但带来两个问题:
- 上限是每个线程一份。
size是 128,四个工作线程都往池里还连接,池里最多可以有 512 条。 - 在别的线程取走,记录留在原线程。 连接 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。
提交 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(对端已关)、读到数据(服务端不该在空闲连接上主动发东西)或出错,都不放回,连接随streamdrop 关掉;只有「现在没东西可读」才算可用。读到数据时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:每条空闲连接一个任务
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 收到 true | get 取走了它,PoolConnection::release 发的 | 任务退出,drop 锁守卫,取走的人拿到锁 |
evicted | LRU 满了挤掉它 | 任务退出;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
}
// ...
- 等锁。 监视任务收到
true后才会退出并放开守卫,这里lock().await等它放。 matches_fd。 用getpeername查这个 fd 现在连的是谁,和 peer 的地址比(InetSocketAddr的实现),不一致就打一条Crit: FD mismatch的错误日志。连接的 ID 是 fd 号,下面的实验里连续三个请求用到的上游连接都是fd 15,其中后一条是新连接:fd 号复用很常见,这道检查防的是池子的账和真实 socket 对不上。- 再测一次
test_reusable_stream。 放回时测过一次,空闲期间idle_poll一直在读;这里补的是「监视任务退出到现在」这段空档。
任何一道不过都返回 None,get_stream 转去新建连接。它不会回头再去池里拿下一条:池里同组还有别的空闲连接,这次也用不上。
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 在错误、重试与故障转移。
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,同组内先进先出;Lru16 个分片、全局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 层面压测稳定复现,代理层面的一次压测未复现。