从 Google BBR 到生产级柔性过载保护:一个服务端限流器的设计与实现
29

写在前面

这篇文章记录了我在设计和实现一个服务端限流器过程中的思考——从最初"为什么令牌桶不够用"的疑问,到最终落地一个四层防护 + 自适应学习的柔性过载保护系统。它借鉴了 Google BBR 拥塞控制的核心思想,融合了 Little's Law、饱和曲线慢启动、突变阻尼、以及 CPU-RT P99 直方图自动学习过载拐点等机制。全部代码约 1100 行 Go(含 93 个确定性测试),已在生产环境运行。

如果你曾经遇到过这些问题,这篇文章可能对你有帮助:

  • 固定 QPS 限流总是要么太保守要么太激进
  • 服务冷启动时被流量打满
  • CPU 阈值设 80% 但不知道为什么是 80%
  • 想让限流器"自己学会"什么时候该限

一、问题:传统限流的两个核心矛盾

大多数服务端限流方案——固定 QPS 令牌桶、漏桶——本质上都是在回答同一个问题:每秒最多放多少请求

这个问题本身就有问题。

矛盾一:容量充足时误限

凌晨 3 点,你的服务只有 10% 的 CPU 使用率,但因为 QPS 限制设了 500,第 501 个请求被拦了。机器在打瞌睡,你的用户在看错误页面。

矛盾二:真正过载时漏限

白天高峰,你的服务 CPU 95%,但因为 QPS 刚好没超 500,所有请求都放进来了。请求在队列里排队,RT 从 5ms 涨到 5s,系统雪崩。

根本原因是:固定阈值无法感知系统的实际负载状态。500 QPS 在凌晨太低了,在高峰可能太高了——取决于当时每个请求的复杂度、下游延迟、GC 频率、以及一百个你无法预知的因素。


二、核心思想:不预设容量,而是实时感知

Google 在 2016 年发表了 BBR(Bottleneck Bandwidth and Round-trip propagation time)拥塞控制算法。BBR 的革命性在于:不通过丢包来推断拥塞,而是通过持续测量带宽和延迟来找到最优工作点

把这个思想搬到服务端限流:

不预设 QPS 上限,而是通过持续观测系统指标(CPU、RT、并发数),动态推算当前系统能承受多少并发。只在系统真正过载时限流,容量充足时绝不误限。

这就是"柔性过载保护"的核心理念。

Little's Law:一条被低估的定理

排队论中有一条优雅的定理——Little's Law

L = λ × W
  • L = 系统中的平均请求数(并发数)
  • λ = 平均到达速率(吞吐量)
  • W = 平均停留时间(响应时间)

翻译成服务端限流的语言:

maxInFlight = maxPASS × minRT × bucketsPerSecond / 1000
变量含义数据来源
maxPASS滑动窗口内单桶最大通过数观测:通过请求计数窗口的峰值
minRT滑动窗口内最小平均 RT (ms)观测:RT 统计窗口的最优值
bucketsPerSecond每秒桶数配置:1000ms / 桶时长

直觉理解:maxPASS × bucketsPerSec ≈ 系统在最优状态下每秒能处理多少请求;× minRT / 1000 ≈ 每个请求在系统里待多久;两者相乘 = 系统在最优状态下同时能容纳多少请求

注意这里用的是 max PASS 和 min RT — 取的是"系统最好的时候能做到什么"。这是一个保守但安全的估算:只要当前并发没超过这个值,系统就在它的舒适区内。

关键设计决策:何时拒绝?

有了 maxInFlight 还不够,还需要决定什么时候启用它

最朴素的做法是:只要 inFlight > maxInFlight 就拒绝。但这有个问题——如果系统 CPU 只有 10%,inFlight 确实大于 maxInFlight(可能是因为窗口内历史统计偏保守),此时拒绝就是误限。

所以设计原则是:

overloaded := CPU >= threshold || Memory >= threshold || Load >= threshold
if overloaded && inFlight > maxInFlight {
    return ErrLimitExceed  // 拒绝
}
// 不过载 → 无论 inFlight 多大都放行!

