土法炼钢兴趣小组的算法知识备份

【系统架构设计】限流与过载保护:保命的最后一道防线

文章导航

分类入口
architecture
标签入口
#rate-limiting#token-bucket#leaky-bucket#load-shedding#Sentinel#Resilience4j#sliding-window#concurrency-limit

目录

上一篇讨论的是出站弹性(Outbound Resilience):服务作为调用方,如何在下游出问题时保护自己不被拖垮。熔断器、舱壁、超时都是站在”我调用别人”的角度设计的。本文换一个方向,讨论入站过载(Inbound Overload):服务作为被调用方,如何在流量超过自己处理能力时保护自己不被压垮。下一篇优雅降级则接着讨论:如果保护措施仍然不够,业务层该如何体面地”坏掉”而不是直接宕机。三篇文章合起来覆盖了一次故障从”对外调用”到”对内承压”再到”业务兜底”的完整链条。

限流(Rate Limiting)和熔断(Circuit Breaking)经常被混为一谈,但它们解决的是两个方向相反的问题。熔断器保护的是调用方,防止调用方因为下游故障而被拖垮;限流保护的是服务自身,防止过量的入站请求耗尽自己的资源。一个健康的下游完全可以被合法流量打垮——这时候没有任何下游故障可以熔断,唯一有效的手段是在入口处控制流量。

一、一次没有故障的过载

缓存击穿引发的场景

设想一个商品详情页服务:热点商品的详情数据缓存在 Redis 里,TTL 10 分钟。缓存过期的瞬间,如果这个商品正好是大促主推款,会在同一毫秒内涌入大量请求——不是因为有人攻击,只是因为营销页面把这个商品放在了首屏。这些请求全部命中缓存失效,全部穿透到数据库,数据库连接池被瞬间占满。

这个场景里,从头到尾没有任何组件”发生故障”:网络是通的,数据库是健康的,商品服务的代码没有 bug。问题纯粹是瞬时并发请求数超过了系统的处理能力。这和上一篇的银行通道超时事故有本质区别——那次事故里,银行通道确实变慢了、确实不可用了,重试机制把一个真实故障放大成了雪崩;这一次,没有任何下游”坏掉”,是流量本身超过了容量上限。以下数字均为示意,用于说明数量级关系,非任何实际压测或线上结果:

sequenceDiagram
    participant U as Users
    participant S as Product Service
    participant Cache as Redis Cache
    participant DB as Database

    Note over Cache: TTL expires at t0
    U->>S: burst of requests (t0)
    S->>Cache: GET product:hot_sku
    Cache-->>S: cache miss (all in same instant)
    S->>DB: fallthrough query x N
    Note over DB: connection pool saturates
    DB-->>S: queued / slow response
    S-->>U: latency spikes, some timeouts
    U->>S: manual refresh (secondary burst)

这就是所谓”成功的灾难”(Success Disaster):不是产品失败了,而是产品火爆到系统没扛住。促销大促、突发热点新闻、抢票开票、突发事件相关的查询暴涨,都是这一类场景的变体。

过载与下游故障的分界

区分这两类问题很重要,因为对应的工程手段完全不同:

维度 下游故障(熔断器场景) 入站过载(限流场景)
触发原因 下游服务变慢或不可用 入站请求量超过自身容量
请求性质 请求本身合法,下游处理不了 请求本身合法,自己处理不了
保护对象 调用方(自己) 被调用方(自己)
观测视角 出站调用的失败率、慢调用率 入站请求的速率、并发数、队列长度
典型手段 熔断、超时、舱壁 限流、并发限制、负载削减
触发时机 依赖出问题之后 依赖完全健康时也可能触发
恢复方式 等下游恢复,半开探测 等自己扩容或流量回落

如何在监控里快速区分二者

事故现场分钟级的判断速度直接影响止损效果,区分”下游故障”和”入站过载”应该有明确的可观测信号,而不是靠猜:

这四组信号组合起来,通常几分钟内就能定位问题方向:下游故障应该触发熔断和降级预案,入站过载应该触发限流收紧和负载削减,两套预案如果用反了,处置效率会大打折扣。

一个容易忽视的事实是:这两类问题会互相转化。入站过载如果处理不当(比如请求排队时间过长导致大量超时),会让这个服务本身变成别人眼中”不可用的下游”,触发上游对它的熔断。反过来,如果一个服务对下游发起了不受控的重试(上一篇的重试风暴),也会在下游制造出一次入站过载。限流和熔断因此是同一个韧性体系里互补的两块拼图,而不是二选一的关系。

限流不是应对过载的唯一手段。如果流量的增长是渐进的、可预测的,更根本的解法是弹性伸缩——通过扩容把容量上限抬高,而不是把超出的请求拒之门外。但弹性伸缩存在分钟级的响应延迟(拉新实例、预热、加入负载均衡),无法应对毫秒到秒级爆发的流量尖峰。限流和过载保护是在扩容生效之前的”止血带”:它们不解决容量不足的根本问题,只是防止系统在扩容完成前彻底崩溃。

入站限流与出站限流:方向不同,算法相同

上面的表格只区分了”熔断器场景”和”限流场景”,但限流本身还要再细分方向。入站限流控制的是”别人打给我的流量”,判断依据是”我自己还有没有余量”,触发时应该让调用方明确知道”是你超限了”(对应第四节要讨论的 429 语义)。出站限流控制的是”我打给下游的流量”,判断依据是”下游声明的配额还剩多少”,本质上和上一篇的舱壁模式是近亲——舱壁隔离的是资源(线程、连接),出站限流约束的是速率,两者经常配置在同一个下游依赖上共同生效。

这两个方向可以复用完全相同的算法实现(令牌桶、漏桶等),区别只在于限流器摆在调用链的哪一端、超限之后走哪条错误路径。一个服务完全可以同时是别人的”下游”(需要出站限流保护自己不把下游打垮)和自己请求的”入口”(需要入站限流保护自己不被上游打垮),这也是为什么限流规则通常要按”资源”而不是按”服务”来配置——同一个服务里,对数据库的出站调用和对外暴露的 HTTP 接口,可能用的是完全不同的限流参数。

二、算法谱系:漏桶、令牌桶与滑动窗口

两个可以限的维度

限流算法要回答的核心问题只有一个:在单位时间窗口内,允许多少请求通过,拒绝多少请求。围绕这个问题,工程界发展出四类主要算法,它们在”是否允许突发”“实现复杂度”“统计精度”上做出了不同取舍。

在深入算法之前,需要明确限流可以作用在两个不同的维度上:

这两个维度不是等价的,第四节会详细展开为什么”QPS 相同不代表系统压力相同”。本节先专注于速率维度的经典算法,这是限流技术谱系里最古老、最成熟的部分。

算法谱系的起点:ATM 流量整形

现代限流算法的雏形来自上世纪 80 年代的电信网络流量整形(Traffic Shaping)研究,而不是互联网服务。Turner 在 1986 年的论文中提出了漏桶算法(Leaky Bucket),用于约束 ATM(Asynchronous Transfer Mode)网络中信元(cell)的发送速率,目标是把突发流量整形为恒定速率输出,避免下游交换机被瞬时流量打垮(Turner, “New Directions in Communications,” IEEE Communications Magazine, 1986)。这个问题和今天服务端限流要解决的问题在结构上完全一致:都是在入口处约束一个不受控的到达过程,让下游能以恒定或有界的速率消化流量。

IETF 后来在流量整治(Traffic Conditioning)的语境下把令牌桶(Token Bucket)标准化为可运维的规范:RFC 2697(单速率三色标记器 srTCM)定义了承诺信息速率(CIR)与承诺突发大小(CBS)、超额突发大小(EBS)三个参数控制的令牌桶;RFC 2698(双速率三色标记器 trTCM)进一步引入峰值信息速率(PIR)形成双令牌桶结构。这两份规范把”允许多大突发、以什么速率补充令牌”这件事从工程经验变成了可以互操作的协议参数,也是今天几乎所有限流库中”速率 + 突发容量”两参数模型的直接来源。

srTCM 的具体做法是维护两个桶:一个容量为 CBS、按 CIR 补充的”承诺桶” \(T_c\),一个容量为 EBS、同样按 CIR 补充的”超额桶” \(T_e\)。一个大小为 \(B\) 字节的分组到达时按以下规则打色:

