← 返回博客2026-08-21

限流是三个问题,大多数服务只答了一个

「这个调用方太快了吗」「这个账号额度用完了吗」「这台机器满了吗」——第三个问题无法用按调用方计数拼出来,而它才关乎存活。这篇写我们在 Rust/axum 上怎么补齐它:丢弃而非排队、限制真正花钱的对象、GCRA 取代固定窗口的边界双倍、以及每层限流器该往哪个方向失败。

大多数服务只回答了一个问题,却以为答了三个

上周我们把 Rust 后端里所有限流器审了一遍。纸面上防守很像样:二十五个接口带 per-IP 上限,登录和改密带 per-account 节流,昂贵的 AI 功能有每日配额,solver 前面还挂着信号量。纸面上。

然后我们换了一个问题——机器单纯被塞满时会发生什么?——答案是:什么也不会发生。它只会变慢。一直慢下去。没有请求超时,没有在途请求上限,没有 WebSocket 连接数上限,没有活跃牌桌数上限,任何地方都没有过载丢弃。「过载」这件事在系统里根本没有表示。

这个缺口的形状值得单独命名,因为它不是我们独有的:

问题按什么 key常见机制你有吗?
这个调用方是不是太快了?IP / 账号 / 会话令牌桶、GCRA几乎肯定有
这个账号今天的额度用完了吗?用户,按天数据库里的计数器卖会员就有
这台机器满了吗?不按任何人——它是全局的准入控制 + 丢弃通常没有

前两个关乎公平和计费。只有第三个关乎存活,而它恰恰是无法用「按调用方计数」拼出来的:一百个诚实用户各发出他们的第一个请求,会顺利通过人类写过的每一条 per-caller 检查。如果你所有的限制都挂在某个人身上,那你其实没有容量控制。

规则一:丢弃,而不是排队

服务器忙的时候,人的直觉是让调用方等。排队显得有礼貌。但它通常是错的,而且你可以用一个问题判断自己属于哪一类:客户端身上有没有你看不见的截止时间?

我们有。牌局出手倒计时 15 秒;我们的 Web 客户端重连预算是 30 秒,用完就放弃。一个等了八秒才看到自己「跟注」生效的玩家,体验已经比被告知「桌满了,稍后再试」更糟——而且这个排队中的玩家同时还占着那份让所有人变慢的容量。过载时排队并不会减少伤害,只会把伤害摊给本来打得好好的人。

所以我们加的每一道限制都是拒绝,而不是等待。唯一的例外是刻意的:等待 CPU 许可最多等一小段,因为普通争用几毫秒就化解了,为它丢弃很蠢。超过截止时间,它也丢:

pub async fn cpu_permit_or_shed(max_wait: Duration) -> Option<OwnedSemaphorePermit> {
    match tokio::time::timeout(max_wait, cpu_admission::acquire()).await {
        Ok(permit) => Some(permit),
        Err(_) => None,   // 调用方返回 503 + Retry-After
    }
}

「等一小会儿,然后拒绝」几乎总是优于两个极端。无界排队把过载变成谜之延迟;立刻拒绝则把两毫秒的小抖动变成错误页。

规则二:限制真正花钱的那个东西

限制最自然的单位,是你看得见的那个——请求、连接、用户。但正确的单位是消耗资源的那个,两者常常不同。

在我们的服务里,昂贵的对象是牌桌:每局一个长期存活的任务,持有状态机、计时器、机器人决策和广播扇出。六个朋友坐一张 6 人桌 = 一张桌。六个人各自和机器人练习 = 六张桌。同样的「同时在线用户数」,成本差六倍。因此用连接数表达的上限,要么对便宜人群太紧,要么对昂贵人群太松——唯独不可能对两者都正确。

所以主上限统计的是活跃对局任务,而且只在一个地方拒绝:

pub fn table_capacity_refusal(live_tables: usize) -> Option<Response<Body>> {
    let cfg = global_admission_config();
    if cfg.max_live_tables == 0 || live_tables < cfg.max_live_tables {
        return None;
    }
    Some(refusal(SERVICE_UNAVAILABLE, "server_full", cfg.retry_after_sec,
        "服务器已满,暂时无法开新桌;加入已有牌桌仍然可以。"))
}

注意它拒绝的是什么:只拒绝「开桌」,永远不拒绝「入座」。在已经存在的牌桌上多坐一个人,边际成本几乎为零;而把一个被朋友邀请、牌局已经开打的人挡在门外,是花掉一份容量上限的最糟方式。当你给某个资源设上限时,先搞清楚哪些操作真的在分配它,其余的放行。