双条件 AND:系统资源确实紧张 并发超过估算容量,才拒绝。这保证了在系统空闲时永远不会误限——即使你的 inFlight 有 99999,只要 CPU 不高,就说明机器扛得住。


三、整体架构:四层防护

单靠 BBR 自适应还不够。一个生产级限流器需要处理几个 BBR 本身不擅长的场景:冷启动(没有历史数据)、流量突变(来不及反应)、极端情况(兜底保护)。

因此我设计了四层短路求值架构:

请求到达
  │
  ▼
┌──────────────────────────┐
│ ① InFlight 硬上限         │  O(1),默认禁用
│   并发 >= limit → 拒绝    │  最后一道防线
└──────────┬───────────────┘
           │ 通过
           ▼
┌──────────────────────────┐
│ ② 慢启动保护              │  冷启动/空窗期自动检测
│   QPS 饱和曲线爬升         │  f''<0 先快后慢
└──────────┬───────────────┘
           │ 通过
           ▼
┌──────────────────────────┐
│ ③ 突变阻尼                │  独立 ring buffer 检测
│   QPS 突增 N 倍 → 限流    │  从基线指数恢复
└──────────┬───────────────┘
           │ 通过
           ▼
┌──────────────────────────┐
│ ④ BBR 自适应              │  核心层
│   系统过载 + 并发超估算     │  Little's Law
│   → 拒绝                 │
└──────────┬───────────────┘
           │ 通过
           ▼
       ✅ 放行

每一层解决一个特定问题,短路求值意味着一旦某层拒绝就直接返回,不往下走。下面逐层展开。


四、第一层:InFlight 硬上限

if inFlight >= MaxInFlightLimit {
    return ErrLimitExceed
}

最简单的一层,O(1) 检查。默认禁用

为什么默认禁用?因为对于网关类服务,并发数天然很高(大量 IO 等待),设一个绝对上限反而容易误限。只有明确知道服务并发容量上限的场景(比如依赖数据库连接池大小的服务)才需要。

这层存在的意义是 defense in depth — 当其他所有机制都失效时(比如 CPU 采集故障导致 BBR 不触发),这里能兜住。


五、第二层:饱和曲线慢启动

问题:冷启动时 BBR 是瞎的

BBR 依赖滑动窗口内的历史统计(maxPASS、minRT)来估算容量。但进程刚启动时,窗口是空的:maxPASS=1, minRT=1, maxInFlight ≈ 1。如果此时来了 1000 QPS,BBR 会认为 maxInFlight 是 1 然后把几乎所有请求都拒绝——这显然不对。

解决方案:饱和曲线放量

f(t) = initialQPS + (targetQPS - initialQPS) × (1 - e^(-4 · progress))
QPS
 ▲
 │           ╭──────────────── targetQPS
 │         ╱
 │       ╱     ← 后期增速慢(接近容量上限)
 │     ╱
 │   ╱         ← 初期增速快(资源充裕)
 │ ╱
 │╱
 ├──────────────────────────────► time
   initialQPS    warmupDuration

为什么是饱和曲线(f'' < 0)而不是线性?

这不是拍脑袋。线性增长意味着"匀速放量",但机器的负载特征不是线性的:

  • 初期:CPU 10% → 30%,增加 20% 负载,系统完全扛得住,应该快速放量
  • 后期:CPU 70% → 90%,同样增加 20%,系统已经接近极限,应该慢慢来

饱和曲线 1 - e^{-kt} 完美匹配这个特征:二阶导数为负,先快后慢。k=4.0 使得 progress=1(慢启动结束时)达到目标 QPS 的 ~98%。

空窗期重入

一个容易被忽略的场景:服务运行了一天,但凌晨 2-4 点完全没有流量。到早上 8 点第一个请求来时,滑动窗口的数据全部过期,maxPASS=1, minRT=1 — 和冷启动一模一样。

解决方案:检测到窗口活跃桶 < 30% 时,自动重入慢启动。这样长时间无流量后的第一波请求不会被 BBR 的错误估算全部拒绝。

// 统计 passStat 窗口内有多少桶有数据
activePct := float64(activeBuckets) / float64(totalBuckets)
if activePct < 0.3 {
    // 窗口稀疏 → 类似冷启动,重入慢启动
    l.warmupStartTime.Store(now)
    return true
}

