Hyper 与 Tower:工业级 HTTP 栈
第23章 生产环境调优:超时矩阵、背压、优雅关闭
23.0 本章导读
前面二十二章,我们一层一层把 hyper 与 tower 拆到了底——Service trait(第 2 章)、Layer/Builder(第 3 章)、poll_ready 背压协议(第 4 章)、http1/h2 wire(第 11、15 章)、流控与 PING(第 16、17 章)、upgrade(第 18 章)、pool(第 20 章)。每一章都有专属的调优旋钮,但它们从来不是孤立起作用的——把它们拼到一个真实的、跑在 K8s 上、每秒扛几万 QPS 的服务里,会出现一种奇怪的现象:每个旋钮配置都"看起来合理",但整体行为完全不合理。
这就是这一章要解决的问题——把前面章节的离散旋钮组装成一个一致的生产配置,并回答三个工程师在凌晨 3 点被 pager 叫起来后会问的问题:
- 我的超时是不是漏了一层?——TCP、HTTP、请求、Body 四层时钟如何叠加,一张表一眼看全。
- 我的背压是不是真的传导到了客户端?——用户业务慢了,信号有没有传到 h2 window、TCP buffer、最终让客户端知道。
- 我的滚动升级为什么总报 500?——SIGTERM 到 pod 死亡的 30 秒里,hyper 在做什么,K8s 的 terminationGracePeriodSeconds 是怎么配才正确。
我们会用 hyper 1.9.0、hyper-util 0.1.x、tower 0.5.x 的真实源码作锚,讲到 hyper-1.9.0/src/server/conn/http1.rs:344(header_read_timeout)、hyper-1.9.0/src/server/conn/http2.rs:226-237(keep_alive_interval/timeout)、tower-0.5.3/tower/src/timeout/mod.rs:64-69(请求级 sleep)、tower-0.5.3/tower/src/load_shed/mod.rs:43-64(LoadShed 的 poll_ready 骗术)、tower-0.5.3/tower/src/limit/concurrency/service.rs:27-32(Semaphore)、hyper-util 仓库 src/server/conn/auto/mod.rs:467(auto 的 graceful_shutdown)这几处关键实现。
读完这一章,你手里会多一份可以直接复制到生产代码里的 Builder 配置,和一份出事时知道怎么问诊的排障心智图。下一章我们会收尾到本专栏最后一个话题——设计哲学:为什么 hyper 选择做成一堆可组合的小库,而不是一个"开箱即用"的大框架。
23.1 完整的超时矩阵
生产环境最常见的事故形态,是"某一层没有超时"——一个慢客户端让一条连接停在只发了半截 header 的状态,一个慢业务把一个请求挂在 handler 里,一个慢网络把一个 body 读到一半阻塞。每一种都需要特定的一层去盯它。hyper 的设计是"每层只管自己的事",这带来了极大的灵活性,也带来了一个陷阱——没有任何一个"总开关"能关掉所有慢行为。
这一节把四层时钟画成一张表。
23.1.1 四层时钟的物理解释
flowchart TD
A[客户端字节到达网卡] --> B[TCP 层]
B --> |SO_KEEPALIVE 探活| B
B --> |TCP_USER_TIMEOUT 未 ACK| B
B --> C[HTTP 连接层]
C --> |header_read_timeout 收齐 header| C
C --> |keep_alive_interval/timeout PING| C
C --> D[请求/Service 层]
D --> |tower::timeout service.call 总耗时| D
D --> |concurrency_limit Semaphore| D
D --> |rate_limit 固定窗口计数| D
D --> E[Body 流层]
E --> |Limited 最大字节数| E
E --> |业务代码 tokio::time::timeout poll_frame| E
style B fill:#fff4e1
style C fill:#e1f0ff
style D fill:#e8f5e9
style E fill:#fce4ec
四层的独立性是关键——任何一层的 kill switch 触发都会让请求结束,但它们互相不感知。举个例子:tower::timeout 的 60 秒计时到了,中间件直接返回 Err(Elapsed),但 HTTP/2 的 keep-alive PING 仍然每 10 秒照发一次;TCP 层的 keepalive 仍然记录"连接健康"。一个请求被砍了,不等于连接被关了。这个区分是第 17 章反复强调过的。
23.1.2 四层详表
| 层级 | 旋钮 | 配置点 | 默认值 | 触发时的行为 |
|---|---|---|---|---|
| TCP | SO_KEEPALIVE |
socket2::SockRef::set_tcp_keepalive |
off(Linux 默认 2 小时) | 发 TCP keepalive 探针,9 次无响应 RST |
| TCP | TCP_USER_TIMEOUT |
socket2::SockRef::set_tcp_user_timeout |
off(内核 tcp_retries2 ≈ 15 分钟) | 写入数据长时间未 ACK 时 RST |
| HTTP/1 | header_read_timeout |
http1::Builder::header_read_timeout |
30 秒(hyper-1.9.0/src/server/conn/http1.rs:242)——但要装了 Timer 才算数(见下) |
关连接 + 日志 |
| HTTP/2 | keep_alive_interval |
http2::Builder::keep_alive_interval |
None(默认关) | 周期发 PING |
| HTTP/2 | keep_alive_timeout |
http2::Builder::keep_alive_timeout |
20 秒(hyper-1.9.0/src/server/conn/http2.rs:237) |
PING 未 ACK 时关连接 |
| HTTP/2 | max_concurrent_streams |
http2::Builder::max_concurrent_streams |
200 | REFUSED_STREAM |
| HTTP/2 | max_pending_accept_reset_streams |
同名 | 20(hyper 侧为 None,沿用 h2 的默认,见 hyper-1.9.0/src/server/conn/http2.rs:123-128 注释) |
GOAWAY + ENHANCE_YOUR_CALM |
| 请求 | tower::timeout::Timeout |
ServiceBuilder::timeout(Duration) |
无默认 | call future 超时 → Elapsed |
| 请求 | ConcurrencyLimit |
ServiceBuilder::concurrency_limit(N) |
无默认 | poll_ready 等 Semaphore permit |
| 请求 | RateLimit |
ServiceBuilder::rate_limit(n, per) |
无默认 | 本周期配额用完后 poll_ready 等到周期结束(按周期整批补满,不是令牌桶) |
| 请求 | LoadShed |
ServiceBuilder::load_shed() |
无默认 | poll_ready 假装 ready,call 里 Overloaded |
| Body | http_body_util::Limited |
Limited::new(body, max) |
无默认 | 超字节 → LengthLimitError |
| Body | 业务 tokio::time::timeout |
手写 | 无默认 | 自定义错误 |
这张表里有两处特别容易漏看的默认值:
第一处是 HTTP/2 的 keep_alive_interval 默认是 None——不发 PING。源码在 hyper-1.9.0/src/server/conn/http2.rs:226:
// hyper-1.9.0/src/server/conn/http2.rs:226-229
pub fn keep_alive_interval(&mut self, interval: impl Into<Option<Duration>>) -> &mut Self {
self.h2_builder.keep_alive_interval = interval.into();
self
}
对比 HTTP/1——HTTP/1 有 30 秒的 header_read_timeout 默认值保护,但这条保护要装了 Timer 才存在:没 Timer 时它被静默禁用,至多留一条 warn(机制见下面 §23.7.2;axum::serve 替你装了,见 axum-full/axum/src/serve/mod.rs:607,裸用 hyper 得自己调 .timer(TokioTimer::new()))。HTTP/2 的这个"默认不发 PING"意味着:假如一个 HTTP/2 客户端连上,发了 SETTINGS 之后什么都不做,hyper 服务端会静静地留着这条连接直到天荒地老。第 14 章讲过的那个 NAT 撕连接事故,在 HTTP/2 上会比 HTTP/1 更严重——HTTP/1 装了 Timer 后,空闲的 keep-alive 连接在等下一个请求头时同样处在 header_read_timeout 的计时里,30 秒等不到完整 header 就会被关掉;HTTP/2 服务端没有对应的 idle 超时,根本没这个机会。生产 HTTP/2 服务强烈建议显式配 keep_alive_interval(10s) + keep_alive_timeout(3s)。
第二处是 max_concurrent_streams 默认 200。源码 hyper-1.9.0/src/server/conn/http2.rs:215:
// hyper-1.9.0/src/server/conn/http2.rs:206-218
/// Sets the [`SETTINGS_MAX_CONCURRENT_STREAMS`][spec] option for HTTP2
/// connections.
///
/// Default is 200, but not part of the stability of hyper. It could change
/// in a future release. You are encouraged to set your own limit.
///
/// Passing `None` will remove any limit.
///
/// [spec]: https://httpwg.org/specs/rfc9113.html#SETTINGS_MAX_CONCURRENT_STREAMS
pub fn max_concurrent_streams(&mut self, max: impl Into<Option<u32>>) -> &mut Self {
self.h2_builder.max_concurrent_streams = max.into();
self
}
注释里用了 "you are encouraged to set your own limit" ——意思是"不要依赖 200 这个默认,自己显式配一个"。200 是一个相当激进的值——意味着一条 HTTP/2 连接可以同时挂 200 个并发请求。如果你的服务平均单请求耗 100ms,200 个流并发 = 每连接 2000 QPS 的突发上限。内存倒不是按 200 条 stream window 线性累加的:未读的请求 body 先扣连接级窗口,被一个连接窗口封顶(第 16 章 §16.5,默认 1 MB);真正随 stream 数上涨的是 200 个同时在跑的 handler 和它们各自持有的资源。对于常规 API 服务,显式降到 64 或 32 更合理。
用 tonic 的服务要格外留意:tonic 的 Server 并不沿用 hyper 这两个默认值,它自己的 max_concurrent_streams 和 http2_max_local_error_reset_streams 默认都是 None,并原样传给 hyper 的 Builder,覆盖掉 hyper 的 200 和 1024(tonic-v0.14.5/tonic/src/transport/server/mod.rs:127、136、778、783)——也就是并发流不设上限、本地错误 reset 也不计数封顶。要这两道限制,得在 Server::builder() 上显式设置(第 22 章 §22.4.1)。
23.1.3 TCP 层两个要配的 option
hyper 本身不碰 TCP socket option——它只要一个实现了 Read + Write 的 IO。这意味着 SO_KEEPALIVE、TCP_NODELAY、TCP_USER_TIMEOUT 得自己在 accept 之后配。生产里最常见的组合:
// socket2 0.6,需开 `all` feature(with_retries、set_tcp_user_timeout 都在它后面);
// 0.5 里 set_tcp_nodelay 叫 set_nodelay
use socket2::{SockRef, TcpKeepalive};
use std::time::Duration;
let (stream, peer) = listener.accept().await?;
let sock_ref = SockRef::from(&stream);
sock_ref.set_tcp_nodelay(true)?;
sock_ref.set_tcp_keepalive(
&TcpKeepalive::new()
.with_time(Duration::from_secs(30))
.with_interval(Duration::from_secs(10))
.with_retries(3),
)?;
// Linux only: 关心"写出去的数据未 ACK 超过多久就 RST"
#[cfg(target_os = "linux")]
sock_ref.set_tcp_user_timeout(Some(Duration::from_secs(30)))?;
TCP_USER_TIMEOUT 是 Linux 特有的,它覆盖 tcp_retries2 内核参数——默认内核要重传 15 次大约 15 分钟才 RST,对于前台 HTTP 服务这个太长了。30 秒的 user timeout 让"网络真的断了但 TCP 没感知到"场景下能快速释放资源。
这两个 socket option 和 hyper 的 header_read_timeout/keep_alive_interval 完全正交——前者盯"TCP 层存活",后者盯"HTTP 层进度"。一个典型生产故障是只配了 HTTP 层超时没配 TCP 层——结果"客户端设备电源拔了"这种硬断场景下,hyper 看到的是"连接健康,只是没数据来":请求还在处理、响应还在往外写的时候,不会触发 header_read_timeout(header 已收完了),也不会触发 keep_alive_timeout(HTTP/1 没 keep-alive PING)——连接在 ESTABLISHED 状态静静躺着,直到响应写完、连接回到空闲、再被 header_read_timeout 关掉(前提是装了 Timer);没装 Timer 就一直躺到 TCP 层自己发现。
23.1.4 和 Tokio Time Driver 的衔接
所有 hyper 的超时——header_read_timeout、keep_alive_timeout、tower::timeout::Timeout——最终都落到 tokio::time::sleep,也就是《Tokio 源码深度解析》专栏第 11 章(Time Driver 与分层定时器轮)讲过的那个 6 层 × 64 slot 分层时间轮。这带来三个实践上需要注意的点:
一是 hyper 要求你显式提供 Timer。看 hyper-1.9.0/src/server/conn/http1.rs:403 与 hyper-1.9.0/src/server/conn/http2.rs:272:
// http1.rs:403-409
pub fn timer<M>(&mut self, timer: M) -> &mut Self
where
M: Timer + Send + Sync + 'static,
{
self.timer = Time::Timer(Arc::new(timer));
self
}
header_read_timeout 的文档(hyper-1.9.0/src/server/conn/http1.rs:338-339)写着 "Panics if header_read_timeout is configured" without a Timer——显式配了超时却忘配 Timer 会在 serve_connection 时 panic。判定逻辑在 hyper-1.9.0/src/common/time.rs:70-85 的 Time::check:Dur::Configured(Some(_)) 遇到 Time::Empty 直接 panic!;而 Dur::Default(Some(_))(也就是没动过 header_read_timeout、吃 30 秒默认值)遇到 Time::Empty 只走一条 warn! —— timeout `header_read_timeout` has default, but no timer set —— 然后静默禁用。这条 warn! 是 hyper 自己的宏,只有开了 tracing feature 并以 --cfg hyper_unstable_tracing 编译才真的输出(hyper-1.9.0/src/trace.rs:5-11、109-116),默认编译下它是空操作,连这条日志都没有——这正是 §23.7.2 那起事故的机制。hyper_util::rt::TokioTimer::new() 就是那个 Timer。注意 hyper_util::server::conn::auto::Builder 也不会替你注入 Timer——它的 Builder::new 只是原样构造 http1::Builder::new() 和 http2::Builder::new(executor)(hyper-util 仓库 src/server/conn/auto/mod.rs:93-104),Timer 仍要通过 builder.http1().timer(..) / builder.http2().timer(..)(auto/mod.rs:875、auto/mod.rs:1083)显式配。
二是"1 万个连接每个定时 30 秒"对 Time Driver 不是压力。分层时间轮的插入是 O(1),取下一个到期也是 O(1)——即使几万个定时器同时存在,调度成本和几十个没差别。所以可以大胆配超时,不用担心 timer 开销。真正的开销是 sleep future 本身占的那几十字节内存 × 连接数。
三是 Timer 实现要和 Tokio runtime 匹配。TokioTimer 内部调用的是 tokio::time::sleep_until——它必须在有 Tokio runtime 的 context 里调用。如果你把 hyper server 放到一个自定义 runtime(比如 smol、async-std)里,就不能用 TokioTimer——得实现一份自己的 Timer。这种场景实际上几乎遇不到,但是API 的解耦让这种可能性存在——这也是 hyper 1.x 相比 0.14 最大的设计进步之一。hyper 0.14 的 IO 直接绑在 tokio 的 AsyncRead/AsyncWrite trait 上(第 19 章 §19.8.1),1.x 用自己的 Read/Write、Executor、Timer 三组 trait 把 runtime 隔开,让 hyper 在语义上对 runtime 无感。
23.1.5 超时值之间的"包含关系"
真正难的不是每一层的具体数值——而是各层超时的包含关系。一个典型错误:
tower::timeout = 60s
header_read_timeout = 10s
h2 keep_alive_interval = 30s, timeout = 10s
看上去每个都合理。但结合起来——一个 HTTP/2 上传请求进行到第 45 秒,网络抖了一下,某次 PING 发出后 10 秒没等到 ACK——keep_alive 直接关掉整条连接(hyper-1.9.0/src/proto/h2/server.rs:348-350,abrupt_shutdown)——tower::timeout 的 60 秒还没到,请求被从底层砍断,这条连接上所有 stream 一起失败,客户端看到的是连接级错误。这不是漏洞,是"每层独立计时"的自然结果,但如果你不意识到,debug 时会被"请求耗时明明没到 60 秒"迷惑。
正确的包含关系是:
TCP 层时钟 ≥ HTTP 层时钟 ≥ 请求层时钟 ≥ Body 层时钟
(几分钟) (几十秒-几分钟) (几十秒) (上下文决定)
底层的时钟要比上层更宽——不然上层还没说完话,底层就把地板抽了。反过来也有一个例外——header_read_timeout 必须远小于请求层 timeout——因为 Slowloris 这种攻击是"我故意不发完 header",如果 header_read_timeout 配到 tower::timeout 一样大(60s),等于没防。一般 header_read_timeout 在 5-15 秒是合理的。
23.2 端到端背压的全链路
"背压"这个词在 Rust 异步生态里被滥用——写的人假装谁都懂,读的人往往只抓到其中一两层。第 4 章讲过单点的 poll_ready——那是 tower 的局部视角。这一节把视角拉到一个请求从客户端到服务端再回来的整条链路,看背压信号是怎么层层传导的。
23.2.1 背压的六层链路
flowchart LR
subgraph Server
H[业务 Handler] -->|写慢| PF[Body poll_frame]
PF -->|收得慢| SW[h2 Stream Window]
SW -->|收得慢| CW[h2 Connection Window]
CW -->|不 ACK| TR[TCP Recv Buffer]
end
subgraph Client
TS[TCP Send Buffer 塞满] -->|write 阻塞| CW2[h2 Connection Window 收不到 UPDATE]
CW2 -->|反馈| SW2[h2 Stream Window 收不到 UPDATE]
SW2 -->|反馈| CPF[Client poll_frame 返回 Pending]
CPF -->|反馈| CB[客户端业务阻塞]
end
TR --> TS
style H fill:#e1f0ff
style CB fill:#fce4ec
上面这张图核心意思是:只要每一层的 buffer 是有界的,慢消费者就能从最底端反推到最上游。反过来说——只要任意一层的 buffer 是无界的,背压就断链。tower 的 Buffer(第 6 章)是有界的,它不会断链——它内部是一个 mpsc::channel(bound),bound 个槽位占满后 poll_ready 才返回 Pending,背压照样向上传;代价是信号被推迟:bound 开得越大,下游变慢之后要先填满这么多槽位,上游才感知得到(§23.2.3)。
23.2.2 各层 buffer 大小的配置
| 层 | 旋钮 | 默认 | 生产常用 |
|---|---|---|---|
| TCP send/recv buffer | 内核 net.core.{r,w}mem_* |
几百 KB | 保持默认,极端场景按 BDP 设 |
| HTTP/2 stream window | initial_stream_window_size |
1 MB(hyper server 默认,proto/h2/server.rs:37;RFC 基线 65535) |
大文件上传 4 MB 到 8 MB |
| HTTP/2 conn window | initial_connection_window_size |
1 MB(hyper server 默认,proto/h2/server.rs:36) |
4 × stream window |
| HTTP/2 max_send_buf_size | 同名 | ~400 KB(hyper-1.9.0/src/server/conn/http2.rs:244) |
保持默认或 1 MB |
| HTTP/1 max_buf_size | http1::Builder::max_buf_size |
~400 KB(hyper-1.9.0/src/server/conn/http1.rs:368) |
256 KB 节约 |
| tower Buffer bound | ServiceBuilder::buffer(N) |
无默认 | 不要随意套!(下面讲) |
initial_stream_window_size 的源码在 hyper-1.9.0/src/server/conn/http2.rs:157:
// hyper-1.9.0/src/server/conn/http2.rs:157-163
pub fn initial_stream_window_size(&mut self, sz: impl Into<Option<u32>>) -> &mut Self {
if let Some(sz) = sz.into() {
self.h2_builder.adaptive_window = false;
self.h2_builder.initial_stream_window_size = sz;
}
self
}
注意这里只要设过 initial_stream_window_size 或 initial_connection_window_size,adaptive_window 会被关掉——不能"既设固定值又要自适应"。自适应 window 是第 16 章讲过的 BDP 估算,实时根据 RTT × 带宽调窗口——对于一般的 API 服务,请求和响应都不大,默认的 1 MB 固定窗口通常已经够用,自适应的收益有限;对大文件上传/下载、高 BDP 链路,adaptive 的好处才会显现。反过来也一样:adaptive_window(true) 会把两个 initial 窗口重置回 65535(hyper-1.9.0/src/server/conn/http2.rs:183-191),固定值和自适应只能二选一。
23.2.3 tower::Buffer 为什么是一个陷阱
tower-0.5.3/tower/src/buffer/service.rs:19-66 的核心片段:
// tower-0.5.3/tower/src/buffer/service.rs 简化
pub struct Buffer<Req, F> { /* 内部是 mpsc 到 Worker 的 channel */ }
impl Buffer<Req, F> {
pub fn new<S>(service: S, bound: usize) -> Self { /* ... */ }
pub fn pair<S>(service: S, bound: usize) -> (Self, Worker<S, Req>) { /* ... */ }
}
Buffer 的目的是把不 Clone 的 Service 变成可以被多 task 共享的——通过把请求丢到 channel、在独立 worker task 里处理。但它有两个副作用:
- 背压会被 buffered——channel 没满之前
poll_ready永远 Ready。下游 100ms 才处理一个请求,上游可以每 10ms 塞一个,bound=100 的 channel 能掩盖 1 秒的真实慢; - 取消传播只到 channel 为止——请求还排在 channel 里时,调用方 drop future,worker 取出消息看到
msg.tx.is_closed()会直接丢弃(tower-0.5.3/tower/src/buffer/worker.rs:85,第 6 章 §6.2.6);但一旦 worker 已经把请求交给 innercall,调用方再 drop 也中止不了它。
生产里看到 .buffer(1024) 这种配置要警惕——作者大概率是因为遇到 !Clone 编译错才套上去,不是有意识地做流控。如果只是为了让 !Clone 的 Service 能被多处持有,先考虑让它自己 Clone(内部 Arc 共享状态);tower::util::BoxCloneService 帮不上忙,它要求内层本身就是 Clone。确实绕不开的情形也有:RateLimit 没有实现 Clone,而 Retry 和 TowerToHyperService 都要求内层 Clone,这时只能套一层 Buffer——那就按下游真实的并发能力去定 bound,别随手写 1024。真要限流,用 ConcurrencyLimit 显式表达,更可控。
23.2.4 LoadShed 的反常识语义
相比 Buffer 的"软缓冲",LoadShed 走另一个极端——不排队,满了就拒。源码 tower-0.5.3/tower/src/load_shed/mod.rs:43-64:
// tower-0.5.3/tower/src/load_shed/mod.rs:43-64
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
// We check for readiness here, so that we can know in `call` if
// the inner service is overloaded or not.
self.is_ready = match self.inner.poll_ready(cx) {
Poll::Ready(Err(e)) => return Poll::Ready(Err(e.into())),
r => r.is_ready(),
};
// But we always report Ready, so that layers above don't wait until
// the inner service is ready (the entire point of this layer!)
Poll::Ready(Ok(()))
}
fn call(&mut self, req: Req) -> Self::Future {
if self.is_ready {
// readiness only counts once, you need to check again!
self.is_ready = false;
ResponseFuture::called(self.inner.call(req))
} else {
ResponseFuture::overloaded()
}
}
LoadShed 是对 tower 背压协议的"有意违背"——它永远 poll_ready = Ready,把"真实的 ready"存到 is_ready 字段,在 call 的时候才检查;没 ready 就直接返回 Overloaded 错误 future。
这是生产服务一个非常重要的模式——当服务已经顶到 concurrency_limit,poll_ready 会一直 Pending——hyper-util 的 TowerToHyperService 对每个请求走一遍 Oneshot(先 poll_ready 再 call,hyper-util 仓库 src/service/glue.rs),请求就挂在这一步排队,客户端看到的是"发了请求没回"这种最难调的状态。套一层 LoadShed,让被 shed 的请求立刻失败,再把 Overloaded 错误翻译成 503(§23.8 模板的 errors_to_status;不翻译的话 hyper 只会断连接)——客户端立刻知道要重试别的节点,整体 P99 反而下降。
注意 poll_ready 里的 self.is_ready = ... 有副作用:内层是 ConcurrencyLimit 时,inner.poll_ready 真的会拿走一个 permit,LoadShed 把这份 ready 记在 is_ready 里,到 call 才用掉——所以拿到 Ready 之后就该紧接着 call,别空转着占住 permit。生产栈的推荐顺序是 LoadShed -> ConcurrencyLimit -> 业务 Service,而不是反过来:LoadShed 包在外面,permit 拿不到就立刻拒;反过来包,ConcurrencyLimit 会先在外层排队等 permit,LoadShed 就失去意义了。
23.2.5 端到端示例:慢客户端拖慢上游
设想一个典型场景:
Client (慢下载) -> hyper server -> upstream grpc (快)
客户端下载慢 → hyper server 等 h2::SendStream::poll_capacity 一直 Pending(hyper-1.9.0/src/proto/h2/mod.rs:131)→ hyper 不再 poll 业务 Response Body 的 poll_frame → 业务 handler 自己保持活跃(占 1 个 task)→ 但它同时占着一个 upstream grpc 的 response stream(从 reqwest/tonic client 角度,这个流还没被 drain)→ upstream 连接的 stream/conn window 不能向上游发 UPDATE → 上游的 write 开始阻塞 → 上游业务也变慢。
这就是"一个慢客户端拖垮整条链路"的经典故事——背压端到端传导,所有共享上游的请求都遭殃。防护方案有两种:
- 给每个请求套
tower::timeout——30 秒写不出就砍了; - 给下游 pool 套
ConcurrencyLimit——不让单个慢请求独占 upstream pool 的太多配额。
这两种是互补不互斥的——timeout 限制"单个请求占用时长的上限",concurrency_limit 限制"同时占用 upstream 的总数"。
23.2.6 背压传导和 coop budget 的互动
《Tokio 源码深度解析》专栏第 19 章(性能调优与典型陷阱)讲过 Tokio 的 coop budget——每个 task 一次被调度时只有 128 点预算(《Tokio》专栏锚定的 1.40.0 把它放在 runtime 下的 coop 模块里,本专栏挂的 tokio 1.46.1 已把它迁到 tokio/src/task/coop/mod.rs:116,常量未变),对 Tokio 资源的 poll 真正取得进展才扣 1 点——返回 Pending 的那次会原样退还(RestoreOnPending,同文件 257-275 行),耗完就被强制 yield。这个机制和背压的互动容易想错:
假设一条 HTTP/2 连接同时跑 100 个 stream,每个 stream 的响应 body 都因为对端慢而发不出去。直觉上会以为"connection task 依次 poll 100 个 stream,把 128 点预算耗在慢 stream 上"——但 hyper 服务端不是这么跑的:每个新 stream 都被包成 H2Stream 交给 executor 单独 spawn(hyper-1.9.0/src/proto/h2/server.rs:305-313),100 个 stream 就是 100 个 task,各有各的预算;慢 stream 的 poll 大多以 Pending 收场,不扣预算。所以一条连接挂 100 个慢 stream,既不会耗尽 CPU,也不会通过 coop budget 挤占快 stream。
真正把快慢 stream 绑在一起的是它们共享的东西:同一个 connection task 负责收发所有帧、同一个 TCP socket、同一个连接级流控窗口。慢 stream 攒着没读的数据会占住连接窗口(§23.2.5 就是这条路径),快 stream 的数据只能跟着等。这对 P99 的影响会被放大。
这种场景下的优化是——分 connection 分流——让慢业务走独立的连接/端口,不要和快业务挤在一条 HTTP/2 多路复用的连接上;客户端侧同理,给慢业务单独建一个 client(独立的连接池)。
23.3 优雅关闭的完整流程
"SIGTERM 到进程退出的这 30 秒,hyper 在做什么?"——这是生产服务作者必须能清晰回答的问题。这一节把整条"关闭链路"拆开讲。
23.3.1 官方 API:graceful_shutdown
三种 Connection 都有 graceful_shutdown——HTTP/1、HTTP/2、以及 hyper-util 的 auto。HTTP/1 在 hyper-1.9.0/src/server/conn/http1.rs:137 和 hyper-1.9.0/src/server/conn/http1.rs:518(UpgradeableConnection):
// hyper-1.9.0/src/server/conn/http1.rs:127-139
/// Start a graceful shutdown process for this connection.
///
/// This `Connection` should continue to be polled until shutdown
/// can finish.
///
/// # Note
///
/// This should only be called while the `Connection` future is still
/// pending. If called after `Connection::poll` has resolved, this does
/// nothing.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.disable_keep_alive();
}
HTTP/1 的优雅关闭就一句话——disable_keep_alive()——意思是"当前请求处理完了之后,不再接新请求,直接关连接"。它分两种情况(hyper-1.9.0/src/proto/h1/conn.rs:876-884):连接正空闲,就地关闭,不再等下一个请求;有请求在处理,就只关掉 keep-alive,这次响应头若还没写出,会带上 Connection: close(hyper-1.9.0/src/proto/h1/conn.rs:692-695),写完就关。
HTTP/2 在 hyper-1.9.0/src/server/conn/http2.rs:78:
// hyper-1.9.0/src/server/conn/http2.rs:68-80
/// Start a graceful shutdown process for this connection.
///
/// This `Connection` should continue to be polled until shutdown
/// can finish.
///
/// # Note
///
/// This should only be called while the `Connection` future is still
/// pending. If called after `Connection::poll` has resolved, this does
/// nothing.
pub fn graceful_shutdown(mut self: Pin<&mut Self>) {
self.conn.graceful_shutdown();
}
HTTP/2 的内层是第 17 章详细讲过的 two-stage GOAWAY——先发 GOAWAY(last_stream_id=2^31-1),等一个 PING-ACK,再发真正的 GOAWAY(last_stream_id=真实处理过的最大)。这是为了给客户端正在发送的 stream 一个"你这个 stream 会不会被处理"的明确答案——避免 race。
hyper_util::server::conn::auto::Builder 则是包装二者的抽象。hyper-util 仓库 src/server/conn/auto/mod.rs:467-476:
// hyper-util 仓库 src/server/conn/auto/mod.rs:467-476 简化
pub fn graceful_shutdown(self: Pin<&mut Self>) {
match self.project().state.project() {
// ...
ConnStateProj::H1 { conn } => conn.graceful_shutdown(),
// ...
ConnStateProj::H2 { conn } => conn.graceful_shutdown(),
}
}
——简单 dispatch 到底层协议。生产代码应该拿 auto 的 Connection 句柄去调 graceful_shutdown,不用关心协议。
23.3.2 一个最小但正确的优雅关闭 server 骨架
use std::{sync::Arc, time::Duration};
use hyper::{body::Incoming, Request, Response};
use hyper_util::{rt::{TokioExecutor, TokioIo, TokioTimer}, server::conn::auto};
use tokio::{net::TcpListener, signal::unix::{signal, SignalKind}};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
// MyService:实现了 hyper::service::Service、并且 Clone 的服务
// (tower 服务先包一层 hyper_util::service::TowerToHyperService)
async fn run(listener: TcpListener, svc: MyService) -> anyhow::Result<()> {
let builder = Arc::new({
let mut b = auto::Builder::new(TokioExecutor::new());
b.http1().timer(TokioTimer::new()).header_read_timeout(Duration::from_secs(10));
b.http2().timer(TokioTimer::new())
.keep_alive_interval(Duration::from_secs(10))
.keep_alive_timeout(Duration::from_secs(3))
.max_concurrent_streams(64);
b
});
let tracker = TaskTracker::new();
let shutdown = CancellationToken::new();
// 信号监听:K8s 发的是 SIGTERM;ctrl_c 只对应 SIGINT,本地调试用
let mut sigterm = signal(SignalKind::terminate())?;
let sig = shutdown.clone();
tokio::spawn(async move {
tokio::select! {
_ = sigterm.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
tracing::info!("shutdown signal received, starting graceful shutdown");
sig.cancel();
});
loop {
tokio::select! {
// 停止 accept 的触发
_ = shutdown.cancelled() => break,
res = listener.accept() => {
let (stream, _peer) = match res {
Ok(v) => v,
Err(e) => {
// EMFILE 之类的错误:记一笔、稍等再 accept,别让循环空转或退出
tracing::warn!(error = %e, "accept failed");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
let io = TokioIo::new(stream);
let svc = svc.clone();
let builder = builder.clone();
let shutdown = shutdown.clone();
tracker.spawn(async move {
let conn = builder.serve_connection_with_upgrades(io, svc);
tokio::pin!(conn);
tokio::select! {
res = conn.as_mut() => { /* 正常结束 */ res }
_ = shutdown.cancelled() => {
// 告诉这条连接:不再接新请求,但 in-flight 请求继续处理
conn.as_mut().graceful_shutdown();
conn.await // 等 in-flight 完成
}
}
});
}
}
}
// 给所有 live connection 指定的时间完成
tracker.close(); // 必须先 close,wait() 才会在任务全部结束后完成
let deadline = tokio::time::sleep(Duration::from_secs(25));
tokio::select! {
_ = tracker.wait() => tracing::info!("all connections drained cleanly"),
_ = deadline => tracing::warn!("timeout waiting, forcing shutdown"),
}
Ok(())
}
这段代码里有四个关键细节:
shutdown.cancel()同时触发两件事——accept 循环 break 停止接新连接;每条 live connection 的 select 分支触发graceful_shutdown()。这里用CancellationToken而不用tokio::sync::Notify:notify_waiters()只唤醒当时已经登记的等待者,不留许可(tokio/src/sync/notify.rs的文档),信号若恰好落在 accept 分支执行期间、或新连接的 task 还没跑到 select 时,就会丢;token 一旦 cancel,之后再cancelled()也立即完成。信号要监听 SIGTERM——K8s 发的就是它,ctrl_c()只对应 SIGINT。- accept 循环的 select 里,只要收到 shutdown 信号就立刻 break——不再产新连接。这一步对应 K8s 的 preStop 之后要达到的效果。
- 每条连接的 graceful_shutdown 之后继续
conn.await——这是必须的。不 await,in-flight 请求拿不到 poll 机会,就永远完不成。graceful_shutdown 只是"翻个开关",真正的完成要靠 poll 推进。 - 最后
tracker.wait()有 deadline——若干连接因为慢客户端迟迟不退,25 秒后强制走——这和 K8s 的terminationGracePeriodSeconds要匹配(下一节讲)。注意tracker.close()要在wait()之前调——TaskTracker::wait只有在 tracker 已 close 且所有 task 结束后才会完成,先 wait 后 close 会永远等下去。
23.3.3 为什么不直接 drop Builder 就完事
新手会觉得——"我把 accept 停了,不 serve 新 connection 不就完了?已经 serve 的 connection 会自己跑完。"
这个理解部分正确——已 serve 的 HTTP/1 连接确实会把当前请求处理完,然后因为 keep_alive 默认为 true,它会等下一个请求到来,直到 TCP 关闭,或者(装了 Timer 时)被 header_read_timeout 关掉。HTTP/2 更糟——它本来就希望长时间复用,客户端不会主动关。没有 graceful_shutdown 的 signal,连接会挂在那里直到超时或 TCP 断,你的进程等不到退出。
graceful_shutdown 的本质,是主动告诉对端"我要关了,把手头的请求发完,别发新的"——对 HTTP/1 是正在处理的这次响应带 Connection: close、空闲连接直接关,对 HTTP/2 是两阶段 GOAWAY。这个主动性是关键。
23.3.4 Connection: close 语义的细节
HTTP/1 的 graceful_shutdown = disable_keep_alive——正在处理的这次响应头里加 Connection: close,客户端看到后就不在这条连接上发新请求,而是开新连接(或者用已有的其他连接)。如果你有流量的 nginx 前置代理,nginx 看到 Connection: close 会立刻把这条连接从上游池里拿掉,不会再往这条连接扔新请求——这是优雅滚动升级的基石。
HTTP/2 的 GOAWAY 更直接——第一阶段 GOAWAY(0x7FFFFFFF) 告诉对端"我打算关但还没关死,你现在发的 stream 可能还会被处理";PING-ACK 往返完成(证明对端已经看到这个 GOAWAY)之后,第二阶段 GOAWAY(最大已处理 stream_id) 给出权威答案——"这个 id 之前的都会处理完,之后的算你重试到别处"。客户端看到 GOAWAY 就开新连接,第二阶段 GOAWAY 之后不再发新 stream。
两种协议都有这个"对端主动重路由"的语义,这是 graceful 成立的前提——客户端必须能识别信号并去别处。如果你的客户端是自己写的、不识别 Connection: close 或 GOAWAY,优雅关闭就退化为暴力关——客户端会看到连接断。
23.3.5 drain 的长尾问题
一个真实难题——有 50 条连接处于 in-flight 状态,49 条 10 秒内完成,第 50 条连着一个"下载 1GB 文件但客户端网速只有 100KB/s"的慢客户端。drain 给了 25 秒——这第 50 条绝对不可能完成。
有三种处理策略:
- 硬 deadline 到了强制断——客户端看到下载中断、报错;K8s 按预期时序退出进程。好处是可预测,代价是明确损失一次用户体验。
- 延长 deadline——把
terminationGracePeriodSeconds配到几分钟。好处是客户端能完整地下完文件,代价是滚动升级慢(需要等所有长连接走完)。 - 单独处理长连接场景——把长连接服务和短连接服务拆成两个 deployment,各自配合适的 drain 时间。这是最干净的做法,但要额外的架构成本。
大多数 API 服务的选择是第一种——25 秒硬 deadline,长尾请求损失。这个选择的前提是客户端有重试机制——客户端看到 504/connection reset 会自动去别的节点重试;整体上用户还是收到正确结果,只是多花了几秒。这其实是在交易"可预测的运维行为 vs 个别请求的无损"——长远看,可预测性更重要。
这一点对 WebSocket 和 SSE(Server-Sent Events)这种本质上长连接的业务是不够用的。第 18 章讲过的 upgrade 链路出来的连接已经脱离 hyper,UpgradeableConnection::graceful_shutdown 对已升级的连接什么也不做(hyper-1.9.0/src/server/conn/http1.rs:518-525);WebSocket 协议本身有礼貌告知的办法——发一个状态码 1001(Going Away)的 Close 帧(RFC 6455 §7.4.1),但得由应用层自己发。SSE 还是一个普通的流式响应,graceful_shutdown 不会打断它,要由应用层自己结束 body 流。
23.4 K8s 部署的关键点
大多数生产 hyper 服务跑在 K8s 上。K8s 的滚动升级机制和 hyper 的 graceful_shutdown 要精确咬合——任何一环错位都会导致滚动升级报 5xx。
23.4.1 K8s 的关闭时序
sequenceDiagram
participant kubelet
participant ep as endpoints controller
participant pod as Pod (hyper server)
participant client
kubelet->>pod: 1. preStop hook 执行
ep->>ep: 2. 控制面把 Pod 从 Endpoints 摘除(与 1 同时触发,二者并行)
Note over ep,client: Service 停止路由新流量到这个 Pod<br/>(但旧连接还在)
kubelet->>pod: 3. SIGTERM
pod->>pod: 4. graceful_shutdown 所有连接
pod->>client: GOAWAY / Connection: close
client->>client: 5. 重建连接到其他 Pod
Note over kubelet,pod: 等 terminationGracePeriodSeconds(默认 30s)
kubelet->>pod: 6. SIGKILL(如果还没退出)
关键的时序风险在第 2 步和第 3 步之间——"Endpoints 摘除"是通过 K8s controller → kube-proxy iptables/ipvs → 每个 Node 生效,这个链路并不是瞬时的。常见的时间是几百毫秒到 2-3 秒。在这个窗口里,kubelet 已经给 pod 发了 SIGTERM,但 kube-proxy 还没更新,Service IP 可能仍然把新流量路由过来。
如果 hyper 收到 SIGTERM 的反应是"立刻关 listener",那这 2-3 秒内新到的 TCP 连接会被TCP RST——客户端看到 connection refused 或 5xx。
23.4.2 preStop hook 是干什么的
preStop 的真正作用是给 "Endpoints 生效" 争取时间。标准配置:
# k8s deployment spec
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command:
- "sh"
- "-c"
- "sleep 10"
这个"sleep 10"不做任何事,只是 block 10 秒不让 kubelet 发 SIGTERM。这 10 秒里:
- 控制面已经把 pod 从 Endpoints 摘除;
- kube-proxy 已经把规则同步到所有 Node;
- Service IP 不再路由新流量到这个 Pod;
- 进程仍然正常处理请求,健康的很。
10 秒过完 preStop,kubelet 才发 SIGTERM。这时候 Pod 已经彻底"看不见"新流量——graceful_shutdown 只需要处理 in-flight 请求。
23.4.3 terminationGracePeriodSeconds 怎么配
hyper 侧的 drain deadline(23.3.2 的 25 秒)必须 < K8s 的 terminationGracePeriodSeconds,不然 K8s 会先 SIGKILL——hyper 来不及通知客户端就被杀。
推荐公式:
terminationGracePeriodSeconds = preStop 时长 + hyper drain deadline + buffer
= 10s + 25s + 10s = 45s
buffer 是为了给 graceful_shutdown 最后那些"即将完成的响应"一点时间完成 TCP FIN/ACK 挥手。
典型错误:terminationGracePeriodSeconds: 30(默认值)+ 20 秒长请求 + 10 秒 preStop。这组合下,K8s 留给 hyper 的时间只有 30 - 10 = 20 秒,而长请求本身就要 20 秒,余量为零——SIGTERM 之后剩余耗时超过 20 秒的请求(偶尔更慢的长请求,或摘流量有延迟、SIGTERM 前后才进来的请求)会被第 30 秒的 SIGKILL 直接砍掉,表现为客户端收到 502;§23.3.2 的 25 秒 drain deadline 在这里也根本用不满。
23.4.4 readinessProbe 和 livenessProbe
readinessProbe 的作用是控制 Endpoints 成员资格——探测失败就从 Endpoints 摘除,和新流量的路由关联;livenessProbe 的作用是重启 pod——探测失败 kubelet 直接 SIGKILL + 启动新实例。
关闭阶段的陷阱在 livenessProbe。想象:hyper 正在 graceful_shutdown 中,有 50 个 in-flight 请求在处理——这时候 livenessProbe HTTP GET /healthz 怎么回?回 200 正常,回 503 会让 kubelet 以为服务挂了直接 SIGKILL——暴力关。
生产推荐:
- readinessProbe:关闭期间立刻回 503(在 preStop 里给个文件 flag,让 /readyz handler 读到 flag 就 503)——这是加速 Endpoints 摘除。
- livenessProbe:关闭期间继续回 200——告诉 kubelet "我还活着,在清理,别 kill 我"。直到进程主动 exit。
这两个 probe 的行为在关闭时必须不一样——很多 Rust HTTP 框架模板里把它们写成同一个 /health 接口,上线就出事。
23.4.5 K8s HPA 和 concurrency_limit 的拉扯
HPA(Horizontal Pod Autoscaler)会根据 CPU 或 QPS 扩 pod。假设你配了 ConcurrencyLimit(100)——每个 pod 同时最多 100 个请求。流量突然 2× 上涨:
- 前 10 秒:每个 pod 的 concurrency 打满 100,后续请求在 poll_ready 阻塞——或者被 LoadShed 拒掉;
- HPA 感知 metrics 滞后(15-30 秒);
- HPA 扩出新 pod;
- 新 pod 启动 + readinessProbe 就绪还要 10-20 秒。
这意味着突刺流量下,ConcurrencyLimit 会让客户端"感知"到延迟升高(若不配 LoadShed)或大量 503(若配 LoadShed),持续约 1 分钟。缓解手段:
- readiness gate 不走 /readyz 而是看剩余并发额度,大于 20 才回 ready——让接近满载的 pod 从 Endpoints 主动摘掉(需要自己实现:
ConcurrencyLimit本身不暴露剩余额度,可以用ConcurrencyLimit::with_semaphore传入一个共享的Arc<Semaphore>,readiness handler 读它的available_permits(),见tower-0.5.3/tower/src/limit/concurrency/service.rs:32); - HPA 监控 concurrency utilization 而非 CPU——更快感知;
- pre-warmed pool——始终比实际负载多预留 30% 的 pod。
第三条和成本/可用性权衡相关,通常是最后的手段。
23.5 可观测性与 Metrics
"配好了,但看不见"和没配好一样。这一节是关于"在哪里埋点、埋什么、看什么"。
23.5.1 必须暴露的 metrics
| Metric 名 | 类型 | 含义 | 告警线 |
|---|---|---|---|
http_request_duration_seconds |
Histogram | 单请求端到端耗时(P50/P95/P99) | P99 > SLA |
http_requests_total{status,method,path} |
Counter | QPS 总量 | 异常变化 |
http_active_connections |
Gauge | 当前 live 连接数 | > 阈值(FD 限制 / 2) |
http_concurrency_in_flight |
Gauge | 当前并发请求数(tower-http 自带的 InFlightRequests 就是干这个的,tower-http-0.6.8/tower-http/src/metrics/in_flight_requests.rs) |
接近 ConcurrencyLimit(tower-0.5.3/tower/src/limit/concurrency/service.rs:14) |
http_header_read_timeout_total |
Counter | header 超时计数(对应 hyper 的 header_read_timeout,hyper-1.9.0/src/server/conn/http1.rs:344,默认 30s) |
> 0 持续 |
http_h2_keep_alive_ping_rtt_seconds |
Histogram | PING RTT | 突升 |
http_h2_reset_stream_total{reason} |
Counter | 各种 RST_STREAM 原因 | REFUSED_STREAM 上涨 |
http_load_shed_total |
Counter | 被 LoadShed 拒掉的请求 | > 0 |
http_client_pool_utilization |
Gauge | 客户端 pool 使用率 | > 0.8 |
要注意,表里协议层这几项 hyper 1.9.0 并不都直接给数:header 超时可以从连接 future 返回的 hyper::Error 用 is_timeout() 识别(hyper-1.9.0/src/error.rs:277);PING RTT 和 RST_STREAM 原因 hyper 没有公开接口,得靠抓包或前置代理采集。
前四个是"业务可用性"信号——P99 和 QPS 看客户端感知,active connections 和 concurrency 看资源边界。中间四个是 hyper 协议层的"协议健康"信号——第 17 章讲过的 PING RTT 是最好的"网络感受器"。最后一个 pool_utilization 是客户端侧的——第 20 章开篇那类复用率归零的事故由它直接预警。
23.5.2 tracing 埋点位置
hyper、h2 和 tower 内部已经打了大量 tracing::trace!——生产环境开 RUST_LOG=info 只看高层日志,排障时临时调到 RUST_LOG=hyper=debug,h2=debug,tower=debug 能看到大部分问题。对 hyper 来说前提有三个:进程里装了读 RUST_LOG 的 subscriber(如 tracing_subscriber 的 EnvFilter);hyper 开了 tracing feature;并用 RUSTFLAGS='--cfg hyper_unstable_tracing' 编译——只开 feature 不加这个 cfg 会直接编译报错(hyper-1.9.0/src/trace.rs:5-11)。不开 feature 时 hyper 的日志宏是空操作(hyper-1.9.0/src/trace.rs:109-116 的 warn! 等),hyper=debug 什么也看不到。h2 的 tracing 是常开的,tower 的随用到的中间件 feature 自动启用,它们只需要第一条。
你自己的代码至少要打三个 span:
use tracing::{info_span, Instrument};
let span = info_span!(
"http_request",
method = %req.method(),
path = %req.uri().path(),
request_id = %req_id,
);
async move {
let resp = inner_service.call(req).await?;
tracing::info!(status = %resp.status(), "request completed");
Ok(resp)
}.instrument(span).await
tower_http::trace::TraceLayer 已经把这套模板做好了——TraceLayer::new_for_http()(tower-http-0.6.8/tower-http/src/trace/layer.rs:182)直接套:
use tower_http::trace::TraceLayer;
let svc = ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.timeout(Duration::from_secs(30))
.service(router);
23.5.3 Prometheus 导出
用 metrics + metrics-exporter-prometheus 是 Rust 生态的主流方案:
use metrics_exporter_prometheus::PrometheusBuilder;
let handle = PrometheusBuilder::new()
.install_recorder()?;
// 暴露 /metrics
let app = Router::new()
.route("/metrics", get(|| async move { handle.render() }))
.merge(business_router);
业务代码里打点:
metrics::counter!("http_requests_total", "status" => status.as_u16().to_string()).increment(1);
metrics::histogram!("http_request_duration_seconds").record(duration.as_secs_f64());
关键约束:label 基数别太大。path 作为 label 很诱人,但如果是 /users/123 这种带 id 的具体 path,百万级 id 会让 Prometheus 存储爆炸——应该用 route pattern(axum 0.8 写作 /users/{id},可从 MatchedPath 提取)而非具体 path。
23.5.4 log level 建议
- info:请求级事件(请求开始/结束、状态码、耗时);服务生命周期(启动、关闭、reload)。
- warn:预期外但不致命(超慢请求、LoadShed 拒绝、PING RTT 飙升)。
- error:请求处理失败;协议错误;连接被异常关闭。
- debug:中间件打点(只在排障时开);pool 取/还;stream window 变化。
- trace:hyper/h2 内部(只在特定排障场景开);waker 唤醒链。
不要在 hot path 打 info 级以上的日志——每条 log 至少是一次 string formatting + IO。P99 是这么被吃掉的。
23.5.5 "看得到不等于看得懂"
埋点和 metric 是基础,但真正能用来诊断问题的是在线上真出事那一刻,能快速从症状定位到原因。实现这一点需要把 metric 和 trace 关联起来——比如看到 P99 飙升,能立刻下钻看"这段时间哪些 span 最慢";看到某个 span 慢,能看它在 hyper 的哪一层卡住了。
具体技术是 OpenTelemetry 的 trace_id 贯穿——从 HTTP request header 的 traceparent 进来,落到 tower 的 TraceLayer,向下一路传到上游的 reqwest client,再由上游服务用同一个 trace_id 继续。运维拿到一个慢请求的 trace_id,可以在所有相关服务的日志里 grep 到完整链路。这种"全链路可观测"能力是现代微服务运维的基础。
Rust 生态里 tracing-opentelemetry 把这套做得相当完整——一次配置,hyper server 入口、reqwest client 出口、数据库 driver 的 span、业务 handler 的 span,全部贯通一个 trace_id。配合 Jaeger 或 Tempo 查询,排障从"翻日志猜"变成"顺着链路看"。
23.6 压力测试方法
"上线前压过了"的承诺,很多时候是假的——因为压测工具选得不对、场景搭得不像。这一节梳理几个靠谱的工具和对应场景。
23.6.1 wrk:HTTP/1 高并发小请求
wrk 是最经典的工具——C 写的、epoll-based、单机能打满千兆网卡。
# 4 个线程、400 个连接、压 30 秒
wrk -t4 -c400 -d30s http://localhost:8080/api/hello
# 带 Lua 脚本自定义 body
wrk -t4 -c100 -d30s -s post.lua http://localhost:8080/api/users
wrk 的强项是极限 QPS,弱项是不能精确控速——它会尽可能地发请求直到对端顶不住,很难模拟"稳定 1k QPS"这种现实场景。
23.6.2 wrk2:定速 QPS(正确的 latency 测量)
wrk2 fork 自 wrk,加了恒定速率(constant throughput)模式——测 P99 应该用这个模式。原因是 Coordinated Omission(协同遗漏)——wrk 每条连接"请求 A 回来之后 B 才发",服务卡住的那段时间里本该发出的请求根本没发,卡顿只记在少数几个请求头上,P99 被严重低估。
wrk2 -t4 -c100 -d60s -R10000 http://localhost:8080/api/hello
# -R 10000 表示恒定 10000 QPS
看 wrk2 的 latency 直方图——如果你的服务实际能扛 8000 QPS,你打 10000,直方图 P99 会飙到几秒。这是"容量测试"最直观的方法。
23.6.3 vegeta:脚本化和报表
vegeta Go 写的、JSON/CSV 输出、会画直方图——适合 CI 集成。
echo "GET http://localhost:8080/api/hello" | \
vegeta attack -duration=60s -rate=5000 | \
vegeta report -type=json > report.json
23.6.4 ghz:gRPC 专用
基于 hyper 的 tonic 做 gRPC 服务——用 ghz:
ghz --insecure -c 50 -n 100000 \
--proto ./api.proto --call package.Service/Method \
-d '{"name":"world"}' \
localhost:50051
23.6.5 必测的"异常场景"
除了正常 QPS,生产准备阶段必须压三种异常场景:
- 慢客户端(Slowloris):用
slowhttptest -c 1000 -r 100 -H -u http://yoursvc——1000 个 slow header 连接,100 个/秒的速率。服务必须能在 header_read_timeout(如 10 秒)内把这些只发了半截 header 的连接清掉; - 连接数上限:
wrk -c 100000打 10 万并发连接——服务必须在 FD 耗尽前在accept层限住连接数(比如达到上限就暂停 accept)——LoadShed按请求拒绝,管不到连接数; - 大 body:1GB 大 body 上传——测
Limited::new的 cutoff 和 h2 window 流控效果。
这三种都是生产真实事故来源——第 23.7 节会讲一个 Slowloris 真实事故的复盘。
23.6.6 压测环境必须匹配生产
压测一个最常见的错误——压测环境跑 localhost,生产跑跨区网络。localhost 上 RTT < 1ms,所有第 16 章讲过的 HTTP/2 流控窗口几乎不构成瓶颈;真实跨区网络 RTT 几十毫秒,1 MB 的 stream window 很可能不够用,导致 throughput 比压测时低得多。
标准的做法是压测环境至少要和生产在相近的网络拓扑上——同 AZ 内部压力测试 + 跨 AZ 压力测试各跑一份,对比观察。很多团队会发现压测没问题的配置上线后性能差一大截——几乎都是这个 BDP(带宽 × 延迟乘积)不匹配的原因。
另外压测目标服务要和生产环境相同的机器规格。Tokio 的 worker threads 默认等于 CPU 核数——在 2 核机器上跑得好不代表在 32 核跑得好(LIFO slot 的局部性、跨核 cache line 弹跳等问题在核数多时才显现)。这是《Tokio 源码深度解析》专栏第 19 章(性能调优与典型陷阱)讲过的"高核数场景的特殊陷阱"。
23.7 三个典型事故复盘
理论讲完,挑三个真实事故——也是前面章节引用过的那三个——把它们放到同一个聚光灯下复盘。
23.7.1 事故一:连接复用率归零(呼应第 20 章开篇)
现象:凌晨 3 点告警——P99 从 50ms 飙到 8s,抓包看到几乎每个请求都在重新走 TCP + TLS 握手,客户端侧 TIME_WAIT 堆积。
根因:服务 A 调用下游服务 B,用的是 reqwest::Client(内部是 hyper-util 的 legacy pool)。某个请求路径在某个分支里没把 body 读完就 drop 了 response。hyper client 对 HTTP/1 连接的归还条件不是"拿到响应头",而是"body 读到 EOF、SendRequest 重新 ready"——hyper-util 仓库 src/client/legacy/client.rs:350-363 在响应返回后 spawn 一个 on_idle future 等 pooled.poll_ready,ready 了才把 Pooled drop 回池。body 没读完就 drop 时,hyper 的 h1 dispatcher 走 poll_drain_or_close_read(hyper-1.9.0/src/proto/h1/dispatch.rs:212-213、hyper-1.9.0/src/proto/h1/conn.rs:849-865):只有剩余 body 能一次读空才算 drained,否则直接 close_read()——这条连接不会回到池里,下一个请求只能重新建连。于是复用率归零,每个请求都要付一次握手(第 20 章 §20.1.1 算过这笔账)。
修复:
// 错的写法
let resp = client.get(url).send().await?;
if resp.status().is_success() {
return Ok(()); // resp drop 时 body 还没读完 —— 这条连接被关掉,不会回池
}
// ...
// 对的写法
let resp = client.get(url).send().await?;
if resp.status().is_success() {
let _ = resp.bytes().await?; // 显式读完 body,连接才能归还池子
return Ok(());
}
教训:
- hyper client pool 对"body 未读完的 response 早 drop"这个模式没有任何保护机制——它只保证正确性(关掉不能复用的连接),不保证复用率。
- 连接新建速率 /
pool_utilizationmetric 必须监控——不然这类问题只会在延迟飙升那一刻才爆。 - 事后推动了团队内部 lint 规则——禁止 reqwest response 不走 bytes()/json() 直接 drop。
23.7.2 事故二:Slowloris 对 hyper 的影响
现象:某个新上线的 HTTP/1 服务,白天没问题,晚上 10 点定时报 active_connections 飙到 10 万。FD 耗尽后整个服务拒新连接。
根因:新服务忘了配 header_read_timeout——用了 http1::Builder::new() 默认,虽然默认是 30 秒,但不配 Timer 的话默认值是死的——Time::check(hyper-1.9.0/src/common/time.rs:72-78)对"默认值 + 无 Timer"只走一条 warn! 就返回 None,不 panic(只有显式调过 header_read_timeout(..) 又无 Timer 才 panic,见 hyper-1.9.0/src/server/conn/http1.rs:338-339 注释)——他们用的是底层 http1::Builder 没套 TokioTimer,而 hyper 默认编译不带 tracing,那条 warn! 根本没有输出。实际效果是"header_read_timeout 完全不生效"。
每天晚上 10 点定期跑一个扫描机器人,每秒新建 1000 条 TCP 连接,每条只发半条 HTTP header(GET / HT),不发完。每条连接占 1 个 FD + 一点内存 + 一个 Tokio task。几分钟就把 FD 耗尽。
修复:
let mut builder = http1::Builder::new();
builder
.timer(TokioTimer::new()) // <- 这里,原先漏了
.header_read_timeout(Duration::from_secs(10));
换成 hyper_util::server::conn::auto::Builder 也一样——auto 不会替你注入 Timer(§23.1.4),仍然要 builder.http1().timer(TokioTimer::new())。
教训:
- hyper 的 API 设计是"你不主动配就没保护"——这带来了灵活性也带来了责任。
- Timer 的 panic 警告在文档里,默认值失效时默认编译下连一条日志都没有,都很容易漏看——生产配置必须显式套
TokioTimer::new()。 - 加
http_active_connectionsmetric 告警线在 FD 限制的一半。
23.7.3 事故三:K8s rolling update 导致 500
现象:每次发版都有一波 500 错误,持续 20 秒左右,和发版时间完全对齐。
根因:复合的:
- K8s deployment 没配 preStop——SIGTERM 一发下来立刻触发 hyper 停 accept。
- 那一瞬间 kube-proxy 规则还没更新——Service IP 仍往 pod 路由。
- 新的 TCP SYN 到了 pod——TCP 栈接收了(内核 listen backlog 没关),但 hyper 的 accept task 已经 break——backlog 里的连接永远不会被 accept。
- 客户端等 read timeout 后报 502。
修复:
spec:
template:
spec:
terminationGracePeriodSeconds: 60
containers:
- name: app
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 15"]
- hyper 代码里 graceful_shutdown 流程按 23.3.2 节模板改造。
教训:
- K8s 默认行为不是"优雅"——优雅需要你主动组装 preStop + graceful_shutdown + tracker。
terminationGracePeriodSeconds: 30默认值对大多数服务是不够的。- 发版期间的 5xx 必须被监控——不然这个问题"感觉发版不稳定"但找不到根因,能拖几个月。
三个事故合起来揭示一件事——hyper 的默认值保护不了所有场景。生产准备清单必须包括"主动检查每一层超时""主动写优雅关闭""主动配 K8s lifecycle"——没有哪一条是"开箱即用"的。这也和《Tokio 源码深度解析》专栏第 19 章(性能调优与典型陷阱)的基调一致——Tokio/hyper 给你最大自由,代价是你必须主动负责。
23.7.4 三个事故的共同模式
这三个事故表面是三个不同的根因,但如果抽象一层看——它们都是"静默的默认值"带来的。hyper 的 header_read_timeout 默认有值但需要 Timer 注入才生效;K8s 的 terminationGracePeriodSeconds 有默认但没配 preStop 时行为不对;hyper client 的"body 没读完就 drop 则连接直接关闭、不复用"是一个没写在第一页文档的隐含契约。
这些"默认能跑但生产会翻车"的地方,是工程文化里最难治的——代码能跑、单元测试过、staging 也正常,只有在生产流量/规模下才暴露。防御它的方法只有三条:
- 明确列出每一层的默认值和触发条件——像 23.1.2 那张表一样,把默认值写出来,让人能主动对比。
- 压异常场景,不只压 happy path——Slowloris、连接数爆炸、慢客户端是必须的三项,它们覆盖了上面所有事故。
- 暴露"边界指标"——pool_utilization、active_connections、concurrency_in_flight——这些不是 SLI(业务人不看),而是给运维团队看的"接近极限"信号。没有这些 metric,出事前完全不知道在接近悬崖。
更深一层的教训是:"库的灵活性"和"生产的可靠性"是一对永远需要调和的矛盾。hyper 选择把灵活性极大化——把超时、背压、优雅关闭都交给用户,带来了第 24 章会讨论的"生态繁荣"——但同时也要求用户具备相当的工程素养。如果你的团队没有这个素养,选一个默认配置更保守、约定更完整的框架可能更合适。这不是 hyper 做得不好,而是设计取舍。
23.8 一份可复制的生产 Builder 模板
最后给一份可以直接抄到生产项目里的完整配置模板——取 hyper 1.9.0 + hyper-util 0.1.x + tower 0.5.x 的稳定 API。
use std::{sync::Arc, time::Duration};
use anyhow::Result;
use hyper::body::Incoming;
use hyper::{Request, Response, StatusCode};
use hyper_util::{
rt::{TokioExecutor, TokioIo, TokioTimer},
server::conn::auto,
service::TowerToHyperService,
};
use socket2::{SockRef, TcpKeepalive};
use tokio::{net::TcpListener, signal::unix::{signal, SignalKind}};
use tokio_util::{sync::CancellationToken, task::TaskTracker};
use tower::{Service, ServiceBuilder};
use tower_http::trace::TraceLayer;
pub struct ProdServer<S> {
listener: TcpListener,
service: S,
cfg: ServerConfig,
}
#[derive(Clone)]
pub struct ServerConfig {
// HTTP/1
pub h1_header_read_timeout: Duration,
pub h1_max_buf_size: usize,
// HTTP/2
pub h2_keep_alive_interval: Duration,
pub h2_keep_alive_timeout: Duration,
pub h2_max_concurrent_streams: u32,
pub h2_initial_stream_window: u32,
pub h2_initial_conn_window: u32,
pub h2_max_frame_size: u32,
pub h2_max_pending_accept_reset_streams: usize,
// TCP
pub tcp_nodelay: bool,
pub tcp_keepalive_time: Duration,
pub tcp_keepalive_interval: Duration,
pub tcp_keepalive_retries: u32,
#[cfg(target_os = "linux")]
pub tcp_user_timeout: Duration,
// Server
pub graceful_drain_timeout: Duration,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
h1_header_read_timeout: Duration::from_secs(10),
h1_max_buf_size: 256 * 1024,
h2_keep_alive_interval: Duration::from_secs(10),
h2_keep_alive_timeout: Duration::from_secs(3),
h2_max_concurrent_streams: 64,
h2_initial_stream_window: 1 * 1024 * 1024, // 1 MB = hyper server 默认值(见 §23.8.1),高 BDP 场景可提到 4-8 MB
h2_initial_conn_window: 4 * 1024 * 1024, // 4 MB(默认 1 MB)
h2_max_frame_size: 16 * 1024, // 16 KB (SPEC 默认)
h2_max_pending_accept_reset_streams: 20,
tcp_nodelay: true,
tcp_keepalive_time: Duration::from_secs(30),
tcp_keepalive_interval: Duration::from_secs(10),
tcp_keepalive_retries: 3,
#[cfg(target_os = "linux")]
tcp_user_timeout: Duration::from_secs(30),
graceful_drain_timeout: Duration::from_secs(25),
}
}
}
impl<S, B> ProdServer<S>
where
S: Service<Request<Incoming>, Response = Response<B>> + Send + Clone + 'static,
S::Future: Send + 'static,
S::Error: Into<Box<dyn std::error::Error + Send + Sync>> + 'static,
B: http_body::Body + Send + 'static,
B::Data: Send,
B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
{
pub fn new(listener: TcpListener, service: S, cfg: ServerConfig) -> Self {
Self { listener, service, cfg }
}
pub async fn serve(self) -> Result<()> {
let cfg = self.cfg;
let mut builder = auto::Builder::new(TokioExecutor::new());
// HTTP/1 配置
builder
.http1()
.timer(TokioTimer::new())
.header_read_timeout(cfg.h1_header_read_timeout)
.max_buf_size(cfg.h1_max_buf_size);
// HTTP/2 配置
builder
.http2()
.timer(TokioTimer::new())
.keep_alive_interval(cfg.h2_keep_alive_interval)
.keep_alive_timeout(cfg.h2_keep_alive_timeout)
.max_concurrent_streams(cfg.h2_max_concurrent_streams)
.initial_stream_window_size(cfg.h2_initial_stream_window)
.initial_connection_window_size(cfg.h2_initial_conn_window)
.max_frame_size(cfg.h2_max_frame_size)
.max_pending_accept_reset_streams(cfg.h2_max_pending_accept_reset_streams);
let builder = Arc::new(builder);
let tracker = TaskTracker::new();
let shutdown = CancellationToken::new();
// SIGTERM(K8s)/ SIGINT(本地 Ctrl-C)监听
let mut sigterm = signal(SignalKind::terminate())?;
let sig = shutdown.clone();
tokio::spawn(async move {
tokio::select! {
_ = sigterm.recv() => {}
_ = tokio::signal::ctrl_c() => {}
}
tracing::info!("shutdown signal received");
sig.cancel();
});
loop {
tokio::select! {
biased;
_ = shutdown.cancelled() => {
tracing::info!("accept loop stopping");
break;
}
res = self.listener.accept() => {
let (stream, peer) = match res {
Ok(v) => v,
Err(e) => {
tracing::warn!(error = %e, "accept failed");
tokio::time::sleep(Duration::from_millis(100)).await;
continue;
}
};
// TCP 层配置
let sock_ref = SockRef::from(&stream);
let _ = sock_ref.set_tcp_nodelay(cfg.tcp_nodelay);
let _ = sock_ref.set_tcp_keepalive(
&TcpKeepalive::new()
.with_time(cfg.tcp_keepalive_time)
.with_interval(cfg.tcp_keepalive_interval)
.with_retries(cfg.tcp_keepalive_retries),
);
#[cfg(target_os = "linux")]
{
let _ = sock_ref.set_tcp_user_timeout(Some(cfg.tcp_user_timeout));
}
let io = TokioIo::new(stream);
// tower Service → hyper Service 的桥(第 13、19 章)
let svc = TowerToHyperService::new(self.service.clone());
let builder = builder.clone();
let shutdown = shutdown.clone();
tracker.spawn(async move {
let conn = builder.serve_connection_with_upgrades(io, svc);
tokio::pin!(conn);
let res = tokio::select! {
res = conn.as_mut() => res,
_ = shutdown.cancelled() => {
conn.as_mut().graceful_shutdown();
conn.await
}
};
if let Err(e) = res {
tracing::warn!(peer = %peer, error = %e, "connection ended");
}
});
}
}
}
// Drain
tracker.close();
let deadline = tokio::time::sleep(cfg.graceful_drain_timeout);
tokio::select! {
_ = tracker.wait() => tracing::info!("all connections drained cleanly"),
_ = deadline => tracing::warn!("drain timeout"),
}
Ok(())
}
}
type BoxError = Box<dyn std::error::Error + Send + Sync>;
// 中间件的错误若原样交给 hyper,hyper 只会断连接(HTTP/1)或重置 stream(HTTP/2),
// 客户端拿不到状态码;在这里把它们翻译成响应
fn errors_to_status<B: Default>(res: Result<Response<B>, BoxError>) -> Result<Response<B>, BoxError> {
let status = match &res {
Err(e) if e.is::<tower::load_shed::error::Overloaded>() => StatusCode::SERVICE_UNAVAILABLE,
Err(e) if e.is::<tower::timeout::error::Elapsed>() => StatusCode::GATEWAY_TIMEOUT,
_ => return res,
};
let mut resp = Response::new(B::default());
*resp.status_mut() = status;
Ok(resp)
}
// 业务层的 tower 栈,交给 ProdServer(具体类型由编译器推断)
pub async fn run(listener: TcpListener) -> Result<()> {
let service = ServiceBuilder::new()
.layer(TraceLayer::new_for_http())
.map_result(errors_to_status)
.load_shed()
.concurrency_limit(1024)
.timeout(Duration::from_secs(30))
.service(your_business_router());
ProdServer::new(listener, service, ServerConfig::default()).serve().await
}
这份配置里每一个数字都有理由——不是拍脑袋出来的:
h1_header_read_timeout = 10s——防 Slowloris,10 秒足够任何正常客户端发完 header;h2_keep_alive_interval = 10s+timeout = 3s——每 10 秒发一次 PING,3 秒内没收到 ACK 就关,约 13 秒内能清掉死连接;h2_max_concurrent_streams = 64——默认 200 太激进,64 对 API 服务够用;h2_initial_stream_window = 1 MB——这正是 hyper server 侧的默认值(§23.8.1),写出来是为了显式化;高 BDP 场景再往上提到 4-8 MB;concurrency_limit = 1024——结合 CPU 核数和单请求耗时估出的上限,配合load_shed满了直接拒;errors_to_status把拒绝翻译成 503、把超时翻译成 504——少了这一层,hyper 拿到的是Err,客户端只会看到连接被断(HTTP/1)或 stream 被重置(HTTP/2),实测 curl 分别报 Empty reply 和 HTTP/2 stream 错误;timeout = 30s——业务请求级硬上限,比多数实际业务慢很多但够防"handler 里无限循环";graceful_drain_timeout = 25s——配合 K8sterminationGracePeriodSeconds = 45s(preStop 10s + drain 25s + buffer 10s)。
套上 tower 中间件的顺序也不是随意的——从外到内是 Trace → 错误翻译 → LoadShed → ConcurrencyLimit → Timeout → 业务。
- Trace 在最外面——任何情况都能记录;
- 错误翻译紧贴在 LoadShed 外面——它要接住
Overloaded和Elapsed两种错误; - LoadShed 在 ConcurrencyLimit 外面——满了立刻 503,不等;
- Timeout 在业务外面——30 秒 kill 业务;
- 业务拿到的是已经过 LoadShed + ConcurrencyLimit 筛过、有 30 秒时间的请求。
这份模板不是"最终答案"——每个服务都有自己的负载特征,具体数字要基于压测调。但它是一个合格的起点——比 hyper::server::conn::http1::Builder::new() 那种完全裸跑稳得多。
23.8.1 一条容易被误传的默认值:h2 stream window 不是 64 KB
很多文章会写"hyper 的 stream window 默认 64 KB 太小"——这条不准确,照着配 1 MB 在 server 侧等同于不配。hyper 1.9.0 源码里的实际默认值:
| 角色 | 实际默认 | 源码位置 |
|---|---|---|
| server stream window | 1 MB = 1024 × 1024 | hyper-1.9.0/src/proto/h2/server.rs:37 DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024 |
| server conn window | 1 MB | hyper-1.9.0/src/proto/h2/server.rs:36 DEFAULT_CONN_WINDOW: u32 = 1024 * 1024 |
| client stream window | 2 MB | hyper-1.9.0/src/proto/h2/client.rs:49 DEFAULT_STREAM_WINDOW: u32 = 1024 * 1024 * 2 |
| client conn window | 5 MB | hyper-1.9.0/src/proto/h2/client.rs:48 DEFAULT_CONN_WINDOW: u32 = 1024 * 1024 * 5 |
| HTTP/2 spec 基线 | 64 KB = 65535 | hyper-1.9.0/src/proto/h2/mod.rs:30 SPEC_WINDOW_SIZE: u32 = 65_535 |
"64 KB"那个数字来自 RFC 9113 §6.9.2 的协议基线(SPEC_WINDOW_SIZE),但 hyper 从来不用它当默认——hyper 在 server/client 两侧都早早把 DEFAULT_STREAM_WINDOW 抬到了 1 MB / 2 MB。所以 §23.8 模板里"= 1 MB"这一行在 server 侧只是把默认值显式写出来;高 BDP 场景如果想要更大的 window,应该配到 4 MB 或 8 MB(同时考虑 adaptive_window 默认 false,见 hyper-1.9.0/src/proto/h2/server.rs:63)。模板注释的写法可以概括成:
// 默认是 1 MB(DEFAULT_STREAM_WINDOW),HTTP/2 spec 基线 64 KB 只是 RFC 兜底;
// 如果是 BDP × 单 stream 大文件场景,配到 4 MB 或 8 MB;
// 否则可以删掉这一行——默认就够。
这一条事实校对体现的不是"hyper 文档不清楚",而是协议规范与实现默认之间的常见 gap——读者看 RFC 看到 65535,看 hyper 公开 API 文档看到"hyper will use a default",两个数字不会对齐——只有 grep DEFAULT 直接读源码才能拿到真实数字。这也是本专栏反复强调"所有数字都要回到源码验证"的一次活生生的例子。
23.8.2 调优旋钮的源码账本
把本章 §23.2.2 那张大表背后的所有"旋钮文件"按真实行数列出来——这是你"调一个生产 hyper 服务"时需要心中有数的代码总量:
| 子系统 | 文件 | 行数 | 本章对应小节 |
|---|---|---|---|
| hyper server H1 | hyper-1.9.0/src/server/conn/http1.rs |
559 | §23.1 header_read_timeout(line 344)、§23.3 graceful_shutdown(line 137) |
| hyper server H2 | hyper-1.9.0/src/server/conn/http2.rs |
312 | §23.1 keep_alive_*(line 226-237)、max_concurrent_streams(line 215 默认 200) |
| hyper proto H2 server | hyper-1.9.0/src/proto/h2/server.rs |
550 | §23.2.2 表 + §23.8.1 默认值真相(frame 编解码不在这里,在 h2 crate) |
| hyper-util auto Builder | hyper-util/src/server/conn/auto/mod.rs |
1385 | §23.3 graceful_shutdown(line 467) |
| hyper-util graceful | hyper-util/src/server/graceful.rs |
488 | §23.3 优雅停机(本章正文未直接引,GracefulShutdown 是把 §23.3.2 手写的 tracker 模式封装好的现成实现) |
| tower timeout | tower-0.5.3/tower/src/timeout/mod.rs |
70 | §23.1.2 / §23.8 tower::timeout |
| tower load_shed | tower-0.5.3/tower/src/load_shed/mod.rs |
76 | §23.2.4 LoadShed(line 43-64 的 poll_ready 永真 + call 里 Overloaded) |
| tower concurrency | tower-0.5.3/tower/src/limit/concurrency/service.rs |
117 | §23.1.2 / §23.8 ConcurrencyLimit + Semaphore |
| tower 全 crate | tower-0.5.3/tower/src/(0.5.3) |
11935 | 调优时可能要翻的范围;本章直接用到的只有上面三行的 263 行,其余是 balance、buffer、retry 等本章没用上的中间件 |
上表各文件合计 15229 行 Rust(hyper 两个 server builder 871 行 + proto/h2/server.rs 550 行 + hyper-util auto 1385 行 + graceful 488 行 + tower 全 crate 11935 行)。这个数是"调优时可能要翻的源码范围",不是旋钮本身的体量:其中近八成来自 tower 全 crate,而本章模板真正用到的 tower 代码只有 timeout、load_shed、limit/concurrency 三个 Service 实现,合计 263 行(70 + 76 + 117),各自的 layer / future / error 文件另计。真正的旋钮是散在这些文件里的十几个 pub fn——调优就是找到对应那行源码、读它的默认值、决定是否覆盖,而不是拍脑袋改 Builder::new()。
23.9 落到你键盘上
- 检查当前服务的四层超时矩阵—— 打开你线上服务的配置,对着 23.1.2 的表过一遍:TCP keepalive 配了吗?
header_read_timeout配了 Timer 吗?HTTP/2 keep_alive_interval 配了吗?tower::timeout 在 service stack 里吗?Limited 套了 body 吗?每一项都回答 "yes" 或 "no,因为...",不能含糊。 - 自己做一次 Slowloris 测试—— 随便找一台 VM 装 slowhttptest,对自己的 dev 环境打:
slowhttptest -c 5000 -r 100 -H -u http://your-dev:8080。看http_active_connectionsmetric 是否稳定在 header_read_timeout 内的 connection 数(100 conn/s × 10s 超时 ≈ 1000 稳态)。如果一路涨向 5000,说明 header_read_timeout 没真的生效——检查 Timer 是否注入了。 - 手写一次 graceful shutdown 完整流程—— 从 signal 监听、accept 循环的 select、每连接 graceful_shutdown、到 TaskTracker::wait with deadline——不用抄我的模板,自己写一遍,理解每一步"为什么不能少"。写完用
tcpdump抓 SIGTERM 时刻的包,看 HTTP/2 的 GOAWAY 两阶段、HTTP/1 的Connection: close是不是按预期发出了。 - 给你的 K8s deployment 加 preStop—— preStop 里
sleep 15+terminationGracePeriodSeconds: 60+ 发版前后 5 分钟监控 5xx 曲线。对比加之前的差异——大多数服务这里会有一个戏剧性的改善。 - 读
tower_http::trace::TraceLayer—— 第 22 章讲过 tower-http,但这一层具体怎么和 tracing 对齐、span 怎么延续、关键字段怎么埋——看源码tower-http-0.6.8/tower-http/src/trace/mod.rs。它是生产级 tracing 的范本。 - 跑一次 wrk2 capacity test—— 阶梯式增加
-R参数从 1000 一直到服务扛不住,记录每一档的 P99。画出 "QPS-P99" 曲线,找到膝盖点——那就是你的 ConcurrencyLimit 应该配的值。用实测数据而不是拍脑袋定旋钮值。