返回文章列表

Rust 微服务限流不是加个中间件,而是保护下游和自己

237·2 分钟阅读
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 服务里的限流,不应该只是一个中间件。它应该围绕资源边界设计:入口容量、租户公平性、昂贵接口、下游并发、降级策略。

先明确限流保护谁;全局限流保护服务,租户限流保护公平,下游限流保护调用方;限流后的动作要区分拒绝、排队和降级;所有限流都必须有指标。

限流写得好,系统不是更冷酷,而是更诚实。