六、第三层:突变阻尼

问题:BBR 的反应速度

BBR 基于滑动窗口统计,窗口默认 10 秒。这意味着当流量突增 10 倍时,BBR 需要若干个桶的时间才能"看到"这个变化并调整 maxInFlight。在此期间,过量请求可能已经把系统打满了。

解决方案:独立检测到达速率

关键设计决策:用两个独立的 ring buffer 分别跟踪"到达速率"和"通过数"

// arrivalStat: 记录所有到达的请求(在 Allow() 最前面,无论是否放行)
l.arrivalStat.Add(1)

// passStat: 只记录成功通过的请求(在 done() 回调里)
l.passStat.Add(1)

为什么要分开?如果用 passStat 检测到达速率,当 burst 被拦截后 passStat 不增长,你就无法知道"真实有多少请求在敲门"。arrivalStat 不受限流影响,准确反映真实洪峰。

基线 historyQPS = passStat.MaxPerBucket × bucketsPerSec   // 只看通过的
当前到达       = arrivalStat.MaxPerBucket × arrivalPerSec // 看所有到达的
当 到达 > 基线 × 3.0 → 激活阻尼

激活后,从 historyQPS 出发,用同样的饱和曲线恢复到 historyQPS × 3.0。持续时间为慢启动时长的一半(至少 5 秒)。

职责边界

慢启动和阻尼有明确的边界:慢启动未完成过一次时,阻尼不介入。原因很简单——没有可靠的 passStat 基线,阻尼无法判断什么是"正常"、什么是"突增"。

if l.opts.WarmupEnabled && !l.warmupExited.Load() {
    return false // 慢启动还没跑完,阻尼靠边站
}

七、第四层:BBR 自适应限流

这是核心层。前三层解决的是 BBR 不擅长的场景,这一层才是稳态下的主力。

数据流

┌─────────────────────────────────────────────────────────┐
│                       BBR Limiter                        │
│                                                          │
│  ┌────────────┐  ┌────────────┐  ┌────────────────────┐ │
│  │ sysCollector│  │ passStat   │  │ arrivalStat        │ │
│  │ (CPU/Mem/  │  │ (通过数窗口) │  │ (到达速率窗口)      │ │
│  │  Load 采集) │  │ 10s/100桶  │  │ 5s/50桶           │ │
│  └────────────┘  └────────────┘  └────────────────────┘ │
│                  ┌────────────┐                          │
│                  │ rtStat     │                          │
│                  │ (RT 统计)   │                          │
│                  │ 10s/100桶  │                          │
│                  └────────────┘                          │
│                                                          │
│  maxInFlight = maxPASS × minRT × bucketsPerSec / 1000   │
│                                                          │
│  if overloaded && inFlight > maxInFlight → 拒绝          │
│  else                                     → 放行         │
└─────────────────────────────────────────────────────────┘

系统指标采集

从 cgroup v2 读取容器级指标,而非宿主机指标(在 K8s 环境中这很重要):

指标数据源采集方式
CPUcgroup v2 cpu.stat每 500ms 采样,10 次环形缓冲区取均值
Memorycgroup v2 memory.stat每 500ms 采样,10 次环形缓冲区取均值
Load/proc/loadavg直接取值(内核已做 1 分钟 EMA)
CPU 核数cgroup v2 cpu.max > MY_CPU_LIMIT > runtime.NumCPU启动时检测一次

CPU 使用率用"千分比"(0-1000)而非百分比,精度更高、避免浮点。

冷却期

限流触发后有 1 秒冷却期:即使过载指标恢复,仍然检查 inFlight > maxInFlight。这是为了防止状态抖动——CPU 在 80% 附近波动时,不会出现"限→放→限→放"的震荡。

if now - prevDropTime <= 1s {
    // 冷却期:仍检查 inFlight(但不要求 overloaded)
    return inFlight > 1 && inFlight > maxInFlight
}

八、滑动窗口:看似简单实则有坑的数据结构

BBR 的所有统计都建立在滑动窗口之上。实现一个正确的、高性能的滑动窗口比你想的难。

基本结构