三色标记不是简单的”允许/拒绝”二元判断,而是把超限流量降级而不是直接丢弃:绿色分组正常转发,黄色分组在下游拥塞时优先被丢弃,红色分组通常直接丢弃或标记为最低优先级。这个思路和第四节要讨论的按关键性(Criticality)分级限流本质上是同一个模式在不同层次的复用——网络层用颜色标记流量优先级,应用层用关键性标签标记请求优先级,两者都是”超限不等于全部拒绝,而是先牺牲低优先级部分”的具体实现。trTCM 在此基础上把 CIR 替换为两档速率(CIR 和更高的 PIR),可以同时约束”长期不能超过 CIR”和”瞬时不能超过 PIR”两个独立的速率上限。

令牌桶:允许突发的速率限制

令牌桶维护一个容量为 \(B\)(burst,突发容量)的桶,以恒定速率 \(r\)(rate)向桶中补充令牌,令牌数不超过桶容量上限。每个到达的请求需要消耗 \(c\) 个令牌(通常 \(c = 1\)),只有桶中令牌数足够时才被放行。

令牌数随时间的演化:

\[T(t) = \min\big(B,\ T(t_0) + r \cdot (t - t_0)\big)\]

其中 \(T(t_0)\) 是上一次计算时刻 \(t_0\) 的令牌数。请求到达时刻 \(t\),若 \(T(t) \geq c\),则放行并令 \(T(t) \leftarrow T(t) - c\);否则拒绝或排队等待令牌补足。

令牌桶的关键性质:长期平均通过速率被限制在 \(r\) 以内,但允许瞬时突发——只要桶里攒够了令牌,最多 \(B\) 个请求可以在同一瞬间全部通过。这个”允许突发”的特性正是令牌桶区别于漏桶的核心:它描述的是一种准入策略,而不是一种整形策略

// 简化的令牌桶实现,桶容量 burst,恒定补充速率 ratePerSec
type TokenBucket struct {
    mu         sync.Mutex
    tokens     float64
    capacity   float64
    ratePerSec float64
    lastRefill time.Time
}

func NewTokenBucket(ratePerSec, capacity float64) *TokenBucket {
    return &TokenBucket{
        tokens:     capacity,
        capacity:   capacity,
        ratePerSec: ratePerSec,
        lastRefill: time.Now(),
    }
}

// Allow 尝试消耗 cost 个令牌,返回是否放行
func (b *TokenBucket) Allow(cost float64) bool {
    b.mu.Lock()
    defer b.mu.Unlock()

    now := time.Now()
    elapsed := now.Sub(b.lastRefill).Seconds()
    b.tokens = math.Min(b.capacity, b.tokens+elapsed*b.ratePerSec)
    b.lastRefill = now

    if b.tokens >= cost {
        b.tokens -= cost
        return true
    }
    return false
}

这段实现用”懒惰补充”(lazy refill)代替了后台定时器:每次请求到达时才根据经过的时间计算应补充的令牌数,避免了维护一个独立的补令牌线程。这是生产级限流库(包括 Guava RateLimiter、golang.org/x/time/rate)的通用做法。

漏桶:恒定速率整形

漏桶算法的模型是一个有固定容量 \(L\) 的队列,请求到达后进入队列排队,出口以恒定速率 \(r\)(每 \(1/r\) 秒放行一个请求)处理队首请求。如果请求到达时队列已满,则丢弃该请求(Turner 1986 原始定义)。

漏桶和令牌桶最本质的区别在于对突发的处理方式:

一个等价且在生产中更常用的漏桶变体是通用信元速率算法(Generic Cell Rate Algorithm, GCRA),这是 ATM 论坛在漏桶思想上给出的形式化算法,核心是维护一个”理论到达时间”(Theoretical Arrival Time, TAT):

\[TAT_{new} = \max(TAT_{old},\ t) + \tau\]

其中 \(\tau = 1/r\) 是两个请求之间的最小间隔,\(t\) 是当前请求到达时刻。若 \(t \geq TAT_{new} - \tau - \sigma\)\(\sigma\) 对应允许的突发容限),则放行请求并更新 \(TAT_{old} \leftarrow TAT_{new}\);否则拒绝。GCRA 的优点是不需要显式维护令牌计数或队列,只需要一个时间戳,redis-cell 这个开源 Redis 模块就是用 GCRA 把限流实现为一条原子命令 CL.THROTTLE,作者 Brandur Leach 在项目说明中提到这类基于 Lua 脚本手搓限流逻辑的做法在 Heroku、Stripe 等公司的实践中很常见,redis-cell 的目标是把它变成一个语言无关、可直接复用的原语:

-- redis-cell 的 CL.THROTTLE 命令语义(伪代码还原核心判断逻辑)
-- key、最大突发数 burst、速率 count/period 秒
local tat = redis.call('GET', key) or now
tat = math.max(tonumber(tat), now)
local new_tat = tat + emission_interval  -- emission_interval = period / count
local allow_at = new_tat - burst_offset  -- burst_offset = emission_interval * burst

if now >= allow_at then
    redis.call('SET', key, new_tat, 'PX', ttl_ms)
    return {1, 0}  -- 放行
else
    return {0, math.ceil((allow_at - now) / 1000)}  -- 拒绝,附带建议重试秒数
end

这段逻辑只需要 Redis 存一个时间戳(tat),单次 GET + SET 就能完成限流判断,比维护令牌计数或时间戳列表都更轻量,是分布式限流在存储成本上的一个务实选择。

Nginx 的 ngx_http_limit_req_module 官方文档明确说明其限流算法基于漏桶:rate 参数控制恒定处理速率,burst 参数控制允许排队等待的请求数量上限,超出部分直接返回 503(不带 nodelay)或立即拒绝(带 nodelay 时退化为类似令牌桶的突发放行):

# nginx.conf:定义限流区,按客户端 IP 分桶,速率 10 req/s
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/ {
        # burst=20:允许最多 20 个请求排队等待
        # nodelay:桶未满时立即放行而不是强制排队整形
        limit_req zone=api_limit burst=20 nodelay;
        limit_req_status 429;
    }
}

固定窗口计数器:简单但有边界突刺

固定窗口计数器是最简单的实现:把时间轴切成长度为 \(W\) 的固定窗口,统计当前窗口内的请求数 \(c_w\),若 \(c_w < N\) 则放行并计数,否则拒绝。窗口边界到达时计数器清零。

问题在于窗口边界处的突刺效应。假设 \(N = 100\),窗口长度 \(W = 1\) 秒,如果 100 个请求全部集中在窗口 \([0, 1)\) 的最后 1 毫秒到达,紧接着又有 100 个请求集中在窗口 \([1, 2)\) 的最初 1 毫秒到达,两次都不超过各自窗口的限制,都会被放行——但在任意长度为 1 秒的滑动区间(例如 \([0.999, 1.999]\))内,实际通过的请求数是 \(200\),是设定限额的两倍。固定窗口无法防止这种边界处的双倍突刺。

滑动窗口日志:精确但昂贵

滑动窗口日志(Sliding Window Log)为每个请求记录到达时间戳,请求到达时先剔除时间戳早于 \(t - W\) 的记录,再统计剩余日志条数,若小于 \(N\) 则放行并记录新时间戳。这个算法在任意时刻都精确统计”过去 \(W\) 时间内”的真实请求数,不存在固定窗口的边界问题,代价是需要维护一个随请求量增长的时间戳列表,内存开销是 \(O(N)\) 量级,且每次判断都要做一次区间剔除操作。

滑动窗口计数器:用近似换效率

生产环境更常见的折中方案是滑动窗口计数器(Sliding Window Counter),用当前窗口和上一个窗口的计数值做加权估算,避免存储每条请求日志。设当前窗口已经过去的比例为 \(t_{elapsed}/W\),则估算的”滑动窗口内请求数”为:

\[count_{est} = c_{curr} + c_{prev} \times \frac{W - t_{elapsed}}{W}\]

直觉是:假设上一个窗口内的请求是均匀分布的,那么”上一个窗口里还落在当前滑动区间内的那一部分”可以按比例折算。这是一个近似值而非精确值(真实分布不一定均匀),但只需要两个计数器就能把边界突刺问题压缩到可接受范围,是 Cloudflare 等厂商在公开技术博客中描述过的限流实现思路,也是多数 API 网关默认限流算法的选择。

