写在前面
这篇文章记录了我在设计和实现一个服务端限流器过程中的思考——从最初"为什么令牌桶不够用"的疑问,到最终落地一个四层防护 + 自适应学习的柔性过载保护系统。它借鉴了 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 环境中这很重要):
| 指标 | 数据源 | 采集方式 |
|---|---|---|
| CPU | cgroup v2 cpu.stat | 每 500ms 采样,10 次环形缓冲区取均值 |
| Memory | cgroup 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 飙升意味着系统开始出问题。
一个真实的例子:
| CPU | avg RT | P99 RT | 感受 |
|---|---|---|---|
| 70% | 15ms | 18ms | 一切正常 |
| 85% | 20ms | 200ms | 平均看着还行,但 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():
- 从桶 0 到桶 19,依次计算每个有足够样本(≥8 个)的桶的 P99 RT
- 相邻有效桶的 P99 RT 做环比:
ratio = (curr.p99 - prev.p99) / prev.p99 - 当
ratio > KneeRatio(默认 50%)时,这就是拐点 - 返回该桶对应的 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:
clk.Advance(1ms)— 推进时间- 完成所有到期的 pending 请求(
done()回调) - 按 QPS 累加器计算本 tick 该发几个请求
- 调用
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— 无锁,CASwarmupCounter: 自定义secCounter,全 atomic 操作,自动秒切换dampingActive:atomic.BoolpassStat/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 服务:
| 参数 | 默认值 | 说明 |
|---|---|---|
CPUThreshold | 800 (80%) | CPU 过载阈值,自适应会自动调低 |
Window | 10s | 滑动窗口时长 |
Bucket | 100 | 桶数,每桶 100ms |
WarmupEnabled | true | 慢启动保护 |
WarmupDuration | 30s | 慢启动持续时长 |
WarmupInitialQPS | 200 | 初始放行 QPS |
DampingEnabled | true | 突变阻尼 |
DampingThresholdRatio | 3.0 | QPS 突增 3 倍触发 |
CPURTAdaptiveEnabled | true | CPU-RT P99 自适应学习 |
MaxInFlightPerCore | 0 (禁用) | 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 / 1000 | Little's Law:系统最优并发容量 |
f(t) = init + (target - init) × (1 - e^{-4·progress}) | 饱和曲线:先快后慢放量 |
effective = min(adaptive, configured) | 自适应阈值取保守值 |
kneeRatio = (curr.p99 - prev.p99) / prev.p99 | P99 环比增幅,>50% 为拐点 |