时间 ──────────────────────────────────────────────►
       ┌───┬───┬───┬───┬───┬───┬───┬───┬───┬───┐
       │ B0│ B1│ B2│ B3│ B4│ B5│ B6│ B7│ B8│ B9│
       └───┴───┴───┴───┴───┴───┴───┴───┴───┴───┘
                                        ↑ offset
       |←──────── window = 10s ────────→|
       每桶 100ms,每桶记录 {sum, count}

惰性推进

没有后台 timer。桶的推进只在 Add() 时发生——计算从上次写入到现在跳过了多少个桶,清理这些桶,然后写入当前桶。

func (w *rollingWindow) advance() {
    span := w.timespan()
    if span <= 0 {
        return
    }
    for i := 1; i <= min(span, w.size); i++ {
        idx := (w.offset + i) % w.size
        w.buckets[idx].reset()
    }
    w.offset = (w.offset + span) % w.size
    w.lastAppendTime = w.lastAppendTime.Add(time.Duration(span) * w.bucketDuration)
}

注意 lastAppendTime 不是直接赋值 now,而是 += span × duration。这保证了时间对齐——桶的边界总是在整数倍的 bucketDuration 上,不会因为调用时机的微小偏差而漂移。

为什么不用 prometheus histogram?

因为 BBR 需要的是 MaxPerBucket(单桶最大值)和 MinAvg(最小平均值),这两个聚合方式在标准 histogram 库中通常不直接支持。而且我需要注入 mock 时钟做确定性测试(后面会详细讲),这要求完全控制时间推进逻辑。


九、CPU-RT P99 直方图:让限流器自己学会什么时候该限

到目前为止,系统还有一个硬编码的"先验假设"——CPU 阈值。默认 80%(800‰),但这个值从哪来的?

CPU 80% 就一定是过载吗?

不一定。不同类型的服务差异巨大:

服务类型真实拐点原因
计算密集型~60-70%CPU 调度竞争激烈,上下文切换成本高
IO 密集型~85-90%CPU 不是瓶颈,大部分时间在等 IO
混合型~75-80%取决于 CPU 和 IO 的比例

用固定 80% 阈值,IO 密集型服务会被过早限流(明明还有余量),CPU 密集型服务会被延迟限流(RT 已经恶化但 CPU 还没到 80%)。

核心观察:过载的本质是 P99 非线性恶化

系统从"正常"到"过载"的标志是什么?

不是 CPU 达到某个百分比——那只是一个代理指标。真正的过载信号是尾部延迟出现非线性恶化

P99 RT
 (ms)
  │                    ╱ ← 非线性恶化区(过载)
  │                  ╱
  │               ╱ ← 拐点(knee point)
  │ ──────────╱
  │    线性区(正常)
  └────────────────────────── CPU%
    30%   50%   70%   85%   95%

当 CPU 从 30% 涨到 70% 时,P99 RT 可能从 10ms 涨到 15ms — 线性的、可预期的。但当 CPU 从 70% 涨到 85% 时,P99 可能从 15ms 突然跳到 200ms — 系统开始排队、抢锁、GC stall。

这个拐点就是"过载阈值",而且它对每个服务都不一样。

为什么用 P99 而不是平均值?

用户体验不在意你能快到多少,只在意最慢的那 1% 有多慢。

平均 RT 上升可能只是请求变复杂了(正常),但 P99 飙升意味着系统开始出问题。

一个真实的例子:

CPUavg RTP99 RT感受
70%15ms18ms一切正常
85%20ms200ms平均看着还行,但 1% 的用户等了 200ms

如果看平均值,20ms vs 15ms 增幅只有 33%,不触发拐点。但 P99 从 18ms 到 200ms,增幅 1000%+,立刻检测到过载。

数据结构:CPU-RT P99 直方图

