HTTP/2
主线位置:请求主线的第二步换成 h2 时的样子。
process_new在 TLS 握手选出h2、或明文端口上读到 h2 连接前言时,不建 v1 的HttpSession,而是做 h2 握手,再为每个流建一个 v2 的HttpSession,各自走一遍和 HTTP/1 相同的process_new_http。上游方向的Http2Session是连接器的事,本页只讲到它包了什么。
flowchart TB
pn["process_new"] --> tls{"TLS?"}
tls -->|是| alpn{"ALPN 是 h2?"}
tls -->|否| h2c{"开了 h2c?"}
h2c -->|是| peek["try_peek 24 字节"]
peek -->|"是前言"| hs["server::handshake"]
peek -->|"不是"| h1["v1 HttpSession"]
h2c -->|否| h1
alpn -->|是| hs
alpn -->|否| h1
hs --> loop["accept 循环"]
loop --> chk["from_h2_conn"]
chk -->|"坏流: RST 或 400"| loop
chk -->|"好流"| task["每流一个任务"]
代码在 pingora-core/src/protocols/http/v2/:server.rs 2093 行(前 958 行是实现),client.rs 909 行,mod.rs 770 行(共用的 write_body 和测试)。HTTP/2 的帧、HPACK、流控全在 h2 crate 里,实验锁文件里是 h2 0.4.20;pingora 这一层负责的是:什么时候进 h2、握手参数、把 h2::server::Connection 拆成一个个会话、校验请求、写响应时去掉 HTTP/1 专用的头。
实验在 exp-pingora/proto-main(M2 Max、macOS 27、rustc 1.99.0,debug,tokio 1.53.2)。两个 h2c 端口都转到 18301 的原始 TCP 上游:18303 用默认设置,18304 在 HttpProxy 上设了 h2_options = Some(H2Options::new())。
什么时候走 h2
入口在 process_new:
let mut h2c = self.server_options().as_ref().map_or(false, |o| o.h2c);
// ...
// h2c is for cleartext connections; on TLS, ALPN handles protocol negotiation.
// Otherwise, h2c stays true on TLS streams, forcing HTTP/1.1 clients into HTTP/2
if stream.get_ssl_digest().is_some() {
h2c = false;
}
// try to read h2 preface
else if h2c && !custom {
let mut buf = [0u8; H2_PREFACE.len()];
let peeked = stream
.try_peek(&mut buf)
.await
.map_err(|e| {
// this error is normal when h1 reuse and close the connection
debug!("Read error while peeking h2c preface {e}");
e
})
.ok()?;
// not all streams support peeking
if peeked {
// turn off h2c (use h1) if h2 preface doesn't exist
h2c = buf == H2_PREFACE;
}
}
if h2c || matches!(stream.selected_alpn_proto(), Some(ALPN::H2)) {
两条路:
- TLS + ALPN。 监听器的
TlsSettings调了enable_h2(ALPN 设成H2H1),客户端提议h2,握手后selected_alpn_proto()是H2。不调enable_h2就不会有 h2,见 TLS。 - 明文 h2c。
HttpServerOptions的h2c设成true。连接一进来先try_peek24 字节(H2_PREFACE,PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n),完全相同才走 h2,否则把读到的字节退回去走 HTTP/1。这只支持「先验知识」(prior knowledge),不支持 HTTP/1.1 的Upgrade: h2c。
同一个 h2c 端口上:
$ curl --http2-prior-knowledge -i http://127.0.0.1:18303/h2c
HTTP/2 200
...
content-length: 92
...
GET /h2c HTTP/1.1\r\nuser-agent: curl/8.7.1\r\naccept: */*\r\nHost: 127.0.0.1:18303\r\n\r\n
$ curl -i http://127.0.0.1:18303/h1
HTTP/1.1 200 OK
...
下游是 h2,到上游变成了 HTTP/1.1,:authority 被 proxy_h1 写成了 Host(放在最后,因为是后插进去的)。
try_peek 是 read_exact,要凑满 24 字节才返回,中间没有超时。比 24 字节短的明文请求,比如 GET / HTTP/1.0\r\n\r\n(18 字节),发到 h2c 端口上:
-- 0 bytes; idle timeout (connection still open)
客户端等了 3 秒,一个字节也没收到;关掉连接时代理打出 Read error while peeking h2c preface early eof。同一个请求发到非 h2c 端口会得到 200。hyper-util 的 auto::Builder 也读最多 24 字节、也没有超时,但它逐字节比对,第一个字节对不上就转去 HTTP/1,这个 18 字节的请求在那里不会卡住,见 auto。
process_new 里那段「h2c is for cleartext connections」的判断是 0f7c505(「fix: skip h2c preface detection on TLS streams」,PR #826,首次进入 0.9.0)加的。之前的代码只看 h2c && !custom 就去 peek;TLS 流不支持 peek(Peek 的默认实现返回 false),peeked 为假,h2c 就保持 true。提交说明:「When h2c is enabled and both TCP and TLS listeners share a service, preface detection runs on TLS streams too. On TLS, try_peek returns peeked=false, leaving h2c=true unconditionally. This forces all TLS connections into the HTTP/2 branch, breaking HTTP/1.1 clients.」修复是先看 get_ssl_digest(),TLS 上把 h2c 关掉,交给后面的 ALPN 判断。
握手参数:0.9.0 的有界默认值
H2Options 就是 h2::server::Builder。handshake 在调用方没给选项时用 default_h2_options:
// 64 KiB decoded header-list limit.
const DEFAULT_MAX_HEADER_LIST_SIZE: u32 = 64 * 1024;
const DEFAULT_MAX_CONCURRENT_STREAMS: u32 = 100;
// ...
pub fn default_h2_options() -> H2Options {
let mut options = H2Options::default();
options.max_header_list_size(DEFAULT_MAX_HEADER_LIST_SIZE);
options.max_concurrent_streams(DEFAULT_MAX_CONCURRENT_STREAMS);
options
}
pub async fn handshake(io: Stream, options: Option<H2Options>) -> Result<H2Connection<Stream>> {
let options = options.unwrap_or_else(default_h2_options);
// ...
}
这是 90890bb(「Bound default HTTP/2 server limits to mitigate memory exhaustion」,首次进入 0.9.0),CHANGELOG 把「Default HTTP/2 server limits are bounded rather than unbounded」列为可能破坏兼容的变更。同样的改动以 d193c8d 先发进了 0.8.1(2026-06-04),0.8.1 的 CHANGELOG 把它列在 Security 下;还停在 0.8.0 的项目没有这两个上限(本站网关就是,见在本站中)。之前是 options.unwrap_or_default(),即 h2 的默认值:不通告 MAX_CONCURRENT_STREAMS(按协议就是不限),本地对解码后头列表的上限是 h2 0.4.20 framed_read.rs 里的 16 << 20(16 MiB)。
选项从 HttpServerApp::h2_options 来,代理服务是 HttpProxy 上的 pub h2_options 字段。陷阱在 Option 上:给了 Some(...),unwrap_or_else 就不会调 default_h2_options。从 H2Options::new() 开始改一个窗口大小,100 和 64 KiB 两个上限就一起没了;default_h2_options 的文档注释写的正是「Use this as the starting point when customizing options」。一个 Python 脚本发 h2 前言和一个空 SETTINGS,打印服务端的第一个 SETTINGS 帧,两个端口各跑一次,结果在动脑筋答案里。
h2_options 为 None)和 18304(h2_options = Some(H2Options::new()))发出的第一个 SETTINGS 帧里各有哪些参数?两边都不改窗口:h2 的默认流窗口和连接窗口都是协议默认的 65535,pingora 不通告也不发 WINDOW_UPDATE 放大它。hyper 的服务端默认是 200 个并发流、1 MiB 的窗口、16 KiB 的头列表上限,见 HTTP/2 服务端。
一个流,一个 HttpSession
握手之后,accept_downstream_sessions 循环调 from_h2_conn,conn.accept() 驱动整个 h2 连接的读写。select! 里还有两个分支:停机信号到了就 conn.graceful_shutdown()(先发一个 last_stream_id 为最大值的 GOAWAY),之后不再响应信号、继续接流直到连接排空;设了 h2_idle_timeout 且没有在途的流时,到时直接丢掉连接。每接到一个流,process_new 传进来的闭包 spawn 一个任务,ServerSession::new_http2(h2_stream) 进 process_new_http。同一条连接上的流共享一个 Arc<Digest>,它在握手前从 L4 / TLS 流上收集好。
from_h2_conn_with_malformed_budget 在把流交出去之前做三项检查,每项失败都只影响这一个流:
:path里有 CR、LF 这类分隔字节:回RST_STREAM(PROTOCOL_ERROR)。h2 允许这些字节进:path,转给 HTTP/1 上游就能拆出第二个请求行,所以这里兜底。Content-Length冲突或不可解析:RST_STREAM(PROTOCOL_ERROR),规则和 HTTP/1 相同,见消息体。invalid_request_authority:重复Host、userinfo、Host与:authority不一致、两者都没有、:path是绝对形式:回一个 400 并结束流。注释说这里故意不用 RST,因为「An immediate PROTOCOL_ERROR reset would replace this observable 400 in h2」。
三项共用一个每连接的计数器,累计到 MAX_MALFORMED_STREAMS_PER_CONN(32)就报连接级错误,整条连接关掉;合法的流不会清零它。这三项本页没有构造请求验证(curl 发 h2 时会把 Host 头改写成 :authority,造不出不一致),server.rs 的测试覆盖了它们。
v2 的 HttpSession 比 v1 小得多:请求头、RecvStream(请求体)、SendResponse、写响应体用的 SendStream、ended 标记、字节计数、重试缓冲、共享的 digest 和两个超时。没有 keep-alive、没有 body 状态机,这些都是 h2 的流在管。读请求体的 read_body_bytes 每拿到一块数据就 release_capacity(data.len()),把流控窗口还给对端:代理读得慢,客户端就会被窗口挡住,这就是 h2 下的背压。
写响应
if header.status.is_informational() {
// ignore informational response 1xx header because send_response() can only be called once
// https://github.com/hyperium/h2/issues/167
debug!("ignoring informational headers");
return Ok(());
}
// ...
/* update headers */
header.insert_header(header::DATE, get_cached_date())?;
// remove other h1 hop headers that cannot be present in H2
// https://httpwg.org/specs/rfc7540.html#n-connection-specific-header-fields
header.remove_header(&header::TRANSFER_ENCODING);
header.remove_header(&header::CONNECTION);
header.remove_header(&header::UPGRADE);
header.remove_header(&HeaderName::from_static("keep-alive"));
header.remove_header(&HeaderName::from_static("proxy-connection"));
h2 的 SendResponse::send_response 每个流只能调一次,所以 1xx 全部被丢掉,包括 100 Continue:h2 下游发 Expect: 100-continue,上游回的 100 到不了客户端。HTTP/1 上游响应里的 Transfer-Encoding、Connection 等在这里删掉,HTTP/2 禁止这些头。
写 body 用 mod.rs 里共用的 write_body,服务端写响应、客户端写请求体都用它。reserve_and_send 一次把剩余字节全部 reserve_capacity,poll_capacity 等到多少就切多少发出去,循环到发完;空数据不能等容量,直接 send_data。写超时套在每一次 reserve_and_send 外面,不是整段 body 的总时长。没有长度的响应体也不需要 chunked,finish 发一个空的 DATA 帧带上 END_STREAM。pingora 一次预留整块数据的长度;hyper 先拿到数据块、只预留 1 字节,它的 #4149 说按整块预留在压测里让每个请求多花约 10% 的 CPU,见 HTTP/2 服务端。
上游方向:Http2Session
连接上游的 h2 客户端会话是 client.rs 里的 Http2Session:一个 h2::client::SendRequest,加一个 ConnectionRef(同一条上游 h2 连接的共享句柄,Drop 时 release_stream 归还一个流的名额)。连接本身由 drive_connection 在单独的任务里 poll;设了 ping 间隔时再起一个任务定时发 PING,PING_TIMEOUT(5 秒)内没有 PONG 就关连接。哪些请求共用一条上游 h2 连接、流用完怎么还,见 HTTP 连接器。
动脑筋答案
===== settings 18303 (pingora defaults)
SETTINGS MAX_CONCURRENT_STREAMS=100, MAX_HEADER_LIST_SIZE=65536
SETTINGS ACK
===== settings 18304 (H2Options::new())
SETTINGS (empty)
SETTINGS ACK
- 18303:
h2_options是None,handshake用default_h2_options,SETTINGS 里正好是那两个上限。 - 18304:
Some(H2Options::new())绕过了default_h2_options,h2 的Builder默认什么都不通告,SETTINGS 帧是空的。客户端于是认为并发流不限;服务端本地对头列表的上限回到 h2 的 16 MiB。 - 两边的第二帧都是对客户端那个空 SETTINGS 的 ACK,之后没有
WINDOW_UPDATE:两边的窗口都停在 65535。
小结
- 进 h2 有两条路:TLS 上 ALPN 选中
h2(要enable_h2),明文端口上h2c打开且读到完整的 24 字节前言。0.9.0 起 TLS 流不做前言检测(PR #826)。 - 前言检测是
read_exact,不足 24 字节的明文请求会一直等,没有超时。 - 0.9.0 默认通告 100 个并发流、64 KiB 头列表;给
h2_options传Some(H2Options::new())会把这两个上限一起丢掉,要从default_h2_options()改起。 - 每个流一个 v2
HttpSession,各自 spawn 一个任务走process_new_http;:path分隔字节和冲突的Content-Length回 RST,authority 问题回 400,每连接累计 32 个就关连接。 - 写响应时 1xx 全部丢弃,HTTP/1 的连接级头被删;body 按流控容量分段发,结束用空 DATA 帧。