# 滑动窗口计数器:仅用当前窗口与上一窗口两个计数器估算滑动区间内的请求数
class SlidingWindowCounter:
    def __init__(self, window_seconds: float, limit: int):
        self.window = window_seconds
        self.limit = limit
        self.curr_window_start = 0.0
        self.curr_count = 0
        self.prev_count = 0

    def allow(self, now: float) -> bool:
        window_id = int(now // self.window)
        curr_id = int(self.curr_window_start // self.window)

        if window_id != curr_id:
            # 跨入新窗口:当前窗口变为上一窗口
            self.prev_count = self.curr_count if window_id - curr_id == 1 else 0
            self.curr_count = 0
            self.curr_window_start = window_id * self.window

        elapsed = now - self.curr_window_start
        weight = max(0.0, (self.window - elapsed) / self.window)
        estimated = self.curr_count + self.prev_count * weight

        if estimated < self.limit:
            self.curr_count += 1
            return True
        return False

这个实现每次判断只需要两个整型计数器和一次浮点乘法,是内存开销和统计精度之间一个务实的折中点,Kong、Cloudflare 等网关产品公开描述过的限流实现思路与此结构基本一致。

四种算法对比

限流算法对比示意

(图中数值为示意,用于说明桶结构与窗口结构的差异,不代表任何实测吞吐或延迟曲线。)

算法 是否允许突发 边界突刺问题 内存开销 实现复杂度 典型实现
固定窗口计数器 否(窗口内均匀) 有,最坏可达 2 倍限额 \(O(1)\) 最低 早期简易网关、内部工具限流
滑动窗口日志 \(O(N)\) 较高 精确审计场景、低 QPS 高精度需求
滑动窗口计数器 有限(窗口边界附近) 大幅缓解,非完全消除 \(O(1)\) 中等 多数 API 网关、Cloudflare 类方案
令牌桶 是(最多 \(B\) 个瞬时突发) 不适用(非窗口模型) \(O(1)\) 中等 Guava RateLimiter、Envoy local rate limit、tc tbf
漏桶 / GCRA 否(强制整形为恒定速率) 不适用 \(O(1)\) 中等 Nginx limit_req、Stripe redis-cell

选择哪种算法取决于对突发的容忍度:如果下游是有状态、扩容慢的资源(比如数据库连接池),更适合用漏桶把流量整形为恒定速率;如果下游是无状态、能快速消化突发的资源(比如可以短暂堆积到内存队列的异步任务),令牌桶的突发容忍能提升资源利用率。窗口类算法的优势是实现简单、语义直观(“最近 N 秒内最多 M 次”),是绝大多数对外 API 限流公告里最容易向用户解释的模型。

一套系统里通常不止用一种算法

实际系统很少只用一种限流算法,而是在不同层次按各自的目标搭配不同算法。一个典型组合:边缘网关用令牌桶保留一定的突发容忍度(避免正常的用户交互模式,比如页面加载时并发发出的多个请求,被过度限制);面向用户的配额展示(“你本月还剩多少次调用”)用滑动窗口计数器,因为窗口语义(“每月/每天 N 次”)对用户更直观,且和计费周期天然对齐;数据库或其他有状态资源前面用漏桶或 GCRA 做最后一道整形,保证无论前面几层放行了多少突发,落到这个资源上的实际请求速率都是恒定的。这三层用的是不同算法,服务的目标也不同:突发容忍、用户可理解的配额语义、资源保护的恒定速率——不存在一种算法能同时把三个目标都做到最优。

参数不是拍脑袋定的:一个推导示例

限流参数(速率 \(r\)、突发 \(B\)、窗口 \(N\))最终要落到具体数字上,推导思路应该从下游资源的硬约束反推,而不是先拍一个”看起来合理”的数字。以下计算过程和数字均为示意推导,不是任何真实压测结果:

  1. 确定下游硬约束:假设商品详情接口背后的数据库连接池配置为 \(P = 100\) 个连接,单次查询平均耗时 \(t_q = 20\text{ms}\)
  2. 反推数据库能承受的稳态吞吐:按照利特尔法则(第四节详细展开),稳态下 \(P \approx \lambda \cdot t_q\),即 \(\lambda \leq P / t_q = 100 / 0.02 = 5000\) 次/秒。这是数据库这一层单纯从连接池角度能承受的理论上限,不代表整体系统的安全水位。
  3. 留出安全余量:生产环境通常不会把限流阈值设到理论上限,需要为其他并发消费者(后台任务、报表查询)和抖动留出余量,实践中常见做法是按理论上限的 50% 到 70% 折算,例如取 \(r = 3000\) 次/秒作为该接口对数据库查询路径的限流速率。
  4. 突发容量 \(B\) 的选择要看请求到达模式:如果请求主要来自用户点击这类相对均匀到达的行为,\(B\) 可以设得较小(比如 \(B = r \times 0.1\),允许 100ms 的突发缓冲);如果明确知道存在”缓存失效瞬间集中到达”的模式,需要单独识别热点 key 并对其做更严格的突发限制,而不是依赖全局令牌桶——全局参数无法反映单个热点 key 造成的局部尖峰。

这个推导过程的关键不在于最终数字,而在于说明限流参数应该锚定在某个具体的下游资源约束上(连接池、线程池、CPU 预算),并且随着这个约束的变化(比如连接池扩容)重新计算,而不是设一次就长期不变。

三、部署分层:从客户端到网关到服务端

限流不应该只部署在一个层次。一个成熟的过载保护体系通常在三个层次都设防,越靠近请求源头的限流,节省的资源越多,但控制精度和全局视角也越弱。

flowchart LR
    C[Client] -->|self-throttle| G[API Gateway]
    G -->|coarse-grained QPS| S1[Service Instance A]
    G -->|coarse-grained QPS| S2[Service Instance B]
    S1 -->|fine-grained per-resource| R1[(Downstream Resource)]
    S2 -->|fine-grained per-resource| R1

客户端限流:Google SRE 的自适应节流

最省资源的限流发生在请求还没离开客户端之前。如果客户端能提前知道服务端大概率会拒绝这次请求,就没必要发起一次网络调用再被拒绝——这次调用本身也要消耗服务端的连接和处理时间。

Google 的 SRE 手册在《Handling Overload》一章中给出了一个客户端自适应节流(Client-Side Adaptive Throttling)的具体公式。客户端在滑动时间窗口(例如最近两分钟)内维护两个计数器:requests(客户端发出的请求总数,含被拒绝的)和 accepts(后端实际接受的请求数)。客户端本地按以下概率主动拒绝新请求,而不发往后端:

\[P_{reject} = \max\left(0,\ \frac{requests - K \cdot accepts}{requests + 1}\right)\]

其中 \(K\) 是一个可调的乘数(书中示例取 \(K = 2\))。这个公式的直觉是:如果最近的接受率很高,\(K \cdot accepts\) 远大于 \(requests\)\(P_{reject}\) 趋近于 0,客户端几乎不自我节流;如果后端持续拒绝请求,\(accepts\) 增长停滞而 \(requests\) 因为客户端还在不断尝试而持续增长,\(P_{reject}\) 会逐渐升高,客户端在本地就拦掉大部分请求,不再把已经过载的后端进一步压垮。这本质上是把”服务端已经在拒绝我”这个信号,通过客户端自身的历史统计推断出来,而不依赖服务端显式下发限流规则。

这个机制要求客户端和服务端配合:服务端需要能区分”正常拒绝”(客户端应该按公式退避)和”永久性错误”(客户端不该重试),否则客户端会对不该抑制的请求也进行自我节流。

用具体数字走一遍这个公式会更直观。假设某个两分钟窗口内 requests = 1000accepts = 400(后端已经在大量拒绝),取 \(K = 2\)

\[P_{reject} = \max\left(0,\ \frac{1000 - 2 \times 400}{1001}\right) = \frac{200}{1001} \approx 0.20\]

客户端会在本地以约 20% 的概率直接拒绝新请求,不再产生网络调用。如果后端拒绝进一步恶化,accepts 跌到 200,requests 因为客户端还在继续尝试而涨到 1200:

\[P_{reject} = \max\left(0,\ \frac{1200 - 2 \times 200}{1201}\right) = \frac{800}{1201} \approx 0.67\]

拒绝概率会自动跟着后端的真实接受率同步收紧,不需要任何一方显式下发新的限流数值——这正是 Google SRE Book 强调”该决策完全基于客户端本地信息、没有额外依赖和延迟”的原因。反过来,一旦后端恢复、accepts 回升,\(P_{reject}\) 也会随之自动回落,客户端不需要人工干预就能恢复正常请求速率。

API 网关层:粗粒度、全局视角

API 网关是最常见的限流落点,原因是它天然处于所有入站流量的汇聚点,适合做跨实例的全局限流决策。

Envoy 的本地限流过滤器(Local Rate Limit Filter)在每个 Envoy 实例内部使用令牌桶,参数直接对应速率与突发:

# Envoy local_ratelimit filter 配置片段
http_filters:
  - name: envoy.filters.http.local_ratelimit
    typed_config:
      "@type": type.googleapis.com/envoy.extensions.filters.http.local_ratelimit.v3.LocalRateLimit
      stat_prefix: http_local_rate_limiter
      token_bucket:
        max_tokens: 100        # 桶容量,对应突发上限
        tokens_per_fill: 100   # 每次补充的令牌数
        fill_interval: 1s      # 补充周期,对应速率 100 req/s
      filter_enabled:
        runtime_key: local_rate_limit_enabled
        default_value:
          numerator: 100
          denominator: HUNDRED
      response_headers_to_add:
        - append: false
          header:
            key: x-local-rate-limit
            value: "true"

本地限流的问题是”本地”二字——每个 Envoy 实例各自维护自己的令牌桶,如果后面有 10 个 Envoy 实例,实际放行的总速率是配置值的 10 倍。要做跨实例的精确全局限流,需要引入 Envoy 的全局限流服务(envoyproxy/ratelimit),由一个独立的限流服务后端(通常以 Redis 为存储)为所有网关实例提供统一计数。这个矛盾正是第七节要讨论的分布式限流一致性问题的直接来源。

服务端限流:细粒度、按资源维度

网关层限流通常是粗粒度的(按 API 路径或客户端标识),无法感知服务内部不同资源的差异化承载能力。服务端限流需要按资源(数据库、线程池、特定接口)细分。

Resilience4j 的 RateLimiter 模块采用的是”刷新周期内限定通行数”模型:在每个 limitRefreshPeriod 周期开始时把可用许可数重置为 limitForPeriod,请求消耗许可,许可耗尽后新请求要么在 timeoutDuration 内排队等待下一周期刷新,要么直接被拒绝。这个模型本质上更接近一个带等待队列的固定窗口,而不是严格的令牌桶(令牌桶是连续补充,Resilience4j 是周期性整体刷新):

RateLimiterConfig config = RateLimiterConfig.custom()
    .limitForPeriod(50)                             // 每个周期允许 50 个请求
    .limitRefreshPeriod(Duration.ofSeconds(1))       // 周期长度 1 秒
    .timeoutDuration(Duration.ofMillis(100))         // 许可不足时最多等待 100ms
    .build();

RateLimiter rateLimiter = RateLimiter.of("productDetail", config);

Supplier<ProductDetail> decorated = RateLimiter
    .decorateSupplier(rateLimiter, () -> productService.getDetail(productId));

Try<ProductDetail> result = Try.ofSupplier(decorated)
    .recover(RequestNotPermitted.class, e -> ProductDetail.cachedFallback(productId));

Sentinel 的流量控制(FlowSlot)支持三种控制行为,覆盖了限流算法谱系里的多种模型:直接拒绝(超过阈值即抛 FlowException,对应固定/滑动窗口计数)、匀速排队(RateLimiter 模式,按漏桶思想把请求间隔拉平,用于削平突发)、预热(WarmUp 模式,冷启动阶段阈值逐步爬升到设定值,借鉴了 Guava RateLimiter 的平滑预热算法,避免服务刚启动、缓存未预热时突然承受满负荷流量):

FlowRule rule = new FlowRule();
rule.setResource("productDetailService");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);
rule.setCount(200);
rule.setControlBehavior(RuleConstant.CONTROL_BEHAVIOR_WARM_UP);
rule.setWarmUpPeriodSec(10); // 10 秒内阈值从低位爬升到 200 QPS

FlowRuleManager.loadRules(Collections.singletonList(rule));

服务间调用:Sidecar 层限流

微服务架构下还存在一个容易被忽视的层次:服务与服务之间的内部调用,这些流量既不经过对外的 API 网关,也未必被单个服务的框架层限流规则覆盖。采用服务网格架构时,Sidecar 代理(如 Istio 中的 Envoy)天然处于每个服务实例的入站流量路径上,是落地”服务端限流”的另一个可选位置——规则可以跟随服务网格的控制面统一下发,不需要在每个服务的业务代码里重复实现限流逻辑,这也是把限流能力从应用层下沉到基础设施层的常见动机。这一层的限流粒度介于网关(面向外部流量)和进程内限流(面向具体资源)之间,适合处理”服务 A 调用服务 B 的内部流量应该如何配额”这类问题。

分层限流的设计原则

三层限流不是重复劳动,而是分工:

任何一层单独存在都不完备:只有网关限流,服务内部不同接口之间无法差异化保护;只有服务端限流,请求已经消耗了网关和网络资源才被拒绝,浪费更大。

四、过载保护:从限流到负载削减

限流回答的是”允许多快的速率进来”,负载削减(Load Shedding)回答的是更进一步的问题:“当限流已经生效、请求仍然超过承载能力时,该丢弃谁”。这两者经常被合并讨论,但侧重点不同:限流是入口速率控制,负载削减是资源耗尽边缘的取舍策略。

并发限制 vs QPS 限制

QPS 限制隐含一个假设:单个请求消耗的资源和处理时间是稳定的。这个假设在过载场景下往往不成立——过载本身会导致下游变慢(比如数据库连接排队),单个请求的处理时间从 10ms 恶化到 500ms,此时即便 QPS 没有变化,系统中同时”在途”(in-flight)的请求数也会显著增加。

用排队论中的利特尔法则(Little’s Law)可以精确描述这个关系(Little, “A Proof for the Queuing Formula: L = λW,” Operations Research, 1961):

\[L = \lambda \cdot R\]

其中 \(L\) 是系统内平均请求数(并发数),\(\lambda\) 是请求到达速率(QPS),\(R\) 是请求在系统内的平均停留时间(延迟)。这个公式说明:如果 \(\lambda\) 固定但 \(R\) 因为过载而升高,\(L\) 会同比例升高。一个按 QPS = 500 设置的限流器,在正常延迟 20ms 时对应的平均并发数只有 \(500 \times 0.02 = 10\);一旦下游变慢到 500ms,同样 500 QPS 对应的平均并发数暴涨到 \(500 \times 0.5 = 250\)——线程池、连接池等以并发数为约束的资源可能早就被打满了,而 QPS 限流器完全没有触发,因为它监控的维度(速率)和真正耗尽的资源(并发中的请求数)不是同一个量纲。

这正是 Netflix 在其 concurrency-limits 开源库中放弃固定 QPS、转而直接限制并发请求数的动机:并发数是和线程池、连接池容量同量纲的指标,能更直接地反映资源耗尽的风险,而不需要为每个下游单独试算”多少 QPS 会打满连接池”。

并发限制的实现比 QPS 限流更简单,本质上是一个有上限的计数器加等待/拒绝策略,可以用信号量直接表达:

public class ConcurrencyLimiter {

    private final Semaphore semaphore;
    private final long maxWaitMillis;

    public ConcurrencyLimiter(int maxConcurrency, long maxWaitMillis) {
        this.semaphore = new Semaphore(maxConcurrency, true); // 公平模式,避免饥饿
        this.maxWaitMillis = maxWaitMillis;
    }

    public <T> T execute(Callable<T> action, Supplier<T> onRejected) throws Exception {
        boolean acquired = semaphore.tryAcquire(maxWaitMillis, TimeUnit.MILLISECONDS);
        if (!acquired) {
            return onRejected.get(); // 并发已达上限,快速拒绝
        }
        try {
            return action.call();
        } finally {
            semaphore.release();
        }
    }
}

并发上限的取值可以直接由利特尔法则反推:如果目标 P99 延迟是 \(R = 200\text{ms}\),预期峰值到达速率 \(\lambda = 800\) 次/秒,期望的并发上限约为 \(L \approx \lambda \cdot R = 800 \times 0.2 = 160\)(示意计算,非任何真实压测数值)。这个数字应该作为线程池或信号量并发上限的参考起点,并配合超时机制——如果没有超时兜底,一旦真实延迟远超预期,公式两边同时失真,并发上限形同虚设。

优先级分桶

不是所有请求都同等重要。当容量不足以处理全部请求时,应该优先保住关键路径。Google SRE 手册在讨论过载处理时引入了”客户端任务关键性”(Criticality)的分级思路,把请求划分为多个优先级层次(如 CRITICAL_PLUS、CRITICAL、SHEDDABLE_PLUS、SHEDDABLE),过载时优先丢弃最低优先级的流量,保证关键请求的资源配额。

工程上落地这个思路,通常需要:

  1. 在请求入口(网关或服务框架)打上优先级标签,可以来自请求头、API 路径、调用方身份。
  2. 为不同优先级维护独立的资源配额(例如独立的线程池分区,或者独立的令牌桶)。
  3. 过载判定基于全局资源水位(如 CPU、平均 RT),而不是单纯某个优先级桶的局部计数。

哪些请求属于”关键”,本质上是一个业务决策,应该和该接口对应的 SLO 挂钩——如果一个接口本身不在 SLO 承诺范围内,过载时优先丢弃它是合理的;反之核心交易路径即使牺牲非核心功能也要保住,这一点在SLO 工程里讨论错误预算分配时也会用到同样的优先级划分逻辑。

Google Cloud 的客户可靠性工程(Customer Reliability Engineering, CRE)团队在 2016 年的一篇工程博客里记录过一次真实的按优先级负载削减案例:一个服务给请求打上了从”关键用户交互”到”非关键后台任务”(后台图片上传)的优先级标签,超过预设容量时按优先级从低到高自动丢弃。上线两天后,一次客户端发布意外重置了”哪些数据已经上传”的本地记录,导致全部客户端同时尝试重新上传已经传过的数据,形成远超预期的流量冲击。负载削减系统按照既定的优先级丢弃了近一半的后台上传请求,前台交互流量没有受到影响,几个小时后追加了足够的容量,客户端补传完数据后系统恢复正常(Google Cloud Blog, “Using load shedding to survive a success disaster — CRE life lessons”, 2016)。这个案例的触发原因是一次客户端缺陷而不是自然增长的爆款流量,但后果和本文开头的场景结构相同——请求本身合法、下游没有故障,纯粹是入站流量超过了容量,验证了同一个结论:负载削减的目标不是”尽量少丢请求”,而是丢弃的顺序要和业务价值排序保持一致——先丢可以晚点重试的后台任务,保住用户正在等待的交互请求,好过所有流量因为没有优先级区分而一起被拖进无响应状态。

# 过载时按优先级从低到高依次丢弃,直到全局资源水位回落到安全线
PRIORITY_ORDER = ["SHEDDABLE", "SHEDDABLE_PLUS", "CRITICAL", "CRITICAL_PLUS"]

def admit(request, system_load: float, safe_threshold: float) -> bool:
    if system_load < safe_threshold:
        return True  # 系统水位正常,全部放行

    # 系统水位超标:按优先级顺序,从最低优先级开始拒绝
    shed_from = PRIORITY_ORDER.index(current_shed_boundary(system_load))
    request_rank = PRIORITY_ORDER.index(request.priority)
    return request_rank > shed_from

这里的关键不是代码本身(真实实现要处理并发和滞回逻辑,避免在临界水位反复抖动),而是判定依据必须是全局资源水位,而不是某个优先级桶自己的局部计数——否则会出现”低优先级桶还没打满、但系统整体已经过载”的判断错误。

队列管理:FIFO、丢弃头部与延迟感知丢弃

过载时的队列策略也会显著影响用户感知到的可用性。最朴素的做法是 FIFO(先进先出)加固定容量,队列满时拒绝新请求(丢弃尾部,Drop-Tail)。这个策略在过载持续较久时会产生一个反直觉的问题:队列里排在前面的请求已经等待了很久,即使最终被处理,客户端很可能早已超时并断开连接或发起了重试——服务端在这些”已经没人等待结果”的请求上浪费了处理能力,真正紧迫、还有客户端在等待的新请求反而被压在队尾迟迟得不到处理。

这个问题在网络拥塞控制领域早有对应的研究:传统的丢弃尾部(Drop-Tail)队列管理策略在拥塞持续时会导致全局同步(多个流同时被丢包、同时退避)和队列常驻满载(bufferbloat)。Floyd 和 Jacobson 提出的随机早期检测(RED, Random Early Detection)在队列尚未打满前就按概率随机丢包,避免队列长期饱和(Floyd & Jacobson, “Random Early Detection Gateways for Congestion Avoidance,” IEEE/ACM ToN, 1993)。后续的 CoDel(Controlled Delay)进一步把丢弃依据从”队列长度”改为”数据包在队列中的排队时延”,只要排队时延持续超过目标值(如 5ms)就开始丢包,直接控制的是延迟而不是队列占用(Nichols & Jacobson, “Controlling Queue Delay,” ACM Queue, 2012)。

这两个思路可以直接类比到应用层的过载队列管理:与其用固定队列长度做准入判断(等价于 Drop-Tail),不如用”请求已经排队多久”作为丢弃依据——如果一个请求在队列里已经等待超过客户端大概率已经超时放弃的时长(例如超过客户端的超时阈值),继续处理它已经没有意义,应该直接丢弃并腾出资源处理新请求。部分实现选择丢弃队列头部(Drop-Oldest)而不是尾部:新请求代表当前仍在等待的客户端,优先保住新请求的响应几率,比保住已经大概率被放弃的旧请求更有价值。这也呼应了 SEDA 架构(Welsh, Culler, Brewer, “SEDA: An Architecture for Well-Conditioned, Scalable Internet Services,” SOSP 2001)中”每个阶段独立做准入控制和自适应负载削减”的设计思想——过载保护不应该是一个全局开关,而应该分布在每个可能成为瓶颈的处理阶段。

HTTP 429 与 503:语义差异与重试交互

限流和过载保护对外暴露的错误状态码,直接影响客户端的行为,选错状态码会制造新的问题。

429 Too Many Requests(RFC 6585 定义)语义明确:客户端在给定时间内发送的请求数超过了限制,这是一个客户端应当”减速”的信号。规范建议携带 Retry-After 头,告知客户端应该等待多久再重试。

503 Service Unavailable(RFC 9110,HTTP 语义规范)语义是服务端暂时无法处理请求,可能是过载、维护或依赖不可用,同样可以携带 Retry-After(RFC 9110 中 Retry-After 是通用响应头,不限于 503)。503 没有明确说明”是谁的错”,客户端不清楚是应该立刻重试还是应该退避。

两者混用会带来切实的运维问题:

正确的实践是:限流场景明确返回 429 并携带 Retry-After;系统性过载、需要临时拒绝所有非关键请求的场景返回 503 并携带 Retry-After;两者都应该在响应体里说明拒绝原因,便于客户端和排障人员区分是”你超限了”还是”我们扛不住了”。

与重试预算的协同:不要让限流反过来放大流量

上一篇分析过重试风暴:调用链每一层都重试会带来指数级放大。限流触发的拒绝如果被客户端当作普通的”暂时性故障”重试,会构成一种特殊的正反馈——服务因为过载开始限流,客户端立刻重试,重试请求进一步推高本已过载的入口流量。SRE Book 给出的具体缓解机制是双重重试预算:每个请求最多重试 3 次(含首次尝试),超过 3 次直接把失败上抛,不再重试;同时每个客户端维护一个全局重试预算,只有当”重试请求数 / 总请求数”的比例低于 10% 时才允许发起新的重试。书中给出的估算是:仅靠”每请求最多 3 次”的限制,在大范围过载的最坏情况下,总请求量会膨胀到接近原始请求量的 3 倍;叠加”全局重试比例不超过 10%“的预算后,膨胀比例被压低到约 1.1 倍。这个数字说明单一维度的重试上限(只看单个请求的重试次数)不足以控制整体放大效应,需要再叠加一个客户端全局维度的预算才能把放大压到可接受范围——这和上一篇”只在调用链最外层重试”的建议是同一个防止放大的原则在不同环节的具体化。

这个机制也回答了一个容易被忽视的问题:当调用链有多层时(比如网关拒绝了服务 A,服务 A 又是被服务 B 调用的),应该只有紧邻拒绝方的那一层(A)重试,B 不应该对 A 转发过来的”已经重试过的失败”再次重试,否则会出现多层重试的组合放大。做法是让被拒绝的响应明确携带”过载,请勿重试”(而不是笼统的”过载,可重试”)的语义,中间层在转发失败时把这个”不可重试”标记一并传递下去,而不是让每一层都独立判断要不要重试。

HTTP/1.1 429 Too Many Requests
Retry-After: 2
Content-Type: application/json

{"error": "rate_limited", "message": "超过接口限流阈值,请 2 秒后重试", "retryable": true}
HTTP/1.1 503 Service Unavailable
Retry-After: 5
Content-Type: application/json

{"error": "overloaded", "message": "系统当前负载过高,非核心请求已被临时拒绝", "retryable": true}

两者都不应该是下面这种写法——用 200 状态码包一层业务错误码,或者不带 Retry-After 的裸 500,前者会让不解析响应体的中间件(如通用监控探针)误判成功,后者会让客户端退化为自己的默认重试策略,完全失去服务端对退避节奏的控制权。

五、自适应过载控制:固定阈值之外的选择

前面讨论的限流算法大多需要预先设定一个具体阈值(QPS 上限、并发数上限)。这个阈值从哪来?通常来自压测或经验估算,但生产环境的实际承载能力会随着机器规格变化、GC 停顿、同机部署的”吵闹邻居”、依赖服务的抖动而波动——一个压测得出的静态阈值,在环境变化后可能过于保守(浪费容量)或过于激进(形同虚设)。这催生了自适应过载控制的思路:不预设固定阈值,而是持续观测系统自身的运行状态,动态计算当前能够承受的流量水位。

Sentinel 系统自适应保护

Sentinel 的系统自适应保护(System Adaptive Protection)不针对单个接口设置阈值,而是从整体维度监控系统的 Load、CPU 使用率、总体平均响应时间、入口 QPS、并发线程数等指标,让入口流量和系统负载动态达到平衡,目标是让系统尽量跑在接近最大吞吐量的水位,同时不至于因为过载而整体退化(Sentinel 官方文档,“系统自适应保护”)。其中一种判定方式借鉴了 TCP BBR 拥塞控制算法的思路:BBR 不依赖丢包信号,而是持续估计瓶颈带宽和最小往返时延,寻找吞吐量与延迟的平衡点;Sentinel 的思路与此类似,通过持续观测系统的平均响应时间和当前并发线程数,估算系统在什么并发水位下会开始出现响应时间恶化的拐点,并以此动态调整允许通过的流量,而不是依赖一个写死的 QPS 阈值。

这种方式的优点是不需要提前压测出精确阈值,系统能力发生变化(比如扩容、降配、依赖抖动)时限流水位也会跟着调整。代价是行为不如固定阈值直观——运维人员在排查”为什么这次请求被限流了”时,面对的不是一个简单的数字比较,而是多个指标的综合判定,可观测性和可解释性要求更高。

Netflix 自适应并发限制

Netflix 的 concurrency-limits 库走的是另一条路径:不监控 CPU、Load 这些系统级指标,而是只根据每次请求的响应时间来动态调整”允许的最大并发数”这一个参数。核心算法(Gradient 系列)持续维护一个响应时间的滑动最小值作为基线(代表系统未拥塞时的正常处理时延),把最近一段时间的响应时间与这个基线做比值,得到一个”梯度”(gradient):

这是一种加性增乘性减(AIMD)结构的控制律,思路上和 TCP Vegas 这类基于延迟(而非丢包)信号做拥塞窗口调节的算法一脉相承——用延迟的变化趋势而不是显式的失败信号来判断是否接近容量上限(Netflix Technology Blog,“Performance Under Load”,2018)。用伪代码还原核心控制律:

输入:每次请求的响应时间样本 rtt
维护:min_rtt(滑动窗口内的最小 RTT,代表无拥塞基线)、limit(当前并发上限)

每次请求完成后:
    min_rtt = min(min_rtt, rtt)          # 持续更新基线(基线会缓慢遗忘,避免长期锁死)
    gradient = min_rtt / rtt             # rtt 越接近基线,gradient 越接近 1
    gradient = clamp(gradient, 0.5, 1.0) # 限制单次调整幅度,避免震荡

    if in_flight_requests >= limit * gradient:
        limit = limit * gradient + queue_size_headroom   # 乘性收紧
    else:
        limit = limit + 1                                 # 加性增长

    limit = clamp(limit, min_limit, max_limit)

这个控制律不需要知道任何硬件规格或压测数据,完全依赖运行时观测到的延迟变化自我调节,这是它相对固定阈值最大的工程优势,也是下面这场争论的起点。

一个悬而未决的争论:固定 QPS vs 自适应并发

固定阈值限流和自适应过载控制在工程实践中始终存在分歧,这个分歧和网络拥塞控制领域”基于丢包” vs “基于延迟”的长期争论结构上是同一个问题。

支持固定阈值的一方认为:固定阈值行为确定、可预测、易于测试和复盘,压测一次就能得到一个可以长期沿用的数字,故障排查时”是否超过阈值”是一个二元判断,不涉及多指标综合评估。缺点是需要持续人工维护,容量变化后阈值容易过时,要么浪费容量要么形同虚设。

支持自适应方案的一方认为:系统能持续跟踪自身真实容量,不需要为每次容量变更重新压测,能应对突发的、不可预测的容量退化(比如某台机器 GC 停顿)。但代价是控制逻辑本身引入了新的复杂度和潜在的不稳定性——BBR 在真实网络中投入使用后,后续研究发现它在某些场景下对使用传统丢包型拥塞控制(如 CUBIC)的并发流存在带宽抢占,公平性不如预期(Hock, Bless & Zitterbart, “Experimental Evaluation of BBR Congestion Control,” IEEE ICNP, 2017)。这个教训提示我们:延迟驱动的自适应限流在生产中同样可能出现类似的振荡或不公平现象,比如多个服务实例各自独立自适应,缺乏协调时可能出现相互抢占资源配额的情况;这方面目前还没有像拥塞控制领域那样系统的实测评估文献,大多数落地经验来自各家公司的内部实践,尚未形成公开、可复现的对比研究。

工程上的折中做法是把两者叠加:用固定阈值兜底一个”绝对不能超过”的硬上限(防止自适应算法在极端场景下失控),在硬上限内部再叠加自适应机制去逼近当前真实容量。这也是 Sentinel 同时提供固定 QPS 规则和系统自适应规则、且两者可以叠加生效的原因。

六、配额与多租户公平性

当一个服务同时为多个租户(客户、团队、API Key)提供服务时,限流还要解决一个额外问题:如何避免一个租户的突发流量耗尽全部配额,挤压其他租户的正常请求。这就是”吵闹邻居”(Noisy Neighbor)问题在流量层面的表现。

最直接的方案是给每个租户分配独立的令牌桶或计数器,租户之间的配额互不影响。这本质上是把前面讨论的单一限流器复制 \(N\) 份(\(N\) 为租户数),但会带来资源浪费:如果多数租户长期用不满自己的配额,严格隔离会导致总吞吐量低于系统实际能承受的水平。

更精细的方案借鉴了网络调度领域的公平队列(Fair Queueing)思想:Demers、Keshav 和 Shenker 在 1989 年提出的公平队列算法证明了如何在多个数据流共享一条链路时,用近似轮询的调度方式逼近理想的最大最小公平(Max-Min Fairness)分配,让每个流至少获得 \(\lceil 1/N \rceil\) 的带宽份额,同时允许暂时没有流量的连接把空闲份额让给其他繁忙连接(Demers, Keshav & Shenker, “Analysis and Simulation of a Fair Queueing Algorithm,” ACM SIGCOMM CCR, 1989)。落到限流场景,对应的做法是设置”每租户保底配额 + 全局共享池”的两层结构:每个租户有一个较小的独占配额保证最低可用性,超出部分从全局共享池按加权公平的方式分配,在系统整体没有打满时允许个别租户临时超过自己的保底额度。

公有云和 SaaS 服务商在对外 API 上普遍采用这种”保底 + 共享”的配额模型:按订阅套餐(Usage Plan)为每个 API Key 设定独立的速率和突发上限,同时在基础设施层面保留一个更高的全局硬上限,防止单一租户即便在自己配额范围内的合法请求也把共享基础设施打垮。这类做法在各家云厂商和 API 服务商的公开文档中都能找到对应描述,具体数值因产品而异,不构成本文讨论的重点。

Linux 的分层令牌桶(Hierarchical Token Bucket, tc HTB)是这一思想在网络流量控制层面的成熟实现,思路可以直接迁移到应用层多租户限流:为每个租户定义 rate(保底速率)和 ceil(允许借用时的速率上限),空闲租户的富余带宽被按权重分配给繁忙租户,而不是简单浪费掉:

# tc HTB 分层限流示例:两个租户共享 100mbit 出口带宽
# tenant-a 保底 30mbit,最多可借用到 80mbit;tenant-b 保底 20mbit,最多可借用到 80mbit
tc qdisc add dev eth0 root handle 1: htb default 30
tc class add dev eth0 parent 1: classid 1:1 htb rate 100mbit
tc class add dev eth0 parent 1:1 classid 1:10 htb rate 30mbit ceil 80mbit
tc class add dev eth0 parent 1:1 classid 1:20 htb rate 20mbit ceil 80mbit

应用层限流器若要实现同样的”保底 + 借用”语义,通常需要维护每租户的令牌桶(对应保底 rate)叠加一个全局共享令牌桶(对应可借用的富余部分),请求先消耗自己的保底配额,配额耗尽后再尝试从共享池借用,共享池耗尽时才真正拒绝。

七、失败模式:限流本身也会引发故障

限流和过载保护是保护机制,但配置或实现不当,保护机制本身会成为新故障的源头。

重试放大:错误的状态码或错误的重试策略

如前所述,如果限流拒绝返回了错误的状态码(比如把 429 包装成了触发通用重试逻辑的 500),或者没有携带 Retry-After 导致客户端使用固定短间隔重试,都会让”拒绝一部分请求以保护系统”的措施反而制造出新的请求洪峰。这和上一篇讨论的多层重试放大是同一类问题的不同触发路径:那里是下游故障通过重试逐层放大,这里是限流拒绝通过不当的客户端重试策略被放大。防御手段也是同源的——限流拒绝的错误必须被明确标记为”不应无脑重试”或”应按 Retry-After 退避”,客户端的重试逻辑必须能区分这类错误和真正的瞬时故障。

以下数字为示意推导,用来说明放大的量级结构,不是任何真实事故的观测值:假设某一限流窗口内有 \(M\) 个客户端被拒绝,如果客户端库统一采用固定 3 秒后重试且没有抖动,3 秒后这 \(M\) 个请求会在同一个窗口内几乎同时重新到达。如果限流阈值本身就是这批客户端数量级的瓶颈所在(即 \(M\) 已经接近或超过限流阈值),下一个窗口大概率再次触发大规模拒绝,形成周期性的”拒绝—同步重试—再拒绝”振荡,直到某次窗口内请求量因为客户端逐渐放弃或退避而自然衰减。这和网络中大量丢包后 TCP 连接同步进入慢启动、又同步恢复发送造成的锯齿状流量模式,结构上是同一种”同步化”现象。

同步重试与雷群效应的再现

固定窗口限流在窗口边界重置的时刻,如果大量请求恰好都在上一个窗口被拒绝、又都设置了相同的固定重试间隔,会在窗口重置的瞬间同步涌入,形成周期性的流量脉冲。这本质上和上一篇讨论的固定间隔重试雷群问题完全相同,解法也一致:拒绝响应中带的 Retry-After 应该加入随机抖动而不是一个精确的固定值,客户端重试也应该遵循带抖动的退避策略而不是严格按服务端返回的秒数立即重试。

阈值设置过高或过低

限流阈值不是设一次就一劳永逸的参数。设得过高,限流形同虚设,起不到保护作用,系统在真正的流量高峰下依然会被打垮;设得过低,会在正常流量下持续拒绝合法请求,浪费本可以处理的容量,还会因为频繁触发限流而干扰真实过载信号的判断(运维人员很难从”日常限流噪声”里分辨出”真正的容量告急”)。这也是第五节里讨论固定阈值 vs 自适应方案分歧的现实动机——阈值维护本身是有持续成本的工程工作,不是配置一次就能忽略的常量。

分布式限流的一致性代价

单机限流器只能看到自己进程内的流量,一旦服务水平扩展到多个实例,就出现了分布式限流的一致性问题:如果每个实例各自独立限流,配置的阈值需要除以实例数才能得到全局限额,而实例数在弹性伸缩下是动态变化的,静态除法很快就会失配;如果用集中式存储(如 Redis)做全局计数,又引入了额外的网络往返和潜在的单点瓶颈,且并发环境下”读取计数、判断、递增”这三步操作如果不是原子的,会出现竞态导致计数超发(需要用 Redis 的原子递增或 Lua 脚本保证原子性)。

sequenceDiagram
    participant Client
    participant LB as Load Balancer
    participant R1 as Replica 1 (local bucket 100qps)
    participant R2 as Replica 2 (local bucket 100qps)
    participant R3 as Replica 3 (local bucket 100qps)

    Note over R1,R3: intended global limit = 150qps
    Client->>LB: burst ~280 req/s
    LB->>R1: ~93 req/s
    LB->>R2: ~93 req/s
    LB->>R3: ~93 req/s
    Note over R1,R3: each replica sees traffic below its own 100qps threshold
    R1-->>Client: 200 OK (all accepted)
    R2-->>Client: 200 OK (all accepted)
    R3-->>Client: 200 OK (all accepted)
    Note over Client,R3: effective ceiling is 3x100=300qps, not the intended 150qps

这张时序图对应一个具体反例:3 个副本各自维护本地令牌桶,限额都是 100 QPS,运维的本意是把全局限额控制在 150 QPS(留出安全余量)。如果负载均衡完全均匀,全局有效限额是 \(3 \times 100 = 300\) QPS,是预期值的 2 倍——每个副本各自的视角里流量都没有超限,但全局层面早已超出预期上限的一倍。更麻烦的是这个偏差会随自动扩缩容动态变化:扩容后副本数增加,全局有效限额跟着水涨船高;缩容后又跟着下降。运维如果假设”全局限额是一个写死的数字”,这个假设从部署拓扑变化的那一刻起就不再成立。这正是本地限流与全局限流权衡的现实后果:本地限流延迟最低、无额外依赖,但精度随副本数漂移;要精确控制全局限额,必须引入集中式存储,代价是上一段提到的网络往返和竞态问题。

这个问题在学术界有过专门研究。Raghavan 等人在 SIGCOMM 2007 的论文中系统分析了分布式限流的代价与精度权衡,提出并比较了三种算法(Raghavan, Vishwanath, Ramabhadran, Yocum & Snoeren, “Cloud Control with Distributed Rate Limiting,” ACM SIGCOMM, 2007):

算法 核心思想 精度 节点间通信开销 适用场景
全局令牌桶(Global Token Bucket) 各节点周期性同步令牌余量估计,逼近集中式令牌桶的行为 较高,接近集中式 较高,需要频繁同步 对精度要求高、节点数不多的场景
全局随机丢弃(Global Random Drop) 节点根据全局估计的丢弃概率,各自独立做本地随机丢弃决策 中等,统计意义上准确 较低,只需同步聚合统计量 节点数多、允许统计误差的场景
流量比例分享(Flow Proportional Share) 按各节点观测到的历史流量比例,静态或周期性分配限额份额 较低,对突发的流量分布变化不敏感 最低,几乎不需要实时同步 通信成本敏感、流量分布相对稳定的场景

这篇论文的核心结论是:严格的全局精确限流需要节点间频繁通信,通信开销和限流精度之间存在直接的权衡曲线,完全的精确性和完全的去中心化不可兼得。

工程上多数系统选择在两端之间取一个具体点:要么接受集中式存储带来的延迟和单点压力换取精确度(适合限流阈值不高、允许额外一次网络往返的场景),要么接受本地估计的误差换取低延迟和高可用(适合限流阈值很高、单次误差在业务上可以容忍的场景)。没有一种方案能同时做到严格精确、低延迟、强可用——这本身就是论文中已经证明的权衡结构,不是工程实现不够好导致的。

选择集中式存储时,“检查计数、判断、递增”三步必须是原子操作,否则并发场景下会出现超发。用 Redis 实现固定窗口计数器时,正确做法是把判断和自增放进一个 Lua 脚本里原子执行,而不是分成 GETINCR 两次独立调用:

-- KEYS[1]: 限流 key,ARGV[1]: 窗口内限额,ARGV[2]: 窗口长度(秒)
local current = tonumber(redis.call('GET', KEYS[1]) or "0")
if current >= tonumber(ARGV[1]) then
    return 0 -- 拒绝
end
local new_val = redis.call('INCR', KEYS[1])
if new_val == 1 then
    redis.call('EXPIRE', KEYS[1], ARGV[2]) -- 第一次自增时设置过期时间,形成固定窗口
end
return 1 -- 放行

如果拆成两条独立命令(先 GET 判断,再 INCR 自增),两个并发请求可能同时读到”未超限”的旧值,都执行自增,导致窗口内实际放行数超过限额——这是分布式系统里经典的”检查后行动”(check-then-act)竞态问题,用 Redis 的 Lua 脚本或原生 INCR 的原子性可以规避,但代价是所有实例都要经过这一次 Redis 往返,这正是”精度换延迟”权衡的具体体现。

多地域部署会放大这个矛盾:如果限流状态存储在单一区域的 Redis 集群里,跨区域的服务实例每次限流判断都要付出跨地域网络延迟。实践中更常见的做法是每个区域维护独立的本地配额(对应 Raghavan 论文里流量比例分享的思路,按预期流量比例静态切分总配额),只在配额消耗出现显著失衡时才做周期性的跨区域再平衡,而不是每次请求都做强一致的全局判断。

八、工程清单

上线或复查限流与过载保护机制时,可以对照以下检查项:

场景与方案选型表

场景 推荐指标 推荐算法 推荐部署层次 关键权衡
保护单机资源(线程池、数据库连接池) 并发数 并发限制 / 自适应并发 服务端 比 QPS 更贴近真实资源占用,见第四、五节
保护共享下游(第三方接口、慢依赖) QPS 或并发数 令牌桶 出站限流(调用方本地) 需要和熔断器、舱壁协同,避免误判,见第一节
网关统一入口保护,key 基数大(按用户/IP) QPS 令牌桶或固定窗口 网关本地限流 存储成本最低,代价是多副本精度漂移,见第七节
需要跨副本精确配额(计费、SLA 承诺) QPS 固定窗口 + 共享存储 网关全局限流 引入额外网络往返和新的故障点
容量随硬件/依赖状态漂移,人工调参困难 延迟梯度 自适应并发限制(Vegas/Gradient) 服务端 冷启动和非平稳流量下收敛较慢,见第五节争论
进程级兜底,防止未配置规则的接口拖垮整个进程 Load/CPU/RT/QPS 多信号 系统自适应保护 服务端(进程级) 阈值组合缺乏理论最优解,需要压测调参
突发流量后要求最终一致速率(非交互式批处理、跨系统对接) QPS 漏桶 / GCRA 网关或消费端 平滑但拉长排队延迟,不适合延迟敏感的交互请求
多租户共享后端,需要防止吵闹邻居 QPS 或权重份额 分层令牌桶(HTB 思想) 网关或服务端 需要额外维护保底配额与共享池的边界,见第六节

选择哪个方案本质上是在允许多大突发要多高的跨副本精度能承受多少额外延迟和存储成本之间做权衡,没有一种配置能同时把三者都做到最优。

九、开放问题

自适应过载控制在多实例协同下是否需要显式协调,目前还没有定论。 Sentinel 的系统自适应保护和 Netflix 的并发梯度算法都是在单个进程或单个实例内独立运行的,多个实例各自独立自适应时,是否会出现类似网络中多条 BBR 流之间观察到的相互干扰或收敛速度不一致的问题,目前公开的工程实践和学术研究都还比较少。这个问题的现实影响是:如果答案是肯定的,大规模自适应限流部署可能需要引入某种轻量的跨实例协调机制,而不是简单假设”每个实例各管各的就足够了”。感兴趣的读者可以从拥塞控制领域关于多流公平性的实验评估方法入手(如前述 Hock et al. 的 BBR 实验评估范式),类比设计针对应用层自适应限流的跨实例公平性实验。

分布式限流的精度-延迟权衡曲线在现代基础设施下是否有新的解法,仍然开放。 Raghavan 等人 2007 年的工作是在传统数据中心网络假设下做的分析,今天的基础设施多出了低延迟的内存数据库、边车代理(sidecar)、eBPF 等新组件,是否存在比”集中式精确”或”本地近似”更好的第三条路径(比如基于 CRDT 的最终一致计数、或者基于硬件时钟同步的近似同步方案),目前没有看到被广泛验证的公开方案,值得持续关注该领域是否有新的系统论文出现。

并发限制模型在高度复用连接的协议下是否还成立,值得重新审视。 利特尔法则和 Netflix concurrency-limits 的设计前提隐含了”一个逻辑请求对应一份独占资源占用”的假设,但在 HTTP/2、gRPC 等支持连接多路复用的协议下,服务端线程模型也早已从线程池-per-request 演化为协程或事件循环,“并发中的请求数”和”实际消耗的系统资源”之间的映射关系变得更复杂。现有的自适应并发限制库大多仍然沿用传统的并发计数假设,这个假设在多路复用和协程调度普及后是否需要修正,是一个具体但尚未被充分讨论的工程问题。

十、总结

限流保护的是服务自身,熔断保护的是调用方在下游故障时不被拖垮——这是两类方向相反、互补而非互斥的保护机制。理解这个区别,才能在”下游没坏但请求量太大”和”下游坏了但请求量正常”这两种截然不同的故障模式下选对工具。

限流算法的谱系从上世纪 80 年代的电信流量整形演化而来:令牌桶允许突发通过、只约束长期平均速率;漏桶把流量整形为恒定输出;固定窗口简单但有边界突刺;滑动窗口用更高的实现代价换取更精确的统计。没有一种算法是全场景最优,选择取决于下游资源对突发的容忍度。

限流应该分层部署:客户端自适应节流省资源但依赖客户端配合,网关层提供全局粗粒度控制,服务端层保护具体资源。固定阈值和自适应过载控制的分歧,本质上是拥塞控制领域”丢包驱动”和”延迟驱动”之争在应用层的重现,目前工程界的主流做法是二者叠加而非二选一。

负载削减需要处理好优先级分桶、并发与 QPS 的量纲差异、队列的排队时延、以及 429/503 的语义差异——任何一个环节处理不当,保护机制本身都可能成为重试放大和雷群效应的新源头,和上一篇讨论的重试风暴机制殊途同归。分布式限流的一致性代价是被学术界证明过的权衡,不是工程实现的疏漏,选型时需要清楚自己站在精度-延迟曲线的哪一侧。

限流和过载保护做到位之后,系统仍然可能面对超出全部保护能力的极端流量。这时候需要的不再是”拒绝多少请求”的问题,而是”保留下来的请求应该以什么样打折的方式提供服务”——这正是下一篇优雅降级要讨论的内容。


上一篇:【系统架构设计】弹性设计模式:熔断器、舱壁与超时

下一篇:【系统架构设计】优雅降级:如何体面地”坏掉”


参考资料

书籍

论文与技术报告

规范与官方文档

开源项目

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计】弹性设计模式:熔断器、舱壁与超时

重试为何反而让系统雪崩?熔断器的状态机如何设计才不会误判?本文从一次重试风暴引发的雪崩事故出发,系统拆解熔断器(Circuit Breaker)状态机设计与参数调优、舱壁(Bulkhead)资源隔离策略、级联超时预算分配、指数退避与抖动的数学原理,深入分析 Resilience4j 与 Sentinel 的架构差异,讨论装饰器组合顺序的陷阱,最后给出工程案例复盘和弹性模式选型对比。

2026-07-28 · architecture

【系统架构设计】优雅降级:如何体面地\"坏掉\"

系统过载时,被动失效和主动降级看起来结果相似,工程含义却完全不同。本文从 Google SRE 的负载脱落与优雅降级实践出发,给出可执行的降级目录、触发信号、开关编排与恢复顺序,讨论一致性边界、用户提示与常见反模式,并说明它如何与熔断、限流协同工作。

2026-07-29 · architecture

【系统架构设计】多租户架构:SaaS 系统的核心设计难题

从 Silo / Bridge / Pool 隔离谱系出发,拆解共享 schema、schema-per-tenant 与 DB-per-tenant 的代价模型,PostgreSQL RLS 与 Citus 分片如何作安全网,吵闹邻居在连接池与限流层的防御,以及 Shopify Pod 与 Cloudflare Isolate 代表的混合分层与计算隔离,并交代跨租户数仓分析与仍开放的工程问题。

2026-06-19 · architecture / security

【身份与访问控制工程】API Gateway、BFF 与边界认证授权

API Gateway 是 IAM 的边界执行点——OAuth Token 的验证、Rate Limit 的绑定、请求级的授权决策都发生在这里。但网关层的认证授权逻辑应该做多厚?BFF(Backend for Frontend)模式把 Session 管理和 Token 兑换从浏览器移回后端后,网关的角色有什么变化?本文从网关层的认证授权架构出发,拆解三种部署模式及其安全边界。


By .