CPU 使用率 (千分比)
  0‰    50‰   100‰  ...  800‰  850‰  900‰  950‰
  ┌──────┬──────┬─── ··· ──┬──────┬──────┬──────┐
  │ 桶0  │ 桶1  │         │桶16  │桶17  │桶18  │桶19│
  │      │      │         │      │      │      │    │
  │ ring │ ring │         │ ring │ ring │ ring │ring│
  │buffer│buffer│         │buffer│buffer│buffer│buf │
  │ [64] │ [64] │         │ [64] │ [64] │ [64] │[64]│
  └──────┴──────┴─── ··· ──┴──────┴──────┴──────┘
  • 20 个桶:CPU 使用率按 5% 一档,0-5%, 5-10%, ..., 95-100%
  • 每桶 64 个采样:环形缓冲区(ring buffer),O(1) 写入
  • 每个请求完成时,把 (当时的 CPU 使用率, 本次 RT) 写入对应桶

数据采集:零额外开销

return func(info DoneInfo) {
    rt := float64(clock.Since(start).Milliseconds())
    l.rtStat.Add(rt)              // 原有逻辑
    l.cpuRTHist.record(cpu, rt)   // 新增:O(1) 环形缓冲区写入
}, nil

采集发生在 Allow()done 回调中 — 请求完成时顺带记录。零额外 goroutine,零额外锁竞争(每个 CPU 桶有独立的读写锁,不同 CPU 区间互不影响)。

拐点检测算法

每 5 秒(带缓存)执行一次 findKnee()

  1. 从桶 0 到桶 19,依次计算每个有足够样本(≥8 个)的桶的 P99 RT
  2. 相邻有效桶的 P99 RT 做环比:ratio = (curr.p99 - prev.p99) / prev.p99
  3. ratio > KneeRatio(默认 50%)时,这就是拐点
  4. 返回该桶对应的 CPU 千分比作为"自适应过载阈值"
for i := 1; i < len(valid); i++ {
    prev := valid[i-1]
    curr := valid[i]
    ratio := (curr.p99 - prev.p99) / prev.p99
    if ratio > h.opts.KneeRatio {
        // 拐点!
        threshold = int64(curr.idx) * cpuBucketWidth
        return threshold, true, profile
    }
}

实际效果

以下是一个测试中的真实输出:

=== Learned CPU-RT Profile ===
CPU 300‰ (30.0%): P99= 8.0ms  samples=64
CPU 500‰ (50.0%): P99=19.0ms  samples=64    ← 增幅 137% > 50% → 拐点!
CPU 700‰ (70.0%): P99=39.0ms  samples=64
CPU 850‰ (85.0%): P99=273ms   samples=64
CPU 950‰ (95.0%): P99=1923ms  samples=64

Learned threshold=500‰ (50.0%)

这个服务在 CPU 50% 时 P99 就开始恶化了 — 如果用默认的 80% 阈值,会让系统在 50%-80% 这个区间内一直处于用户体验恶化状态。自适应学习到的 50% 阈值让限流提前介入,保护了尾部延迟。

与用户配置的关系:defense in depth

effective = min(自适应学到的阈值, 用户配置的 CPUThreshold)
  • 自适应可以更敏感(比配置的低),提前保护
  • 不能超过用户设的上限 — 用户配置是最终兜底

为什么不直接用自适应的值?因为自适应需要数据。冷启动时没数据,学不到拐点,此时用用户配置兜底。即使后来学到了一个比配置高的值(比如 IO 密集型服务拐点在 90%),也不采用——用户设 80% 是有他的理由的。


十、确定性测试:消灭 flaky test 的核心武器

限流器的测试天然很难写。你需要模拟时间推进、流量模式、CPU 变化,然后验证限流行为。如果用 time.Sleep 控制时序、用真实 goroutine 模拟并发,那么:

  • CI 环境的 CPU 调度抖动会导致 QPS 偏差 ±20%
  • goroutine 调度不确定性导致 inFlight 不可预测
  • 测试耗时长(真实 sleep 30 秒慢启动)
  • 每次跑结果不一样 → flaky test

Mock 时钟

解决方案的核心是时间源抽象

type Clock interface {
    Now() time.Time
    Since(t time.Time) time.Duration
}

生产环境用 realClock(直接调 time.Now()),测试用 mockClock(手动推进时间):

type mockClock struct {
    mu  sync.Mutex
    now time.Time
}

func (m *mockClock) Advance(d time.Duration) {
    m.mu.Lock()
    m.now = m.now.Add(d)
    m.mu.Unlock()
}