规则三:清楚每个限流器往哪个方向失败

每个限流器都有失败模式,而且你必须刻意选择它,因为不同层的正确选择是相反的。

我们的 per-caller 限流器在内存里为每个 key 存一条记录。这张表必须有界——一个防资源耗尽的机制内部藏着无界的表,本身就是披着防御外衣的拒绝服务。但当它真的满了,应该怎么办?

  • per-caller 限流器永不淘汰活跃桶,也永不放行未计量的调用方。如果所有桶都还在用,它拒绝的是那个陌生调用方。要走到这一步,需要几万个不同调用方同时活跃 —— 那是攻击,不是繁忙的周二;而攻击当中把一个陌生人挡回去,正是对的失败方式。
  • 全局容量闸门失败时拒绝(fail closed)。那一层是「忙机器」和「死机器」之间最后的东西。

第一条我们错了两次才对,而两次错法都很说明问题。表满时把新 key 放行但不计量,等于让任何能把表撑满的人,替之后所有到达者关掉限流器。改成「淘汰最闲的桶」听起来好些,仍然是错的:扫描是有界采样,超额的桶也可能被选中 —— 这就等于把「清掉自己的封禁」这个能力送给了攻击者。最后落地的规则,是那个能被陈述而不是被估计的规则。这才是真正的教训:如果你没法用一句话说清你的兜底到底保证了什么,那你手里的不是兜底,是侥幸。

同样的思路推出豁免清单,而这正是幼稚的全局限流器伤害最大的地方:

  • 健康检查与就绪探针豁免。一台忙碌但健康检查失败的机器,会被部署系统回滚或重启——你把一次可恢复的过载,变成了一次故障。这是我们实现里最重要的一行,而它只是一个 matches!
  • 静态资源豁免。一次冷加载会通过 HTTP/2 同时抓几十个带 hash 的 bundle。把它们算进为动态请求设定的额度里,会让几个人同时打开页面就触发丢弃——限流器攻击它保护的站点。
  • WebSocket 升级刻意豁免,这点出乎我们意料。直觉是:一个存活一小时的连接,不该算进为 20 毫秒请求设的预算里。但升级处理函数会在 socket 专属的上限生效之前先做会话认证(一次数据库读),所以一旦豁免这条路径,这部分工作就落在所有闸门之外。而它其实可以安全地计入:在途名额是在 101 响应产生时释放的 —— 框架把 socket 交给另一个 task,所以活着的连接并不占用请求名额。这是一个承重的框架细节,因此我们用一个「开真实 socket 并断言名额已归还」的测试把它钉住了。

规则四:固定窗口在边界会漏出双倍

业界最常见的限流器把请求计进墙钟窗口:用 now / 60 当桶 key,桶变了就重置计数。四行代码,而且错得很具体、很可利用。

一个「每分钟 60 次」的调用方,可以在 t = 59.9 s 花掉六十次,再在 t = 60.1 s 花掉六十次:200 毫秒内 120 次请求,完全合规,每分钟一次,在你用这种方式保护的每个接口上都成立。如果你设 60 是因为接口只扛得住 60,那固定窗口会在最糟的时刻悄悄把它翻倍。

解法不是滑动日志(每个请求一个时间戳,内存无界),而是 GCRA——把漏桶表达成一个虚拟调度截止时间。每个 key 一个 Instant,没有计数器,没有重置,而且给出的是精确的重试时间,而不是「等这个窗口过去」:

let interval  = window / limit;          // 配额隐含的平均间隔
let tolerance = interval * (limit - 1);  // 恰好一个完整配额的突发

let tat      = *entry;                            // 理论到达时间
let earliest = tat.checked_sub(tolerance).unwrap_or(now);
if earliest > now {
    return Decision::denied(retry_after: earliest - now);
}
*entry = max(tat, now) + interval;                // 放行,并推进截止时间

内存和固定窗口一样(其实更省——一个时间戳,而不是计数器加窗口号),没有可利用的边界,而且拒绝时能准确告诉客户端要等多久。我们现在发出的每个 429,携带的 Retry-After 都来自这个算式,而不是拍脑袋。

规则五:限流器跑在认证之前,所以调用方能选的 key,他就能轮换

per-IP 是默认选项,因为它不需要认证。它也恰好会惩罚你最不想惩罚的那批用户:运营商 NAT 可能把成千上万移动用户放在同一个地址后面,他们共享一份额度。

