本站的中间件栈
主线位置:请求主线。这四层的
poll_ready都直接放行,事情全在call和响应回来的路上做;限流超额时直接回 429,不走就绪主线的背压,这正是它和tower::limit::RateLimit的分别。
flowchart TB req[请求] --> sec[security_headers_mw] sec --> comp[CompressionLayer] comp --> slash[trailing_slash_mw] slash -->|尾斜杠| redir[308 跳转] slash --> rl[rate_limit_mw] rl -->|桶空了| r429[429] rl --> route[路由与 handler] redir -.->|往外| comp r429 -.->|往外| slash route -.->|响应往外| rl
本页的源码链接都指向本站仓库的提交 89a61b1。
四层,后加的在外层
路由组装好以后,main.rs 连着调了四次 Router::layer(第 382、385、390、394 行):
// S2(风险 R4):应用层 per-IP 限流,仅作用于 /api/*(auth / pay 更严)。
// ...
let router = router.layer(axum::middleware::from_fn(crate::server::rate_limit::rate_limit_mw));
// G7:`/blog/` 这类尾斜杠地址 301 到 `/blog`,避免重复 URL。
let router = router.layer(axum::middleware::from_fn(crate::server::seo::trailing_slash_mw));
// G4:按 Accept-Encoding 压缩响应(br / gzip)。HTML、CSS、JS、wasm、
// sitemap / feed 都受益;图片和流式 SSE 由默认判定跳过,已带
// Content-Encoding 的响应不重复压缩。
let router = router.layer(tower_http::compression::CompressionLayer::new());
// S1(风险 R6):全站安全响应头(CSP / nosniff / Referrer-Policy / X-Frame-Options)。
// 挂在最外层,覆盖页面、server fn、静态资源与上面全部自定义路由(含 429 响应)。
let router =
router.layer(axum::middleware::from_fn(crate::server::security::security_headers_mw));
每次 layer 都把「到目前为止的全部」包进新的一层,所以后加的在外层。请求从外往里走,响应从里往外走:
| 由外到内 | 层 | 做什么 | 会不会自己回响应 |
|---|---|---|---|
| 1 | security_headers_mw | 先 next.run(req).await,再给响应补 CSP、nosniff、X-Frame-Options 等头;已有同名头的不覆盖 | 不会 |
| 2 | CompressionLayer(tower-http) | 按 Accept-Encoding 压缩响应体 | 不会 |
| 3 | trailing_slash_mw | GET / HEAD 的旧博客地址和尾斜杠地址永久跳转 | 会:Redirect::permanent |
| 4 | rate_limit_mw | 只管 /api/*,按客户端 IP 取令牌 | 会:429 加 Retry-After: 1 |
这和 ServiceBuilder 的顺序正好相反:ServiceBuilder 是先写的在外层(见 ServiceBuilder),axum 的 Router::layer 是一次包一层,后写的在外层。两种写法混用时最容易排错顺序,见 layer 顺序。
尾斜杠那一层的注释写的是「301」,实际状态码是 308:axum 0.8.9 的 Redirect::permanent 用的是 308 Permanent Redirect,下面的实验输出也是 308。
还有一点:Router::layer 不是把整个 Router 包起来,而是把 layer 逐条套在每个路由上,fallback 也套一份。路由匹配发生在这四层之前。一个没有匹配到任何路由的地址(比如 /blog/)走的是 fallback,照样经过这四层,所以尾斜杠跳转能生效。
安全头为什么在最外层
rate_limit_mw 超限时不调用 next,直接构造 429 返回;trailing_slash_mw 遇到要跳转的地址也一样。短路返回的响应只会经过它外面的层。安全头挂在最外层,429 和 308 也带安全头;要是挂在里面,这些响应就没有安全头。
下面这个独立的 axum 程序按同样的顺序叠了四层(三个 from_fn 是简化版:限流只给 1 个令牌,安全头只补 X-Frame-Options),每一层进入时记一笔,然后依次发 /api/x、/api/x、/blog/。第二组把安全头挪到最里层做对照。
axum = "=0.8.9"
tower-http = { version = "=0.6.11", features = ["compression-gzip"] }
tower = { version = "=0.5.3", features = ["util"] }
tokio = { version = "=1.54.1", features = ["macros", "rt-multi-thread"] }
use std::sync::Mutex;
use axum::{
Router,
body::Body,
extract::Request,
http::{HeaderValue, StatusCode, header},
middleware::{Next, from_fn},
response::{IntoResponse, Redirect, Response},
routing::get,
};
use tower::ServiceExt;
use tower_http::compression::CompressionLayer;
static TRACE: Mutex<Vec<&'static str>> = Mutex::new(Vec::new());
static TOKENS: Mutex<u32> = Mutex::new(0);
fn trace(step: &'static str) {
TRACE.lock().unwrap().push(step);
}
fn take_token() -> bool {
let mut tokens = TOKENS.lock().unwrap();
let ok = *tokens > 0;
if ok {
*tokens -= 1;
}
ok
}
// 简化版 rate_limit_mw:只管 /api/*,整个进程共用 1 个令牌
async fn rate_limit_mw(req: Request, next: Next) -> Response {
trace("rate_limit");
if req.uri().path().starts_with("/api/") && !take_token() {
return (StatusCode::TOO_MANY_REQUESTS, "请求过于频繁,请稍后重试").into_response();
}
next.run(req).await
}
// 简化版 trailing_slash_mw:GET /xxx/ 跳到 /xxx
async fn trailing_slash_mw(req: Request, next: Next) -> Response {
trace("trailing_slash");
let path = req.uri().path();
if path.len() > 1 && path.ends_with('/') && !path.starts_with("/api/") {
return Redirect::permanent(path.trim_end_matches('/')).into_response();
}
next.run(req).await
}
// 简化版 security_headers_mw:先跑内层,再补头
async fn security_headers_mw(req: Request, next: Next) -> Response {
trace("security");
let mut resp = next.run(req).await;
resp.headers_mut().entry("x-frame-options").or_insert(HeaderValue::from_static("DENY"));
resp
}
fn routes() -> Router {
Router::new()
.route("/api/x", get(|| async { trace("handler"); "x".repeat(100) }))
.route("/blog", get(|| async { "blog" }))
}
async fn send(router: &Router, path: &str) {
TRACE.lock().unwrap().clear();
let req = Request::builder()
.uri(path)
.header(header::ACCEPT_ENCODING, "gzip")
.body(Body::empty())
.unwrap();
let res = router.clone().oneshot(req).await.unwrap();
let h = |name| res.headers().get(name).and_then(|v| v.to_str().ok()).unwrap_or("-").to_string();
println!(
" {path:<8} {} enc={:<4} xfo={:<4} order={:?}",
res.status().as_u16(),
h("content-encoding"),
h("x-frame-options"),
TRACE.lock().unwrap()
);
}
async fn run(name: &str, router: Router) {
println!("{name}");
*TOKENS.lock().unwrap() = 1;
for path in ["/api/x", "/api/x", "/blog/"] {
send(&router, path).await;
}
}
#[tokio::main]
async fn main() {
// 和 main.rs 相同的顺序:先加的在里层
let site = routes()
.layer(from_fn(rate_limit_mw))
.layer(from_fn(trailing_slash_mw))
.layer(CompressionLayer::new())
.layer(from_fn(security_headers_mw));
run("site order (security outermost):", site).await;
// 反例:安全头最先加,落在最里层
let inner = routes()
.layer(from_fn(security_headers_mw))
.layer(from_fn(rate_limit_mw))
.layer(from_fn(trailing_slash_mw))
.layer(CompressionLayer::new());
run("security innermost:", inner).await;
}
按本站的顺序,order 一列是 security → trailing_slash → rate_limit → handler,和上表由外到内一致;第二个 /api/x 停在 rate_limit,/blog/ 停在 trailing_slash,但三个响应都带着 xfo=DENY,因为 security 在它们外面。完整输出在文末。
xfo=DENY?第二组把 security_headers_mw 最先加上,它落在 rate_limit_mw 里面、紧贴着路由。三个请求和第一组相同:一个正常的 /api/x、一个被限流的 /api/x、一个要跳转的 /blog/。哪几行的 xfo 还是 DENY?order 里 security 排在第几?
输出里还有一个顺带的事实:429 的响应体(请求过于频繁,请稍后重试,36 字节)也被 gzip 了,因为压缩层同样在限流外面。308 没有响应体,也就没有压缩。
为什么不用 tower::limit::RateLimit
tower 自带 RateLimit,本站没用它,自己在 rate_limit.rs 里写了一个按 IP 分桶的令牌桶。对照一下就知道 tower 那个为什么不合用:
tower::limit::RateLimit | 本站的 rate_limit_mw | |
|---|---|---|
| 算法 | 固定窗口:每个窗口 num 次 | 令牌桶:每秒补 rps 个,最多攒 burst 个 |
| 计数范围 | 一个实例一个窗口,所有调用方共用 | 每个客户端 IP 一个桶,另分普通桶和敏感桶 |
| 超额时 | poll_ready 返回 Pending,等到窗口结束 | 立刻回 429 和 Retry-After: 1 |
| 作用范围 | 包住的整个服务 | 只管 /api/*,页面和静态资源不限 |
Clone | 没有 | 中间件是一个 async fn,状态放在 static 里 |
逐条看:
一、超额时是等,不是拒绝。 RateLimit 用完本窗口的次数后,poll_ready 等 Sleep 到期,这是就绪主线上的背压,细节见 RateLimit。放在 axum 里,Route::poll_ready 永远返回 Ready,每个请求再用 oneshot 去等内层就绪,结果是超额的请求挂在内存里排队,而不是被拒掉。对公网接口,本站要的是直接告诉客户端「过一秒再来」。
二、粒度不对。 一个 RateLimit 实例只有一个窗口,所有客户端共用。本站按 client_key 分桶:先取 x-forwarded-for 的第一个地址,再看 x-real-ip,都没有就落进共享桶 "global";classify_path 只认 /api/ 开头的路径,登录、支付、下单走更严的敏感桶。
三、编译不过。 Router::layer 要求 layer 产出的服务能 Clone,RateLimit 没有:
use std::time::Duration;
use axum::{Router, routing::get};
use tower::limit::RateLimitLayer;
fn main() {
let _router: Router = Router::new()
.route("/api/x", get(|| async { "x" }))
.layer(RateLimitLayer::new(20, Duration::from_secs(1)));
}
error[E0277]: the trait bound `RateLimit<Route>: Clone` is not satisfied
--> src/bin/ratelimit_layer.rs:9:12
|
9 | .layer(RateLimitLayer::new(20, Duration::from_secs(1)));
| ----- ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ the trait `Clone` is not implemented for `RateLimit<Route>`
| |
| required by a bound introduced by this call
|
note: required by a bound in `Router::<S>::layer`
--> axum-0.8.9/src/routing/mod.rs:306:40
|
303 | pub fn layer<L>(self, layer: L) -> Router<S>
| ----- required by a bound in this associated function
...
306 | L::Service: Service<Request> + Clone + Send + Sync + 'static,
| ^^^^^ required by this bound in `Router::<S>::layer`
For more information about this error, try `rustc --explain E0277`.
error: could not compile `exp-tower-site` (bin "ratelimit_layer") due to 1 previous error
(依赖要多开 tower 的 limit feature。)tower 仓库里要求给 RateLimit 实现 Clone 的 issue #794 写作时仍然开着,讨论里引了 axum 维护者的说法:要是能复制,「you'd be able to just clone them to fake being able to handle more load」。
四、就算能 Clone,状态也留不住。 axum 的 Route 处理每个请求时都是 self.0.clone().oneshot(req):先复制一份套好 layer 的服务,再在副本上调用。存在服务实例身上的计数,每个请求看到的都是一份新的。下面的中间件在自己身上存一个 local,另在 Arc 里存一个 shared:
use std::{
sync::{
Arc,
atomic::{AtomicU32, Ordering},
},
task::{Context, Poll},
};
use axum::{Router, body::Body, extract::Request, routing::get};
use tower::{Layer, Service, ServiceExt};
// 一个把计数存在自己身上的中间件,像 tower::limit::RateLimit 那样
#[derive(Clone)]
struct Count<S> {
inner: S,
local: u32, // 每个实例自己的计数
shared: Arc<AtomicU32>, // 所有副本共用的计数
}
#[derive(Clone)]
struct CountLayer(Arc<AtomicU32>);
impl<S> Layer<S> for CountLayer {
type Service = Count<S>;
fn layer(&self, inner: S) -> Count<S> {
Count { inner, local: 0, shared: self.0.clone() }
}
}
impl<S: Service<Request>> Service<Request> for Count<S> {
type Response = S::Response;
type Error = S::Error;
type Future = S::Future;
fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), S::Error>> {
self.inner.poll_ready(cx)
}
fn call(&mut self, req: Request) -> S::Future {
self.local += 1;
let shared = self.shared.fetch_add(1, Ordering::SeqCst) + 1;
println!(" {} local={} shared={}", req.uri().path(), self.local, shared);
self.inner.call(req)
}
}
fn req(path: &str) -> Request {
Request::builder().uri(path).body(Body::empty()).unwrap()
}
#[tokio::main]
async fn main() {
println!("same Router (with_state), three requests:");
let mut router = Router::new()
.route("/a", get(|| async {}))
.route("/b", get(|| async {}))
.layer(CountLayer(Arc::new(AtomicU32::new(0))))
.with_state(()); // 先把 layer 套好,排除「每次请求重新套」
let mut svc = router.as_service::<Body>();
for path in ["/a", "/a", "/b"] {
svc.ready().await.unwrap().call(req(path)).await.unwrap();
}
}
same Router (with_state), three requests:
/a local=1 shared=1
/a local=1 shared=2
/b local=1 shared=3
(M2 Max、rustc 1.99.0、--release,连跑 3 次输出相同。)同一个 Router、同一个路由连发两次,local 都是 1;只有放在 Arc 里的 shared 在累加。.with_state(()) 是为了排除另一个因素:没 with_state 的 Router 每个请求还会把 handler 外面的 layer 重新套一遍,见 不起服务器的测试。
所以本站的限流器状态放在服务实例之外:rate_limit_mw 第一次被调用时初始化一个 static OnceLock<Option<RateLimiters>>,之后所有副本、所有路由读的都是它。
问: 桶表放在 static 里,整个进程只有一份。那不是和「一个 RateLimit 实例一个窗口」一样,所有人共用了吗?
答: 共用的是表,不是桶。表是 Mutex<HashMap<String, Bucket>>,键是客户端 IP,每个 IP 一个桶,互不影响。必须共用的恰恰是这张表:axum 每个请求都复制一份服务,表要是放在服务身上,每个请求拿到的都是空表,谁都限不住。只有拿不到 IP 的请求才落进同一个 "global" 桶。
令牌桶本身很短,acquire_in:
fn acquire_in(&self, map: &mut HashMap<String, Bucket>, key: &str, now: Instant) -> bool {
let bucket = map.entry(key.to_string()).or_insert(Bucket { tokens: self.burst, last: now });
let dt = now.duration_since(bucket.last).as_secs_f64();
bucket.tokens = (bucket.tokens + dt * self.rps).min(self.burst);
bucket.last = now;
if bucket.tokens >= 1.0 {
bucket.tokens -= 1.0;
true
} else {
false
}
}
新 IP 的桶是满的(burst 个令牌);每次来请求,先按距上次的时间补令牌,上限 burst,再扣 1 个。不需要定时器,也不需要后台任务。默认值是普通桶每秒 20、最多攒 60,敏感桶每秒 5、最多攒 15,都能用环境变量改。表的大小有上限 MAX_TRACKED_KEYS = 50 000:满了先清掉 600 秒(STALE_AFTER)没动过的桶,清完还满,新 IP 就并进一个叫 "__overflow__" 的共享桶,防止有人伪造海量 x-forwarded-for 把内存撑爆。
网关:钩子,不是洋葱
本站另有一个网关 crates/gateway,基于 Pingora,不用 tower。它组织逻辑的方式是实现 ProxyHttp trait 的几个回调,Pingora 在固定的阶段依次调用它们。网关的 impl ProxyHttp for AppGateway 实现了四个:
| 钩子 | 阶段 | 网关里做什么 |
|---|---|---|
request_filter | 收到请求后 | 可以自己写出响应,返回 Ok(true) 结束处理 |
upstream_peer | 选上游 | 固定转给 app |
upstream_request_filter | 发往上游前 | 改写 X-Forwarded-*、X-Real-IP |
response_filter | 回给客户端前 | 补响应头 |
和 tower / axum 的「一层套一层」比:
- 顺序由谁定。 tower 里顺序就是嵌套的顺序,写代码的人决定;Pingora 的阶段顺序是框架定死的,你只决定每个阶段做什么。
- 请求和响应在不在一个函数里。
from_fn中间件在同一个函数里next.run(req).await前处理请求、后处理响应,局部变量自然跨过这一步。Pingora 里请求和响应是两个钩子,要跨钩子传状态只能放进每个请求一份的CTX(网关里type CTX = (),没有用到)。 - 短路的响应走哪些步骤。 tower 里短路的响应还会穿过外面的每一层,上面安全头的实验就靠这一点。Pingora 里
request_filter自己写出响应、返回Ok(true)后,pingora-proxy 0.8.0 直接记日志、结束这个请求,不会再调response_filter。网关里把 80 端口跳到 HTTPS 的HttpToHttps::request_filter就是这样:在钩子里写出 301,然后Ok(true)。想让这类响应也带上某个头,得在写出它的那个钩子里自己加。 - 背压。 Pingora 的钩子里没有
poll_ready这一步,钩子只在请求已经到来时被调用。
动脑筋答案
M2 Max、rustc 1.99.0、--release,连跑 3 次,输出相同:
site order (security outermost):
/api/x 200 enc=gzip xfo=DENY order=["security", "trailing_slash", "rate_limit", "handler"]
/api/x 429 enc=gzip xfo=DENY order=["security", "trailing_slash", "rate_limit"]
/blog/ 308 enc=- xfo=DENY order=["security", "trailing_slash"]
security innermost:
/api/x 200 enc=gzip xfo=DENY order=["trailing_slash", "rate_limit", "security", "handler"]
/api/x 429 enc=gzip xfo=- order=["trailing_slash", "rate_limit"]
/blog/ 308 enc=- xfo=- order=["trailing_slash"]
- 第二组只有第一行(200)带
xfo=DENY:请求一直走到了 handler,路上经过了最里层的security,响应往外走时补上了头。order里security排第三,紧挨着handler(CompressionLayer不记录,所以不在列表里)。 - 429 停在
rate_limit,308 停在trailing_slash,都没走到security,于是没有安全头。本站把安全头挂在最外层,防的就是这两行。 - 两组的
enc一样:压缩层在两组里都包着限流,429 都被压缩了。
小结
Router::layer后加的在外层,和ServiceBuilder相反;它把 layer 套在每个路由和 fallback 上,路由匹配在这些层之前。- 本站由外到内:安全头、压缩、尾斜杠跳转、限流。短路返回的响应只经过它外面的层,所以安全头放在最外面,429 和 308 才带得上。
tower::limit::RateLimit超额时让请求等,一个实例一个固定窗口,不能Clone;axum 又在每个请求上复制服务,实例上的状态留不住。本站改用async fn中间件加static里的按 IP 令牌桶,超额直接 429。- 网关用 Pingora 的回调钩子:阶段顺序由框架定,请求和响应分在两个钩子里,
request_filter短路后不再经过response_filter。