滑动窗口的所有时间操作都通过 Clock 接口,所以测试可以精确控制"现在是什么时候"、"过了多长时间"。

确定性流量模拟器

func simulateTraffic(l *BBR, clk *mockClock, qps int, duration, handleRT time.Duration) (passed, limited int64)

核心循环:1ms 为一个 tick,每个 tick:

  1. clk.Advance(1ms) — 推进时间
  2. 完成所有到期的 pending 请求(done() 回调)
  3. 按 QPS 累加器计算本 tick 该发几个请求
  4. 调用 Allow() → 通过的加入 pending 队列(记录完成时刻 = now + handleRT)
type pendingReq struct {
    done   DoneFunc
    doneAt time.Time  // 预定完成时刻
}

QPS 累加器处理非整除情况:

reqsPerTick := float64(qps) * float64(1ms) / float64(1s)  // 例如 QPS=300 → 0.3
accumReqs += reqsPerTick  // 累加
toSend := int(accumReqs)  // 取整
accumReqs -= float64(toSend)  // 保留余数

QPS=300 时,每 tick 累加 0.3,第 3 个 tick 时 accumReqs=0.9 取整 0 不发,第 4 个 tick accumReqs=1.2 发 1 个,余 0.2。如此循环,3.33ms 发 1 个 ≈ 300 QPS。

效果

指标旧测试(真实时钟)新测试(mock 时钟)
确定性❌ 每次结果不同✅ 完全一致
场景测试耗时~60s< 0.1s
总耗时(93 个测试)~60s~2s

唯一保留真实并发的测试是 TestTimeline_RaceCondition_Stability — 验证竞态安全天然需要真实的 goroutine 调度。


十一、一些工程细节

原子操作 vs 锁

BBR 的热路径(Allow())需要极低开销。设计原则:

  • inFlight: atomic.Int64 — 无锁,CAS
  • warmupCounter: 自定义 secCounter,全 atomic 操作,自动秒切换
  • dampingActive: atomic.Bool
  • passStat/rtStat: sync.RWMutex 保护的滑动窗口(读多写少)
  • cpuRTHist: 每个 CPU 桶独立 sync.RWMutex,不同桶互不阻塞

秒级计数器的 CAS 设计

慢启动需要一个"当前秒内处理了多少请求"的计数器。朴素实现会有 TOCTOU 竞态:

// ❌ 有竞态:两个 goroutine 同时读到 oldSec,一个切换了,另一个在旧秒上加
if currentSec != nowSec {
    currentSec = nowSec  // TOCTOU!
    count = 0
}
count++

正确实现:

func (s *secCounter) addAndGet(nowSec int64) int64 {
    curSec := s.sec.Load()
    if curSec != nowSec {
        if s.sec.CompareAndSwap(curSec, nowSec) {
            s.count.Store(1)
            return 1
        }
        // CAS 失败 = 别人已切换,直接加
    }
    return s.count.Add(1)
}

CompareAndSwap 保证只有一个 goroutine 成功切换秒数并重置计数器,其他 goroutine 直接在新秒上累加。

日志限频

高限流场景下(比如 10000 QPS 被限到 500),如果每次限流都打日志,日志本身就成了性能瓶颈。

type logRateLimiter struct {
    lastLogTime atomic.Int64
}

func (l *logRateLimiter) shouldLog() bool {
    now := time.Now().UnixMilli()
    last := l.lastLogTime.Load()
    if now-last < 1000 {
        return false
    }
    return l.lastLogTime.CompareAndSwap(last, now)
}

最多每秒 1 条日志,CAS 保证并发安全。


十二、gRPC 拦截器接入

BBR 通过 gRPC 拦截器一行代码接入:

grpc.ChainUnaryInterceptor(
    bbr.NewUnaryServerInterceptor(rateLimitErr),
)

拦截器对所有经过它的请求统一限流。如果需要按接口独立限流,可以在接口方法内单独调用 BBR.Allow()

done, err := limiter.Allow()
if err != nil {
    return status.Error(codes.ResourceExhausted, "rate limited")
}
defer done(bbr.DoneInfo{})
// 处理请求...

