三层限流:调用方速率、账号额度与机器容量
限流不只要防止单个调用方刷接口,还要处理账号配额和整机容量。本文结合 Rust/axum 实现,说明何时该拒绝而非排队、怎样找到真正消耗资源的对象、为什么用 GCRA 取代固定窗口,以及每层限流器应当如何失败。
限流其实在回答三个不同的问题
上周,我们盘点了 Rust 后端里的所有限流措施。乍看之下,防线已经很完整:二十多个接口有 per-IP 上限,登录和重置密码有 per-account 节流,成本较高的 AI 功能有每日配额,solver 前面也有信号量。
但换一个问法,缺口就出现了:整台机器满载时,系统准备怎么办?答案是,系统没有任何应对,只会越来越慢。请求没有超时,在途请求、WebSocket 连接和活跃牌桌都没有全局上限,也没有任何过载丢弃机制。换句话说,系统根本无法识别「已经过载」这个状态。
这不是某个项目才有的疏漏。限流本来就在回答三个不同的问题:
| 问题 | 按什么区分 | 常见机制 | 通常是否具备 |
|---|---|---|---|
| 这个调用方请求过快吗? | 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,
// 服务器已满,暂时无法开新桌;加入已有牌桌仍然可以
"The server is at capacity and cannot open a new table right now. \
Joining an existing table still works."))
}
这里有一条关键规则:只拒绝「开桌」,不拒绝「入座」。现有牌桌多坐一人的边际成本很低;如果朋友已经开局,却因为全局上限而无法加入,容量控制反而伤害了成本更低、价值更高的操作。设置资源上限前,先找出哪些操作会真正分配这项资源,只拦住它们。
规则三:预先决定每层限流器该如何失败
限流器也会遇到自身容量耗尽或依赖异常。设计时必须明确:这种情况下是宁可多拒绝一些请求,还是允许一部分请求通过?答案取决于它所在的层级,不能一概而论。
per-caller 限流器会在内存里为每个 key 保存一条记录,这张表本身必须有上限。否则,用来防止资源耗尽的组件反而会成为新的资源耗尽入口。可当表真的写满时,又该怎样处理?
- per-caller 限流器永不淘汰活跃桶,也不让新调用方绕过计量。如果所有桶都仍在使用,就拒绝尚未进入表中的新 key。要同时占满几万个活跃 key,通常已不是普通高峰,而是攻击流量;此时拒绝新的未知来源,是更安全的选择。
- 全局容量闸门采用 fail closed。它是保护进程不被过载拖垮的最后一道防线,失效时不能继续放量。
第一条规则,我们试错了两次。最初的做法是:表满后允许新 key 通过,但不再记录。这样一来,攻击者只要填满表,就等于替后续所有请求关闭限流。后来又尝试淘汰「负债最少」的桶;但淘汰只扫描有限样本,已经超额的桶也可能被选中,攻击者便有机会清除自己的限制。最终方案的价值,在于它能给出一句确定的保证,而不是一个概率判断:如果无法用一句话说明兜底策略保证了什么,那它就还不是可靠的兜底。
同样的原则也适用于豁免清单。全局限制最容易在这里误伤正常系统:
- 健康检查和就绪探针应当豁免。机器只是繁忙,却因为探针被限流而判定失败,部署系统就可能回滚或重启,把原本可恢复的过载升级成真正的故障。在我们的实现里,这条重要规则最终只是一个
matches!。 - 静态资源应当豁免。页面冷启动时,HTTP/2 会并发请求几十个带 hash 的 bundle。如果把它们也计入动态请求的容量预算,少量用户同时打开页面就可能触发拒绝,限流器反而阻止用户加载站点。
- WebSocket 升级刻意不豁免。WebSocket 可能存活数小时,乍看不该占用按几十毫秒 HTTP 请求估算的在途名额;但升级处理函数会先完成会话认证(一次数据库读取),之后 socket 专属上限才会生效。如果豁免升级路径,这段前置工作就不受任何容量闸门约束。把它计入在途请求是安全的,因为产生 101 响应后名额就会释放:框架把 socket 交给另一个 task,存活中的连接不会继续占用请求名额。这个框架行为直接关系到容量正确性,因此我们用真实 socket 测试确认升级后名额已经归还。
规则四:固定窗口会在边界放大一倍流量
常见的固定窗口限流器会按墙钟时间分桶:把 now / 60 作为分钟编号,编号变化时清零计数。实现只要几行,但它有一个明确且可利用的边界漏洞。
假设额度是每分钟 60 次。调用方可以在 t = 59.9 s 发完前 60 次,再在 t = 60.1 s 发出后 60 次。结果是:200 毫秒内通过 120 次请求,而两个窗口都没有超额。如果接口的承载上限就是每分钟 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; // 放行,并推进截止时间
GCRA 的内存占用与固定窗口相当,甚至更少——每个 key 只需一个时间戳,不再需要计数器和窗口编号。它没有可钻的窗口边界,而且能在拒绝时给出准确的 Retry-After。GCRA 这一层返回的 429,等待时间都来自这套计算。更早的限流器只是统一到了同一响应格式,并没有重写:固定窗口的 per-IP 层仍按整个窗口长度给出等待时间,处理函数里没有声明退避的每日配额则默认 60 秒。
规则五:调用方能选择的 key,也能被调用方轮换
per-IP 限流不依赖认证,所以经常成为默认方案。它的问题也很明显:运营商 NAT 可能让成千上万名移动用户共用一个地址,结果所有人只能共享一份额度。
per-account 更公平,但如果必须先查出账号才能限流,就意味着每个请求进入路由前都要多做一次数据库查询。为了节省少量令牌,却给所有请求增加一次查询,成本并不合理。
我们一度选择了看似零成本的第三种方案:用会话 cookie 的哈希作为 key。cookie 本身是随机令牌,哈希后可以得到稳定的会话标识;既不用查库,表里没有 PII,日志里也没有可还原的信息。
跨厂商 reviewer 很快指出了漏洞。限流器运行在认证之前,所以它无法分辨真实 cookie 和调用方随手编造的 cookie。匿名调用方只要发送 session=<随机串>,就能获得一份新额度;每次请求都换一个值,限制便形同虚设。这不是可靠的会话限流,而是一条被写进设计里的旁路。
修复方式是同时计入两个桶,并且让调用方无法伪造的那一层成为必选项:
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 Layer 时,别漏掉就绪契约
在 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。第二,容量名额必须由 drop guard 自动释放,不能只在成功路径手动减计数。客户端在请求中途断开时,future 会直接被 drop;如果释放逻辑只写在正常返回之后,每次断开都会永久漏掉一个名额。WebSocket guard 也遵循同一原则:把它移入升级后的 future,让 guard 与 socket 拥有完全相同的生命周期。
容量数字必须来自实测
一个说不清依据的上限,第一次遇到抱怨时就很容易被调高,最后等同于没有上限。但不同压测测到的是不同资源,不能把结果揉成一个笼统的「容量数字」。在真实生产机器上,solo 阶梯测试显示:150 人 / 150 桌仍在延迟预算内,200 人 / 200 桌开始超出预算,300 人 / 300 桌时 p99 约为 4.1 秒。这个结果定位的是 solo 负载的拐点,不是生产玩家上限。
多人桌的广播形态不同,因此生产保护线采用另一组数据:60 人 / 10 桌的两轮服务端 p99 分别为 69 ms 和 157 ms,120 人 / 20 桌时为 339 ms。我们按较差一轮保守取 60 人的 80%,把新 WebSocket 上限设为 48,并额外为重连或加入已有牌桌保留 12 个名额。另一个配置 ADMISSION_MAX_LIVE_TABLES=2000 不是吞吐目标,而是空桌在 10–20 分钟回收窗口内仍会被计数时,用来防止程序错误或脚本无限开桌的内存保险丝。
除了数值本身,还有两个同样重要的设计要求:
- 每个容量上限都可以通过环境变量配置。更换机器后,可以先重新测量,再调整限制,而不必修改代码。重新测量不能省,因为真正的瓶颈未必是 CPU,也未必随核心数线性变化。
- 每次拒绝都会连同原因写入日志。在途请求上限还维护着在途数、峰值、过载丢弃总数和超时总数的计数器,WebSocket 上限维护着在线数、峰值和拒绝总数;这些计数器目前都还没有导出到指标接口,牌桌上限和 CPU 许可则只有日志。一个从不报告触发次数的容量上限,与一个配置错误、永远不会触发的上限,看起来完全一样;等到故障发生才发现,代价会很高。
读完这篇文章,只需要带走一个问题:当所有用户都很正常,只是同时到来的人太多时,你的服务会怎样?如果答案只是「响应越来越慢」,说明系统具备按调用方限流,却还没有真正的容量控制。两者解决的是不同问题,缺一不可。