Rust 微服务限流不是加个中间件,而是保护下游和自己
限流经常被理解成“请求太多就返回 429”。这个理解太窄了。限流真正保护的是容量边界。
一个 Rust 服务可能会被三种流量压垮:
- 用户请求太多,入口扛不住
- 某个租户异常,把共享资源占满
- 下游变慢,请求堆在本服务里
只在网关加一个全局 QPS 限制,解决不了这些问题。
限流不是拒绝用户,而是把系统容量明确说出来。
限流先问保护谁
限流策略不能从“每秒多少请求”开始。
应该先问:这条限制保护谁?
| 限流对象 | 保护目标 |
|---|---|
| 全局入口 | 当前服务 |
| 用户维度 | 防止单用户滥用 |
| 租户维度 | 防止大租户挤占小租户 |
| 接口维度 | 保护昂贵操作 |
| 下游维度 | 保护被调用服务 |
用代码表达一个限流 key:
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
enum RateLimitKey {
Global,
User(u64),
Tenant(u64),
Endpoint(&'static str),
Downstream(&'static str),
}这个枚举比一堆字符串拼接更清楚。
限流 key 要跟被保护的资源对齐,而不是跟代码层级对齐。
Token Bucket 更适合服务端突发流量
最常见的限流模型是 Token Bucket。
它允许短时间突发,但长期速率受控:
use std::time::Instant;
#[derive(Debug)]
struct TokenBucket {
capacity: u64,
tokens: u64,
refill_per_sec: u64,
updated_at: Instant,
}判断能不能通过:
fn allow(bucket: &mut TokenBucket) -> bool {
if bucket.tokens == 0 {
return false;
}
bucket.tokens -= 1;
true
}真实实现还要按时间补 token,这里重点是语义:令牌代表系统愿意接受的容量。
Token Bucket 比固定窗口更适合服务端,因为它不会在窗口边界产生明显抖动,也允许正常的小突发。
租户限流比全局限流更重要
多租户服务里,只做全局限流会有一个问题:大租户可以把容量用完,小租户跟着失败。
租户级限流能把公平性表达出来:
#[derive(Debug)]
struct TenantLimit {
tenant_id: u64,
qps: u64,
burst: u64,
}
fn limit_key(limit: &TenantLimit) -> RateLimitKey {
RateLimitKey::Tenant(limit.tenant_id)
}这类限制不是为了惩罚大客户,而是避免共享系统失去隔离。
如果某个租户需要更高容量,就给它单独配置,而不是让它冲击全局池。
多租户系统没有租户级限流,就谈不上真正的资源隔离。
下游限流要放在调用方
很多服务只关心入口限流,忽略出站调用。
但一个服务最容易拖垮自己的方式,是对慢下游无限并发调用。
可以把下游调用容量表达成一个并发限制:
#[derive(Debug)]
struct DownstreamBudget {
name: &'static str,
max_in_flight: usize,
}
fn should_call(current_in_flight: usize, budget: &DownstreamBudget) -> bool {
current_in_flight < budget.max_in_flight
}这和前面 背压 是一条线:下游慢的时候,调用方必须让请求排队、失败或降级,而不是继续堆。
入口限流保护自己,出站限流保护自己不被下游拖死。
429 不一定是最好的失败方式
限流以后怎么返回,也要看场景。
| 场景 | 处理方式 |
|---|---|
| 用户频繁刷新 | 429 + Retry-After |
| 后台批处理 | 延迟重试 |
| 管理后台重操作 | 提示稍后再试 |
| 下游容量不足 | 快速失败或降级 |
| 非核心推荐接口 | 返回缓存或空结果 |
可以先把决策建模:
#[derive(Debug, PartialEq, Eq)]
enum LimitDecision {
Allow,
Reject,
Degrade,
}
fn decide_limited(optional_feature: bool) -> LimitDecision {
if optional_feature {
LimitDecision::Degrade
} else {
LimitDecision::Reject
}
}限流不是非黑即白。核心写请求应该明确失败,非核心读请求可以降级。
限流指标比限流代码更重要
没有指标的限流很危险。
你至少要知道:
- 哪个 key 被限流
- 被限流多少次
- 当前 token 消耗情况
- 请求是被拒绝还是降级
- 限流后用户体验是否变差
用一个快照表达:
#[derive(Debug)]
struct LimitSnapshot {
allowed: u64,
rejected: u64,
degraded: u64,
}
fn rejection_ratio(s: &LimitSnapshot) -> f64 {
s.rejected as f64 / (s.allowed + s.rejected + s.degraded).max(1) as f64
}如果限流触发了,但团队没人看到,那就是把故障从“系统崩”换成“用户静默失败”。
限流是保护机制,也必须是可观测机制。
结论
Rust 服务里的限流,不应该只是一个中间件。它应该围绕资源边界设计:入口容量、租户公平性、昂贵接口、下游并发、降级策略。
先明确限流保护谁;全局限流保护服务,租户限流保护公平,下游限流保护调用方;限流后的动作要区分拒绝、排队和降级;所有限流都必须有指标。
限流写得好,系统不是更冷酷,而是更诚实。