per-account 更公平,但一个「必须先查出账号才能判断」的限流器,等于在每个路由前面加一次数据库查询,每个请求都要付——只为了省几个令牌。

于是我们选了第三个、成本为零的方案:用会话 cookie 的哈希作为 key。cookie 本身就是随机令牌;哈希一下就得到稳定的「按会话」身份,不查库,表里没有 PII,日志里也没有任何可还原的东西。

跨厂商 reviewer 一句话就把它毙了。限流器跑在认证之前——中间件的意义就在于此——所以它根本分不出真 cookie 和瞎编的 cookie。任何匿名调用方都可以发 session=<随机串>,拿到一份全新额度,然后每个请求换一次。我们做的不是限流器,而是一个自带旁路的限流器,而那份「文档」正是一段夸这个 key 有多妙的注释。

修法是同时收两个桶,并且让不可伪造的那个变成必收:

let addr = check(class, keys.address, ADDRESS_QUOTA_MULTIPLIER);  // 永远收
if !addr.allowed { return addr }
match keys.session {
    Some(s) => check(class, s, 1),   // 有 cookie 时再加一层公平性
    None    => addr,
}

现在轮换 cookie 只能换到一样东西:更宽的地址额度——而它的存在,本来就是为了让一个满是诚实用户的 NAT 不被当成一个人来限。逃逸被一个倍数框住了,而不是无穷大。而且即使会话桶随后拒绝了这个请求,地址桶也照收不误:被拒绝的调用方,依然是在发请求的调用方。

可复用的规则不是「别用 cookie 当 key」,而是:写清楚哪些 key 是调用方能自己挑的,并确保至少有一个他挑不了的 key 每次都被收费。每种 key 方案都会在某处漏,挑一个你能接受的漏法,把它框住,并在注释里写明白。

tower 层,以及所有人都会写错的那一行

axum/tower 里,全局上限就是包住整个 router 的一个 Layer。形状平平无奇,除了一个会咬所有人的细节:

fn call(&mut self, req: Request<Body>) -> Self::Future {
    // `call` 可能被调用在一个从未被 `poll_ready` 驱动过的克隆上。
    // 换入已就绪的实例,把克隆留给下一次。
    let clone = self.inner.clone();
    let mut inner = std::mem::replace(&mut self.inner, clone);

    Box::pin(async move {
        let _guard = match admission.try_enter() {
            Some(g) => g,                       // RAII:任何退出路径都会释放
            None => return Ok(refusal(503, "server_busy", retry_after, "...")),
        };
        match tokio::time::timeout(deadline, inner.call(req)).await {
            Ok(res) => res,
            Err(_)  => Ok(refusal(503, "request_timeout", retry_after, "...")),
        }
    })
}

两点值得抄走。mem::replace 是 tower 就绪契约的官方绕法;不写它,你会在高负载下拿到间歇性 panic——一种发现自己容量层的美妙方式。另外,那个 guard 必须是 drop guard,不能手动减计数:客户端在请求中途断开会直接 drop 掉 future,而一个只在成功路径释放的槽位,每发生一次就永久漏掉一份容量。同样的规则适用于 WebSocket 的 guard,我们把它移进升级后的 future 里,让它的寿命和 socket 完全一致。

数字从哪来

一个你捍卫不了的上限,第一次有人抱怨就会被调高,那它其实等于不存在。我们的数字来自对真实生产机器跑的一轮阶梯:服务在延迟预算内可以撑住 150 张并发牌桌,200 张时超出预算,到 300 张直接掉下悬崖——运行点定在实测上限的 80%,因为上限是在「客户端已经全部连上并热身完毕」的稳态条件下测的,而真实流量是在已有负载之上成阵地到来的。

有两个属性和数字本身同样重要:

  • 每个上限都是环境变量。机器换了,限制不用改代码就能移动——而重新测量是唯一诚实的移动方式,因为约束从来不是 CPU,也不随核数线性变化。
  • 每个上限都有计数。在途数、峰值、丢弃总数、超时总数、按原因分类的拒绝数。一个从不报告自己触发过多少次的容量上限,和一个被配置成永远不会触发的上限,看起来一模一样——而你会在最糟的时刻才知道自己属于哪种。

如果只带走一件事:去问问你自己的服务,满了会怎么样。不是「某个调用方很粗鲁时会怎样」,而是所有人都很礼貌、只是人太多时会怎样。如果答案是「它会变慢」,那你有的是按调用方限流,没有容量控制——这是两个不同的产品。