写一个迷你代理
主线位置:把请求主线自己走一遍。accept 一条下游连接(对应服务与监听)→ 带超时读请求头(对应 HTTP/1 服务端)→
request_filter、upstream_peer(对应ProxyHttp)→ 从池里取一条上游连接或新建(对应连接池)→ 请求体往上、响应体往下同时转发,中间调response_filter(对应proxy_1to1)→ 上游连接回池,下游连接回到读下一个请求头。
flowchart TB
acc["accept 每连接一任务"] --> head["读请求头 带超时"]
head -->|超时或关闭| bye["关下游"]
head --> rf["request_filter"]
rf -->|Some Reply| reply["自己回应"]
rf -->|None| peer["upstream_peer"]
peer --> take{"池里有吗"}
take -->|有| duplex["send 与 recv 同时跑"]
take -->|没有| conn["connect"]
conn --> duplex
duplex --> resf["response_filter"]
resf --> put["上游连接回池"]
put --> head
reply --> head
mini 是一个库 hands-mini-proxy,只依赖 tokio 和 httparse:
[dependencies]
httparse = "=1.10.1"
tokio = { version = "=1.53.2", features = ["net", "io-util", "rt-multi-thread", "macros", "time", "sync"] }
pingora 只出现在另一个 crate hands-pingora-cmp 里:一个 40 多行的 ProxyHttp,监听 19010,转发到同一个上游,在 connected_to_upstream 里打印和 mini 同样格式的一行日志。mini 不用 BufReader,不受 tokio 1.54 那个断言影响;两个 crate 都钉在 tokio 1.53.2,是为了和对照组一致(原因见怎么读这一卷)。平台 M2 Max、macOS 27、rustc 1.99.0;测试是 debug 构建,对照实验是 release。库里没有 unwrap / expect,测试里随便用。hyper 卷的动手页从零写过一个服务端,这里的下游一侧和它是同一个思路,多出来的是上游一侧。
范围
| 文件 | 做什么 | 代码行(不含空行和注释) |
|---|---|---|
src/lib.rs | Filter、Proxy、下游连接循环、一次请求的转发、连接池 | 200 |
src/head.rs | Headers、请求头和响应头的解析(httparse)与编码、keep-alive 判断、逐跳头 | 143 |
src/body.rs | 读缓冲 Reader、读头、Framing 的判断、body 透传 | 148 |
tests/proxy.rs | 10 个测试,上游是测试里手写的 TCP 服务 | 327 |
examples/mini.rs | 19000 → 19001 的演示代理 | 21 |
examples/rawup.rs | 手写的上游:按客户端端口打印连接,三种模式(照常回、空闲多久就关、回完第一个请求读到第二个就关) | 91 |
examples/seq.rs、examples/idleprobe.rs | 并发 keep-alive 客户端;只连不发和发一个请求后不再发的两条连接 | 66、40 |
hands-pingora-cmp/src/main.rs | pingora 0.9.0 的对照代理 | 45 |
库 491 行代码,加上空行和注释 590 行(rustfmt,2 格缩进,max_width = 120)。计划里写的「约 400 行」只够核心:格式化之前是 425 行,多出来的是 rustfmt 把方法链拆成了多行。
三个阶段:Filter
/// A response the proxy writes itself instead of asking an upstream.
pub struct Reply {
pub status: u16,
pub body: Vec<u8>,
}
/// The three phases a request goes through.
pub trait Filter: Send + Sync + 'static {
/// Runs right after the request head is read. `Some` answers downstream and skips the upstream.
fn request_filter(&self, _req: &mut RequestHead) -> Option<Reply> {
None
}
/// Picks the upstream for this request; connections are pooled per address.
fn upstream_peer(&self, req: &RequestHead) -> SocketAddr;
/// Runs on the upstream response head before it goes downstream.
fn response_filter(&self, _req: &RequestHead, _resp: &mut ResponseHead) {}
}
和 ProxyHttp 比,少的不只是方法数(pingora 42 个,必须实现的是 new_ctx 和 upstream_peer):
- 短路的写法。pingora 的
request_filter返回Result<bool>,Ok(true)表示「我已经写过响应了」,写没写由调用者自己保证;只返回true不写,客户端就一直等。mini 返回Option<Reply>:要短路就得交出一个响应,由代理去写,「短路了却没有响应」这种状态在类型上不存在。代价是 mini 的过滤器不能流式写一个大响应,也不能写完响应后再做别的事。 - 没有
CTX。pingora 每个请求有一个new_ctx建出来的CTX,在各个钩子之间传;mini 的三个方法之间只靠RequestHead本身传数据。 - 全是同步的。pingora 有 17 个钩子是
async,upstream_peer里可以查服务发现、读远端配置;mini 的三个方法都不能.await。要改成异步,可以写成返回impl Future<Output = SocketAddr> + Send的方法,Send是为了tokio::spawn。 response_filter只能改头。框架在调它之前已经按原来的头决定了 body 怎么分界(见下文Framing),过滤器改了Content-Length也不会改变转发多少字节。pingora 有同样的问题,另有response_body_filter能改 body。
下游连接:keep-alive 与空闲超时
每条下游连接一个任务。循环里每一次读请求头都套着同一个 timeout:
async fn downstream(&self, mut stream: TcpStream) -> io::Result<()> {
let mut buf = Vec::new();
loop {
let mut reader = Reader::new(&mut stream, &mut buf);
let req = match timeout(self.idle_timeout, reader.read_head(parse_request)).await {
Err(_) | Ok(Ok(None)) => return Ok(()), // idle too long, or the client closed between requests
Ok(Ok(Some(req))) => req,
Ok(Err(e)) => {
write_reply(&mut stream, 400, b"", false).await?;
return Err(e);
}
};
if !self.one_request(&mut stream, &mut buf, req).await? {
return Ok(());
}
}
}
buf跨请求保留。请求头和请求体读剩下的字节留在buf里,下一圈read_head先解析它们。所以 mini 顺带支持了 pipelining:一次写出两个请求,两个响应依次回来。pingora 0.9.0 的 pipelining 默认关,同样的两个请求只回第一个,然后关连接(HTTP/1 服务端),实测一样:
## 19000
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive
conn #1
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive
conn #1
## 19010
HTTP/1.1 200 OK
Content-Length: 8
Date: Thu, 29 Oct 2026 06:05:21 GMT
Connection: keep-alive
conn #2
<FIN>
- 超时管的是整个请求头。
timeout包住的是read_head整体,不是第一次read:客户端发半个头就停,同样会在idle_timeout时被关掉。它同时当了「等下一个请求」和「读请求头」两种超时,pingora 把这两者分成keepalive_timeout和read_timeout。 - 第一个请求和以后的请求一样。pingora 新连接上的第一个会话由
process_new设成 60 秒;读到 HTTP/1.1 请求后,respect_keepalive把它改成「无限」,read_request_buf在无限时不设任何超时(详见 HTTP/1 服务端)。
两边各开两条连接,一条连上后什么都不发,一条发一个 GET 收完响应后不再发(examples/idleprobe.rs;mini 的 idle_timeout 设成 2 秒,pingora 用默认配置):
## mini, idle_timeout 2000 ms
one: FIN after 2.0s, got 70 bytes
silent: FIN after 2.0s, got 0 bytes
## pingora, 70 s limit
silent: FIN after 60.0s, got 0 bytes
one: still open after 70.0s, got 107 bytes
mini 两条都在 2.0 秒时被关;pingora 只连不发的那条 60.0 秒被关,发过一个请求的那条 70 秒后还开着。
7883c22(#539,首次进入 0.5.0)之前,读请求头时只有 KeepaliveStatus::Timeout 带超时,Infinite 和 Off 都走 _ => read_event.await,read_timeout 默认是 None。提交说明写的是「The initial read(s) for reading in the HTTP request headers from the client did not make use of the read_timeout, and a client could cause a connection to hang forever.」它把 read_timeout 的默认值改成 Some(Duration::from_secs(60))(「matches NGINX's default value for client_header_timeout and client_body_timeout」),让 _ 分支用上它。0.5.0 里同一连接上的每个请求都从一个新的 HttpSession 开始,状态是 Off,所以每一次读请求头都有这 60 秒。
0.6.0 里两个提交把它改回了无限。6f44ac7(「Persist keepalive_timeout between requests on same stream」,#541)让上一个请求的 keepalive 状态传给下一个会话,HTTP/1.1 请求经 respect_keepalive 变成 Infinite,下一个会话也就是 Infinite。337f878(「Allow explicit infinite keepalive timeout to be respected」)把 Infinite 从 _ 里拆出来,直接 read_event.await,加上那行 // FIXME: this should only apply to reads between requests;提交说明的第二段是「Note that a necessary follow up is to refactor the infinite keepalive timeout to only apply to first read between requests on reused conns.」这个后续到 0.9.0 和 main 的 84f21ee 都没有做,FIXME 还在。上面那条「70 秒后还开着」的连接,就是 0.6.0 起的行为。手头只有 0.8.0 和 0.9.0 的 crate,0.5.0 的行为是读源码得出的,没有跑过。mini 只有一个超时,对每一次读请求头都生效。
请求头:httparse 与大小写
httparse 在栈上的 64 个槽里解析,交出的是指向缓冲区的切片。mini 把它们拷成一个按到达顺序排列的 Vec,名字照原样存:
/// Headers in arrival order. Names keep the spelling they arrived with; lookups ignore case.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Headers(pub Vec<(String, Vec<u8>)>);
impl Headers {
pub fn get(&self, name: &str) -> Option<&[u8]> {
self.values(name).next()
}
pub fn values<'s, 'n>(&'s self, name: &'n str) -> impl Iterator<Item = &'s [u8]> + use<'s, 'n> {
self
.0
.iter()
.filter(move |(n, _)| n.eq_ignore_ascii_case(name))
.map(|(_, v)| v.as_slice())
}
// ... remove、set、has_token、encode_into
}
大小写保留因此是白来的:mini 从不把头放进一个键为小写的表,编码时按 Vec 的顺序原样写出。测试 header_case_kept_and_hop_by_hop_dropped 发一个 HTTP/1.0 请求,上游收到的整个请求头是:
GET /h HTTP/1.1\r\nhost: Example.COM\r\nX-MiXeD-Case: 1\r\n\r\n
host、X-MiXeD-Case 的拼写没变,版本改成了 1.1(和 pingora 的 proxy_h1 一样,发上游一律 HTTP/1.1)。请求里的 Connection: keep-alive, X-Drop、X-Drop: 1、Keep-Alive: timeout=5 都没了,删它们的是 strip_hop_by_hop:
/// Drops headers that describe one hop, plus whatever `Connection` names.
/// `Transfer-Encoding` stays: the body is passed through with its framing.
pub(crate) fn strip_hop_by_hop(headers: &mut Headers) {
let named: Vec<String> = headers
.values("Connection")
.flat_map(|v| v.split(|&b| b == b','))
.map(|t| String::from_utf8_lossy(t.trim_ascii()).into_owned())
.collect();
for name in named.iter().map(String::as_str).chain([
"Connection",
"Keep-Alive",
"Proxy-Connection",
"TE",
"Trailer",
"Upgrade",
]) {
if !name.eq_ignore_ascii_case("transfer-encoding") {
headers.remove(name);
}
}
}
Transfer-Encoding 按 RFC 也是逐跳头(hop-by-hop header),mini 却留着它:mini 不解码 body,chunked 原样往下传,头也得原样留着。pingora 0.9.0 默认也删逐跳头(HTTP/1 服务端),删掉 Transfer-Encoding 之后再按自己重新编码的方式补一个。Upgrade 被删,所以 WebSocket 过不了 mini:上游看不到升级请求,不会回 101。
响应方向也一样。上游 rawup 回 Content-Length 时首字母大写,curl 看到的两边的响应头:
## 19000
HTTP/1.1 200 OK
Content-Length: 8
Connection: keep-alive
## 19010
HTTP/1.1 200 OK
Content-Length: 8
Date: Thu, 29 Oct 2026 06:03:31 GMT
Connection: keep-alive
两边都保留了上游的拼写;pingora 自己补了 Date。所以「大小写保留」不是 mini 缺的东西。mini 缺的是 pingora 那套结构:RequestHeader / ResponseHeader 里是一个 http::HeaderMap(名字一律小写,查找是哈希),旁边另存一张 CaseMap 记原始拼写(请求头与错误)。过滤器拿到的是 http 生态的类型,查找不用逐个比;mini 每次 get 都要从头扫一遍 Vec,头一多就是平方级。
body:按分界原样透传
请求头读完,先按头决定 body 怎么结束:
/// How a message body ends.
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Framing {
Empty,
Length(u64),
Chunked,
/// Response only: the body runs until the upstream closes.
UntilClose,
}
body_framing 的规则:同时有 Transfer-Encoding 和 Content-Length 就拒绝;最后一个编码是 chunked 才算 chunked,请求不是就拒绝、响应不是就读到关闭;几个 Content-Length 值不一致就拒绝;请求两样都没有就是没有 body,响应两样都没有就读到关闭。请求被拒绝时回 400,连接关掉,测试 length_and_chunked_together_is_rejected 里上游一次连接都没收到。pingora 0.9.0 在两处和 mini 一样拒绝:不以 chunked 结尾的 Transfer-Encoding(87e2e2f,「Reject non-chunked transfer-encoding」,首次进入 0.8.0)、不一致的 Content-Length。Content-Length 和 chunked 并存时它不拒绝:按 chunked 读,删掉 Content-Length 再转发,回完响应关连接(HTTP/1 消息体的 b13)。mini 选了更简单的一条,直接 400。HEAD 请求的响应、204、304 不看头,一律没有 body:测试 head_response_has_no_body 里上游对 HEAD 回了 Content-Length: 5 不带 body,同一条连接上的下一个 GET 照常拿到 ok。
转发只认分界,不解码:
/// Passes one body to `dst` byte for byte, framing included, and stops where it ends.
pub(crate) async fn forward<W: AsyncWrite + Unpin>(&mut self, framing: Framing, dst: &mut W) -> io::Result<()> {
match framing {
Framing::Empty => Ok(()),
Framing::Length(n) => self.copy_exact(n, dst).await,
Framing::UntilClose => {
dst.write_all(self.buf).await?;
self.buf.clear();
tokio::io::copy(&mut self.io, dst).await.map(|_| ())
}
Framing::Chunked => loop {
let size_line = self.line().await?;
let Ok(httparse::Status::Complete((_, size))) = httparse::parse_chunk_size(&size_line) else {
return Err(invalid("bad chunk size line"));
};
dst.write_all(&size_line).await?;
if size == 0 {
// Trailer fields, then the empty line that ends the body.
loop {
let line = self.line().await?;
dst.write_all(&line).await?;
if line == b"\r\n" {
return Ok(());
}
}
}
self.copy_exact(size, dst).await?;
let crlf = self.line().await?;
if crlf != b"\r\n" {
return Err(invalid("chunk data longer than its size"));
}
dst.write_all(&crlf).await?;
},
}
}
块头那一行交给 httparse::parse_chunk_size 解析出大小,然后整行原样写出,扩展跟着一起走;结尾的 trailer 也一行一行原样写出。mini 只需要知道这个 body 在哪里结束,好让同一条连接上的下一个消息从对的地方开始。copy_exact 读到多少写多少,不攒整个 body。
同一个带扩展和 trailer 的 chunked 请求发给两边,上游 rawup 的 dump 模式打印收到的全部字节,并回一个同样带扩展和 trailer 的 chunked 响应:
== mini
client got 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nConnection: keep-alive\r\n\r\n3;up-ext=1\r\nabc\r\n0\r\nX-Up-Trailer: 1\r\n\r\n'
up: #1 got "POST /chunked HTTP/1.1\r\nHost: t\r\nTransfer-Encoding: chunked\r\n\r\n5;foo=bar\r\nhello\r\n0\r\nX-T: 1\r\n\r\n"
== pingora
client got 'HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\nDate: Thu, 29 Oct 2026 06:06:40 GMT\r\nConnection: keep-alive\r\n\r\n3\r\nabc\r\n0\r\n\r\n'
up: #1 got "POST /chunked HTTP/1.1\r\nHost: t\r\nTransfer-Encoding: chunked\r\n\r\n5\r\nhello\r\n0\r\n\r\n"
(两次运行的输出各取了客户端和上游的那一行。)mini 两个方向都一个字节不差;pingora 两个方向都把 ;foo=bar、;up-ext=1 和 trailer 丢了,块边界是它自己的。这和 HTTP/1 消息体里 22 种请求体的结果一致,那一页只测了请求方向。
问: 原样透传既简单又不丢东西,pingora 为什么要先解码再重新编码?
答: 因为 pingora 的 body 中途要经过别人的手。h1 转发里,上下游两半之间传的是 HttpTask,body 是 HttpTask::Body(Option<Bytes>, bool):一段去掉了分界的数据加「是不是最后一段」。response_body_filter 拿到的是 &mut Option<Bytes>,压缩模块(HTTP 模块与压缩)会把 body 变短,缓存存的也是数据本身;下游是 h2 时根本没有 chunked 这回事。body 一旦能被改,原来的块大小和 Content-Length 就不再对,只能在写出去的那一侧按新的数据重新分界。mini 的过滤器碰不到 body,才能原样透传。
双向转发:try_join! 两个 future
拿到上游连接后,两条 TCP 连接都用 split 拆成读写两半,组成两个 future:send 把请求头和请求体往上游写,recv 读响应头、调 response_filter、把响应往下游写。
let (down_r, mut down_w) = down.split();
let (up_r, mut up_w) = up.split();
// Request body upstream and response downstream run together, like proxy_h1's two halves.
let send = async {
up_w.write_all(&up_head).await?;
Reader::new(down_r, down_buf).forward(req_framing, &mut up_w).await
};
let recv = async {
let mut reader = Reader::new(up_r, &mut up_buf);
let mut resp = loop {
let resp = reader
.read_head(parse_response)
.await?
.ok_or_else(|| io::Error::new(io::ErrorKind::UnexpectedEof, "upstream closed before the response"))?;
if !(100..200).contains(&resp.status) {
break resp;
}
started = true; // 1xx goes downstream as is, e.g. 100 Continue
down_w.write_all(&resp.encode()).await?;
};
// ... 按响应头定 Framing 和两边的 keep-alive,删逐跳头
self.filter.response_filter(&req, &mut resp);
let connection = if down_keep { "keep-alive" } else { "close" };
resp.headers.set("Connection", connection);
started = true;
down_w.write_all(&resp.encode()).await?;
reader.forward(framing, &mut down_w).await?;
io::Result::Ok((up_keep, down_keep))
};
let result = tokio::try_join!(send, recv);
pingora 的 proxy_1to1 也是 try_join! 两半,只是每一半是一个 select! 循环,两半之间隔着两条容量为 4 的 HttpTask 通道(双向转发);mini 的两半直接读一个 socket、写另一个 socket,中间没有通道,背压就是 TCP 本身:下游不读,down_w.write_all 就停住,recv 也就不再读上游。
「同时」是要测出来的。测试 bodies_flow_both_ways_at_once 的上游收到一半请求体(hello)就先回响应头和 early,客户端要看到 early 之后才发另一半(world),上游收齐再发 later。把 try_join! 换成先 send.await 再 recv.await,这个测试在 read_until 的 5 秒读超时处失败(客户端等 early,代理还在等请求体的后一半),其余 9 个照常通过:
test bodies_flow_both_ways_at_once ... FAILED
thread 'bodies_flow_both_ways_at_once' (5804141) panicked at tests/proxy.rs:40:8:
test result: FAILED. 9 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 5.01s
try_join! 在任一边出错时丢掉另一边。出错时如果响应头还没写给下游(started 为假),mini 回 502;已经写了一部分,就只能关掉下游连接。两种情况上游连接都不回池。正常结束时,两边都要说可以 keep-alive,上游连接才回池:上游响应是 HTTP/1.1、没有 Connection: close、body 不是读到关闭的,而且 up_buf 里没有多读到的字节。下游连接能不能留,看客户端的 keep-alive 和响应 body 是不是读到关闭的。
request_filter 短路时请求体没有读:留着它,它就会被当成下一个请求头解析。所以有 body 的请求被短路后,mini 关掉下游连接;没有 body 才 keep-alive(测试 request_filter_answers_without_the_upstream:403 之后同一条连接上的 GET 照常走上游)。
连接池:一个 HashMap 加一个 Vec
/// Idle upstream connections, grouped by address.
struct Pool {
idle: Mutex<HashMap<SocketAddr, Vec<TcpStream>>>,
per_peer: usize,
}
impl Pool {
/// Newest first. Nothing checks whether the upstream closed the connection meanwhile.
fn take(&self, addr: SocketAddr) -> Option<TcpStream> {
self
.idle
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get_mut(&addr)?
.pop()
}
/// Keeps the connection if this address has room; otherwise it is dropped and closed.
fn put(&self, addr: SocketAddr, stream: TcpStream) {
let mut idle = self.idle.lock().unwrap_or_else(PoisonError::into_inner);
let list = idle.entry(addr).or_default();
if list.len() < self.per_peer {
list.push(stream);
}
}
// ... new、idle(测试用的计数)
}
和 pingora 的 ConnectionPool 比(连接池):
- 键。mini 按
SocketAddr分组;pingora 按reuse_hash分组,地址之外还有 TLS、SNI、证书校验这些选项(Peer),两个地址相同、TLS 设置不同的 peer 不能共用连接。 - 顺序。mini 的
Vec::pop后进先出,拿最近放回的那条;pingora 每组的ArrayQueue先进先出,拿最早放回的那条。下一节的 curl 序列里看得到:两条上游连接都空闲时,下一个请求 mini 拿的是 #2,pingora 拿的是 #1。 - 上限。mini 每个地址最多
per_peer条,满了就丢;pingora 的上限是全局的upstream_keepalive_pool_size(默认 128)乘以线程数,满了挤掉全局最旧的。 - 没人盯着空闲连接。pingora 每放回一条连接就 spawn 一个
idle_poll,对端一关、或空闲超时,就把它从池里删掉,取连接时还要再测一次。mini 放进Vec就不管了,上游关掉的连接照样留在池里,也没有空闲超时,上游不关它就永远占着。
和 pingora 跑同一组请求
上游是 examples/rawup.rs:每条连接回带 Content-Length 的 HTTP/1.1 响应,body 是 conn #N,打印每条连接的客户端端口。mini 监听 19000,pingora 监听 19010(默认配置,threads 为 1),都转发到 19001。tools/cmp.sh 依次起上游和代理,跑一组请求,再打印两边的日志。
curl 序列。一个 curl 进程发 /a、/b(同一条下游连接),新进程发 /c,两个 curl 同时发 /d、/e,最后发 /f:
== upstream
830ms up: accept #1 from port 62663
830ms up: #1 got "GET /a HTTP/1.1"
832ms up: #1 got "GET /b HTTP/1.1"
846ms up: #1 got "GET /c HTTP/1.1"
863ms up: accept #2 from port 62667
863ms up: #1 got "GET /e HTTP/1.1"
863ms up: #2 got "GET /d HTTP/1.1"
883ms up: #2 got "GET /f HTTP/1.1"
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /e -> upstream 127.0.0.1:19001, local 127.0.0.1:62663, reused true
mini: GET /d -> upstream 127.0.0.1:19001, local 127.0.0.1:62667, reused false
mini: GET /f -> upstream 127.0.0.1:19001, local 127.0.0.1:62667, reused true
accepts: 2
== upstream
820ms up: accept #1 from port 62674
821ms up: #1 got "GET /a HTTP/1.1"
822ms up: #1 got "GET /b HTTP/1.1"
832ms up: #1 got "GET /c HTTP/1.1"
843ms up: #1 got "GET /d HTTP/1.1"
843ms up: accept #2 from port 62678
843ms up: #2 got "GET /e HTTP/1.1"
853ms up: #1 got "GET /f HTTP/1.1"
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /d -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
pingora: GET /e -> upstream 127.0.0.1:19001, local 127.0.0.1:62678, reused false
pingora: GET /f -> upstream 127.0.0.1:19001, local 127.0.0.1:62674, reused true
accepts: 2
两边都是 2 条上游连接:/c 换了下游连接,照样复用 /a、/b 那条,池是跨下游连接共享的;/d、/e 并发,第二个只能新建。/f 那一行就是上一节说的顺序:mini 拿最后放回的 #2,pingora 拿最早放回的 #1。
并发 keep-alive。examples/seq.rs 先开 8 条下游连接、每条顺序发 50 个请求,再开 64 条、每条 20 个,两轮之间不重启。两边各跑 5 次,全部是 200(400 个加 1280 个),上游接受的连接数:
| 第 1 次 | 第 2 次 | 第 3 次 | 第 4 次 | 第 5 次 | |
|---|---|---|---|---|---|
| mini | 64 | 64 | 64 | 64 | 64 |
| pingora | 63 | 64 | 64 | 64 | 64 |
第一轮 8 条下游连接用 8 条上游连接;第二轮 64 条下游连接同时在飞,池里那 8 条不够,再新建 56 条。pingora 有一次少一条,没有单独查原因,最可能是那一次有一个请求取连接时,另一个请求刚好把连接放回了池里。两边的池上限都是 128,这个负载碰不到上限,连接数由同时在飞的请求数决定。每轮耗时两边都在 79 到 164 ms 之间,跑的时候 uptime 的负载在 44 左右,不拿来比快慢。
上游关掉空闲连接。rawup 换成空闲 200 ms 就关连接的模式。客户端发 /a、/b(各一个 curl 进程),等 500 ms 再发 /c。pingora 这边:
conn #1
-> 200
conn #1
-> 200
conn #2
-> 200
== upstream
818ms up: accept #1 from port 62687
818ms up: #1 got "GET /a HTTP/1.1"
828ms up: #1 got "GET /b HTTP/1.1"
1031ms up: #1 idle 200ms, close
1349ms up: accept #2 from port 62690
1349ms up: #2 got "GET /c HTTP/1.1"
1553ms up: #2 idle 200ms, close
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62687, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62687, reused true
pingora: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62690, reused false
accepts: 2
1031 ms 上游关掉 #1,idle_poll 读到 EOF 把它清出池子;/c 到的时候池是空的,新建 #2,三个请求都是 200。
动脑筋:同样的上游、同样三个 curl,换成 mini(池里没有 idle_poll),/c 拿到的状态码是多少?上游一共接受几条连接?
提示:mini 的 take 不看连接还活不活着,也不会重试。答案在文末。
上游在请求到达时关。rawup 换成「回完第一个请求,读到第二个就关、一个字节也不回」,模拟上游 keep-alive 超时和代理发请求撞在同一时刻:代理取连接时它还活着,idle_poll 也拦不住。客户端发 /a、/b。pingora(RUST_LOG=warn):
conn #1
-> 200
conn #2
-> 200
== upstream
822ms up: accept #1 from port 62699
822ms up: #1 got "GET /a HTTP/1.1"
832ms up: #1 got "GET /b HTTP/1.1"
832ms up: #1 close without answering
832ms up: accept #2 from port 62701
832ms up: #2 got "GET /b HTTP/1.1"
== proxy
pingora: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62699, reused false
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62699, reused true
[... WARN pingora_proxy] Fail to proxy: Upstream ConnectionClosed context: Peer: addr: 127.0.0.1:19001, scheme: HTTP cause: context: while reading response headers, bytes already read: 0, tries: 1, retry: true, GET /b, Host: 127.0.0.1:19010
pingora: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62701, reused false
accepts: 2
mini:
conn #1
-> 200
-> 502
== upstream
818ms up: accept #1 from port 62692
818ms up: #1 got "GET /a HTTP/1.1"
828ms up: #1 got "GET /b HTTP/1.1"
828ms up: #1 close without answering
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62692, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62692, reused true
mini: GET /b via 127.0.0.1:19001: upstream closed before the response
accepts: 1
pingora 把「复用来的连接上、还没读到响应就被关」标成可重试,GET 是幂等的,于是新建一条连接再发一次,客户端拿到 200,上游收到两次 GET /b;换成 POST 就不重试,回 502(错误、重试与故障转移、连接池)。mini 没有重试,recv 读到 EOF,响应头还没写,回 502。要补这一条,最小的改法是在 one_request 里:reused 为真、started 为假、方法是 GET 或 HEAD、请求没有 body 时,新建一条连接再走一遍。
pingora 有而这里没有的
| pingora 有 | mini 的做法 | 讲它的页 |
|---|---|---|
缓存:request_cache_filter 打开后,同一个键的第二个请求不连上游,缓存锁只放一个请求去上游 | 每个请求都去上游 | 缓存状态机、缓存锁 |
HTTP/2:下游 h2 服务端,上游 h2 连接按 max_h2_streams 多路复用 | 只有 HTTP/1.x;curl --http2-prior-knowledge 发来的连接前言被 httparse 拒绝,mini 回 400,日志是 invalid HTTP version | HTTP/2、HTTP 连接器 |
TLS:四个后端二选一的统一接口,下游 add_tls,上游 HttpPeer::new(addr, true, sni) | 两侧都是明文 TCP | TLS |
Server:配置文件、守护进程、信号;SIGQUIT 平滑升级(Linux 上把监听 fd 交给新进程),SIGTERM 平滑停机 | 没有 Server,进程一停所有连接一起断 | 一个 Server 的一生、平滑升级与停机 |
重试:错误带 retry 标记,复用连接上的失败对幂等请求重试,max_retries 是总尝试次数;重试时再调一次 upstream_peer,可以换一个 peer | 直接回 502 | 错误、重试与故障转移 |
池里死连接的探测:每条空闲连接一个 idle_poll,对端关闭或空闲超时就删掉;取出时再 test_reusable_stream | 不探测,也没有空闲超时,上面那个 502 就是这么来的 | 连接池 |
头的结构:http::HeaderMap 加 CaseMap,保留拼写,查找是哈希;RequestHeader 的方法、URI 是 http 的类型 | 同样保留拼写,但是一个线性查找的 Vec,方法和目标是 String | 请求头与错误、HTTP/1 服务端 |
42 个钩子、CTX、17 个 async;body 也有钩子(request_body_filter、response_body_filter) | 3 个同步钩子,碰不到 body | ProxyHttp、process_request |
上游一侧的超时:连接、TLS 握手、读、写、空闲各自可配(PeerOptions) | 上游没有任何超时,上游不回响应,这个下游连接就一直挂着 | Peer、L4 连接 |
| 请求校验:请求目标、Host、头的大小;0.9.0 收紧的部分 | 只有 httparse 自己的检查,64 个头、64 KiB 的头 | HTTP/1 服务端 |
| 下游模块和压缩、上游模块 | 没有 | HTTP 模块与压缩、上游模块与自定义转发 |
| 负载均衡、健康检查、限流 | upstream_peer 返回一个固定地址 | LoadBalancer、健康检查、限流 |
Upgrade / WebSocket | 删掉 Upgrade 头,升级不会发生 | 双向转发 |
也有反过来的:mini 的 body 透传保留了 chunk 扩展和 trailer,pingora 不保留;mini 每次读请求头都有超时,pingora 从第二个请求起没有。前一条是 pingora 为了让 body 能被改而付的代价,后一条是源码里自己标着 FIXME 的。
做到这里能看清 pingora 请求主线的骨架:读头、三个决定(要不要短路、去哪、响应怎么改)、取连接、两半同时转发、两边各自决定能不能复用。mini 都写了,同一组请求下上游连接数也一样。多出来的那些——缓存、h2、TLS、平滑升级、重试和死连接探测、body 钩子、上游超时——都是为了「上游会坏、协议会变、进程要换」,mini 一样都没有,两个真实的故障场景就各回了一个 502。
动脑筋答案
conn #1
-> 200
conn #1
-> 200
-> 502
== upstream
817ms up: accept #1 from port 62683
817ms up: #1 got "GET /a HTTP/1.1"
827ms up: #1 got "GET /b HTTP/1.1"
1031ms up: #1 idle 200ms, close
== proxy
mini: GET /a -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused false
mini: GET /b -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused true
mini: GET /c -> upstream 127.0.0.1:19001, local 127.0.0.1:62683, reused true
mini: GET /c via 127.0.0.1:19001: upstream closed before the response
accepts: 1
502,上游只接受了 1 条连接。
- 1031 ms 上游关掉 #1,mini 这边没有任何任务在读这条连接,它还在
Vec里。 /c到达,take弹出它(reused true,本地端口还是 62683)。上游关闭时发来的 FIN 早就在这条连接的接收缓冲里了。send把请求写出去,写进内核的发送缓冲照样成功;recv读响应头,先读到的就是那个 FIN,也就是 EOF,报「upstream closed before the response」。- 这时响应头还没写给下游,
started为假,回 502;这条死连接被丢掉。上游的日志里没有/c:它在 #1 关闭之后才到,没有被任何连接读到。 - 测试
stale_pooled_connection_gives_502把这件事固定下来:上游每条连接空闲 100 ms 就关,三个请求间隔 300 ms,状态码是["200", "502", "200"],上游接受 2 条连接。第三个请求成功,是因为第二个请求把死连接丢掉了,池又空了。
pingora 在同一个场景里没有这个 502,靠的是 idle_poll:每条空闲连接都有一个任务在读它,EOF 一到就从池里删掉。
小结
- 一个能跑的 HTTP/1.1 反向代理,骨架是:读请求头(带超时)→ 三个决定(短路、选上游、改响应头)→ 取池里的连接或新建 → 请求体和响应体用
try_join!同时转发 → 两边各自判断能不能复用。mini 库 491 行代码(rustfmt 之前 425 行)。 request_filter返回Option<Reply>,短路必须带一个响应;pingora 的Ok(true)由调用者保证已经写过响应。- mini 每次读请求头都套同一个超时;pingora 第一个请求 60 秒,之后无限(0.6.0 的
6f44ac7、337f878起;0.5.x 每次读请求头都有 60 秒的read_timeout)。 - body 按
Framing原样透传,chunk 扩展和 trailer 都留着;pingora 为了让 body 能被钩子和模块改,先解码再重新编码,这两样都丢了。大小写保留在 mini 里是白来的,pingora 多的是HeaderMap加CaseMap这套结构。 - 同一组 curl 和并发请求下,两边的上游连接数相同(2、64);mini 的池后进先出,pingora 的先进先出。
- 上游关掉空闲连接:pingora 的
idle_poll提前清掉,mini 拿到死连接回 502。上游在请求到达时关:pingora 重试 GET 成功,mini 回 502。 - mini 缺的是缓存、h2、TLS、平滑升级、重试、死连接探测、body 钩子和上游超时,每一样都有一页讲 pingora 怎么做。