done 回调必须调用——它记录 RT、递减 inFlight、更新通过数统计。忘了调用会导致 inFlight 永远增长,最终所有请求被拒。用 defer 是最佳实践。


十三、配置指南

零配置即可用

bbr.NewLimiter()  // 所有默认值

默认配置已经覆盖大多数标准 RPC 服务:

参数默认值说明
CPUThreshold800 (80%)CPU 过载阈值,自适应会自动调低
Window10s滑动窗口时长
Bucket100桶数,每桶 100ms
WarmupEnabledtrue慢启动保护
WarmupDuration30s慢启动持续时长
WarmupInitialQPS200初始放行 QPS
DampingEnabledtrue突变阻尼
DampingThresholdRatio3.0QPS 突增 3 倍触发
CPURTAdaptiveEnabledtrueCPU-RT P99 自适应学习
MaxInFlightPerCore0 (禁用)InFlight 硬上限

按场景调优

网关 / 高并发代理

bbr.NewLimiter(
    bbr.WithMaxInFlightPerCore(0),  // 禁用硬上限(默认)
    bbr.WithDampingEnabled(false),  // 网关流量本身波动大,阻尼会误判
)

计算密集型服务

bbr.NewLimiter(
    bbr.WithCPUThreshold(700),       // 更保守的 CPU 阈值兜底
    bbr.WithMaxInFlightPerCore(16),  // 限制并发,保护 CPU 调度
)

内存敏感服务(如大缓存服务):

bbr.NewLimiter(
    bbr.WithMemThreshold(850),  // 内存 85% 触发限流
    bbr.WithCPUThreshold(800),  // CPU 仍然检测
)

十四、总结:设计原则回顾

回顾整个设计,有几个贯穿始终的原则:

1. 宁可漏限,不要误限

在系统资源充足时,永远不限流。即使 inFlight 有 99999,只要 CPU 不高就放行。宁可在极端情况下让几个多余请求进来,也不能在正常情况下拦住合法请求。

2. Defense in depth

不依赖单一机制。四层防护各司其职,自适应阈值取 min(学到的, 配置的),硬上限作为最终兜底。

3. 观测优于假设

Little's Law 从实际观测推导容量,CPU-RT P99 直方图从实际数据学习拐点。不预设 QPS 上限,不预设 CPU 阈值——让数据说话。

4. 零配置可用,深度可调

bbr.NewLimiter() 一行代码,所有默认值都经过仔细选择。但每个参数都可以独立调整。

5. 可测试性是一等公民

时钟抽象、确定性模拟器、93 个测试全部 < 2 秒跑完。限流器的正确性关乎线上稳定性,flaky test 是不可接受的。


附录 A:文件结构

common/gopkg/ratelimit/bbr/
├── bbr.go           # 核心限流器:BBR 结构体、Allow()、shouldDrop()、慢启动、阻尼
├── cpurt.go         # CPU-RT P99 直方图:自动学习 CPU 过载拐点
├── window.go        # 滑动窗口:rollingWindow、rollingCounter、Clock 接口
├── sysload.go       # 系统采集器:CPU/内存/Load 从 cgroup v2 采集
├── interceptor.go   # gRPC 拦截器:NewUnaryServerInterceptor
├── bbr_test.go      # 测试:93 个确定性测试
└── doc/
    ├── BBR柔性过载保护_分享文档.md    # 团队内部分享版(偏实操/速查)
    └── BBR柔性过载保护_博客版.md      # 本文(偏设计思路/why)

附录 B:核心公式速查

公式含义
maxInFlight = maxPASS × minRT × bucketsPerSec / 1000Little's Law:系统最优并发容量
f(t) = init + (target - init) × (1 - e^{-4·progress})饱和曲线:先快后慢放量
effective = min(adaptive, configured)自适应阈值取保守值
kneeRatio = (curr.p99 - prev.p99) / prev.p99P99 环比增幅,>50% 为拐点

附录 C:参考资料

从 Google BBR 到生产级柔性过载保护:一个服务端限流器的设计与实现
https://georgeji.com/archives/cong-google-bbr-dao-sheng-chan-ji-rou-xing-guo-zai-bao-hu
作者
George.Ji
发布于
更新于
许可