流控三板斧
前三篇讲了一个核心矛盾:分布式系统里——网络和时钟不可靠——所以你必须在一致性和可用性之间做取舍——Raft 用 majority 保证 CP——Nacos Distro 用最终一致性取 AP。
但取舍不只发生在数据一致性层面——流量控制层面同样存在。每个服务有自己的承载上限——超过上限就必须拒绝一部分请求——这就是限流。拒绝哪些请求?以什么粒度计数?桶还是窗口?
📌 前置知识:需要有 Sentinel 基本概念(知道它是限流熔断组件)和 Dubbo 基本用法(知道 @DubboReference 怎么调用远程服务)。如果还没用过 Sentinel 的 Dashboard——建议先对着官方文档跑一遍 Quick Start——不需要深入——但得知道控制台里"流控规则"长什么样。
一、为什么"每秒 100 个请求"这种限流方式有 Bug——固定窗口的边界突刺
对限流最直观的理解:系统处理能力是每秒 100 个——超过就拒绝。实现这个最简单的办法——搞一个计数器——每秒归零。
// 固定窗口计数器——最朴素的想法
class FixedWindowRateLimiter {
private long windowStart = System.currentTimeMillis();
private int counter = 0;
private final int limit = 100;
public synchronized boolean tryAcquire() {
long now = System.currentTimeMillis();
if (now - windowStart > 1000) {
windowStart = now; // 新窗口——计数器归零
counter = 0;
}
if (counter < limit) {
counter++;
return true; // 放行
}
return false; // 限流
}
}
看起来没毛病——每秒最多通过 100 个——超过就拒绝。问题出在窗口边界:
12:00:00 ~ 12:00:01
第 51 ~ 100 个请求
→ 集中在 12:00:00.900 ~ 12:00:01.000
窗口 1 总计 100 ✓
12:00:01 ~ 12:00:02
第 1 ~ 50 个请求
→ 集中在 12:00:01.000 ~ 12:00:01.100
窗口 2 总计 50 ✓
系统承载上限是每秒 100——但 200ms 内打了 150——限流形同虚设
窗口边界是计数器归零的时刻——如果在归零前压满窗口、归零后又立即压新窗口——两个窗口叠加——实际通过的流量远超限流阈值。
flowchart LR
fw["固定窗口计数器\n每个窗口独立计数\n边界清空"]:::startEnd
fw --> edge["窗口切换瞬间\n上一窗口最后 100ms\n+ 下一窗口前 100ms\n= 200ms 内通过两倍阈值"]:::reject
edge --> sw["滑动窗口解决方案:\n不是数'这一秒过了多少'\n而是数'过去一秒过了多少'\n——窗口跟着时间滑动"]:::data
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
二、滑动窗口——把一秒切成 N 份——统计的是"过去一秒"
固定窗口的问题在于只看窗口内部的计数——不看跨窗口的连续性。滑动窗口把一秒切成 N 个小格子(bucket)——每个小格子记录这一小段时间里的通过量——统计"过去一秒"时——把过去 N 个小格子的数据加起来就行。
滑动窗口结构——1 秒窗口——分成 5 个 bucket——每个 200ms:
时间轴:─────────────────────────────────────────────────────►
│ bucket-0 │ bucket-1 │ bucket-2 │ bucket-3 │ bucket-4 │
│ 200ms │ 200ms │ 200ms │ 200ms │ 200ms │
│ 通过: 20 │ 通过: 30 │ 通过: 25 │ 通过: 28 │ 通过: 22 │
◄──────────────────►
过去 1 秒(当前窗口)
总计: 30+25+28+22+... = 统计范围
每过一个 bucket 的时间——丢弃最旧的 bucket——创建新的 bucket——窗口始终覆盖"过去一秒"。这样——没有任何两个相邻请求可以跨窗口叠加绕过限流——因为窗口不是固定的——它一直跟着时间走。
Sentinel 的滑动窗口实现用了一个环形数组(LeapArray)——bucket 数量可配置——默认两个窗口——一个 500ms。在 Dashboard 里看到的 QPS 限流——底层就是这个滑动窗口在统计。
flowchart TD
time["时间流动——每 500ms"]:::process
time --> discard["丢弃最旧的 bucket\n该 bucket 的计数从\nwindowSum 中减去"]:::process
discard --> create["创建新 bucket\n计数从 0 开始"]:::process
create --> query["限流判断:\nwindowSum + 当前 bucket 计数\n是否 ≥ 阈值?"]:::condition
query -->|"≥ 阈值"| reject["拒绝请求——\n根据流控效果处理\n(快速失败 / 排队)"]:::reject
query -->|"< 阈值"| pass["通过——\n当前 bucket 计数 +1"]:::data
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef condition fill:#2a1147,stroke:#a855f7,stroke-width:1.5px,color:#ede9fe,font-weight:bold;
classDef reject fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
⚠️ 新手提示:滑动窗口不是万能的——bucket 数量决定了它的精确度和内存开销。1 个 bucket(= 固定窗口)——精确度最差但内存最小——N 个 bucket——越精确但内存越大。Sentinel 默认 2 个 bucket——500ms 粒度的滑动窗口——工程上够用——但不是数学上精确的滑动窗口。
三、令牌桶——“匀速放令牌——突发取走”——而不是"每秒固定个数"
前面讲的固定窗口和滑动窗口——都是直接计数——“过去一秒过了多少”。另一种思路是——用一个"桶"作为中间缓冲——令牌以固定速率放入桶——请求来了消耗令牌——令牌不够就拒绝。这就是令牌桶算法。
令牌桶的运作——假设速率 limit=100/s——桶容量=200:
① 一个后台线程——每 10ms 放入 1 个令牌(100 个/秒的速率)
② 桶里最多容纳 200 个令牌——满了就丢弃(令牌不是数据——不存储请求——只是"允许通过"的凭证)
③ 每来一个请求——从桶里取一个令牌——取到 → 放行——取不到 → 限流
④ 如果来了 200 个请求——桶里有 200 个令牌——全部取走——瞬间通过 200 个
然后桶空了——后续请求被限流——直到新的令牌被放入
令牌桶相比固定窗口的最大区别——它允许突发流量。桶容量 200——如果系统空闲了 2 秒——桶里积攒了 200 个令牌——突然来一批请求——可以瞬间消耗完——但接下来就得等令牌慢慢补充。
sequenceDiagram
participant T as 令牌桶\n速率=100/s\n容量=200
participant R as 请求流
Note over T: 系统空闲了 2 秒\n桶里积攒了 200 个令牌
rect rgb(255, 243, 224)
R->>T: 突发——200 个请求几乎同时到达
T-->>R: 全部取到令牌——放行 ✅
Note over T: 桶已空——0 个令牌
end
rect rgb(255, 205, 210)
R->>T: 第 201 个请求——1ms 后到达
T-->>R: 令牌不足——限流 ❌
Note over T: 10ms 后生成 1 个新令牌
end
rect rgb(200, 230, 201)
Note over T: 等待 100ms——桶里有 10 个令牌
R->>T: 来了 10 个请求
T-->>R: 全部通过——桶又空了 ✅
end
Sentinel 的 WarmUp 预热模式——本质上是一个令牌桶的变体——系统刚启动时——令牌生成速率从低值逐渐提升到设定值——防止冷系统被突如其来的流量打爆。
| Sentinel 流控效果 | 底层机制 | 适用场景 |
|---|---|---|
| 快速失败 | 滑动窗口计数——超阈值直接抛 FlowException | 默认——大多数场景 |
| WarmUp 预热 | 令牌桶——令牌生成速率从 coldFactor 逐渐增加到设定值 | 系统刚启动——缓存还没预热——连接池还没建立 |
| 排队等待 | 漏桶——请求匀速通过——超过排队超时则拒绝 | 希望削峰填谷——高峰期的请求排队——有空闲时就处理 |
四、漏桶——不是令牌桶的反面——作用完全不同
令牌桶控制的是速率——漏桶控制的是匀速。令牌桶允许突发——漏桶强制平滑。
漏桶的运作——假设速率=100/s,排队超时=500ms:
① 请求到达——如果桶内有空闲槽位——放入桶——等待处理
② 桶底以固定速率(100/s=每 10ms 一个)漏出请求——实际执行
③ 如果桶满了——新请求排队等待——等待超过 500ms——拒绝
④ 不在乎请求的到达速度多快——只在乎处理的速率是匀速的
漏桶和令牌桶的关键区别:
五、Dubbo 负载均衡——请求过来了——打给谁
限流负责“过不过”——负载均衡负责“去哪台机器”。Dubbo 内置了多种负载均衡策略——每种背后都有明确的算法和取舍。
5.1 加权随机(RandomLoadBalance)——带概率的随机
最简单的策略——给每台机器设一个权重——权重大的被选中的概率大。
Provider A:权重 5 → 被选中概率 50%
Provider B:权重 3 → 被选中概率 30%
Provider C:权重 2 → 被选中概率 20%
实现思路——把所有权重加起来=10——在 0~9 之间取随机数
0~4 → Provider A
5~7 → Provider B
8~9 → Provider C
加权随机适合Provider 性能差异大——好机器多抗一点——差机器少抗一点——实现极其简单——性能开销几乎为零。
5.2 最少活跃调用数(LeastActiveLoadBalance)——谁闲找谁
把请求发给当前活跃调用数最少的 Provider。活跃调用数 = 发出的请求数 - 收到的响应数——如果 Provider A 当前有 3 个请求在处理——Provider B 只有 1 个——新请求发给 B。
这个策略的隐含假设——活跃调用数低的机器处理能力更强——能更快处理完请求。但实际情况是——如果某个 Provider 恰好刚启动——活跃调用数低——请求都涌过去——反而可能被打挂。Dubbo 通过加一个随机权重来解决——防止所有请求都涌入活跃最低的那台。
⚠️ 新手提示:LeastActive 在 Provider 性能差异大时效果很好——但如果所有 Provider 的性能差不多——随机就够了——LeastActive 反而因为需要维护计数增加了开销。不是越复杂越好——根据场景选。
5.3 一致性哈希(ConsistentHashLoadBalance)——同一个用户总是打到同一台机器
前两种策略都不关心“哪个请求该去哪个 Provider”——一致性哈希反其道而行——让同一类请求永远路由到同一台机器。
这个需求很常见:用户下单后——后续的支付回调、物流更新——如果都打给同一台机器——可以利用本地缓存避免重复查数据库——效率高很多。
一致性哈希不是普通的取模哈希——它的关键创新在于——节点增减时——只有少部分数据需要迁移。
普通哈希取模:hash(key) % N → N 变了——几乎所有 key 的路由都变了
一致性哈希:hash(key) 映射到哈希环 → 节点也映射到环 → key 落在哪个节点就到哪
节点增减——只有受影响的那一小段环的数据需要迁移
flowchart TD
ring["哈希环——0 ~ 2^32-1"]:::startEnd
ring --> n1["节点 A——hash=100"]:::data
ring --> n2["节点 B——hash=500"]:::data
ring --> n3["节点 C——hash=900"]:::data
n1 --> m1["请求 hash=50 → 顺时针\n第一个节点是 A → 打给 A"]:::process
n1 --> m2["请求 hash=200 → 顺时针\n第一个节点是 B → 打给 B"]:::process
n2 --> m3["请求 hash=600 → 顺时针\n第一个节点是 C → 打给 C"]:::process
ring --> virtual["🍩 虚拟节点——\n实际节点数少时——\n数据分布不均匀\n→ 每个物理节点映射多个虚拟节点\n→ 分布在环上各处——消除热点"]:::highlight
classDef startEnd fill:#701a4c,stroke:#e11d48,stroke-width:2px,color:#fce7f3,font-weight:bold;
classDef data fill:#052e16,stroke:#16a34a,stroke-width:1.5px,color:#bbf7d0,font-weight:bold;
classDef process fill:#1e1e24,stroke:#6b7280,stroke-width:1.5px,color:#e5e7eb;
classDef highlight fill:#450a0a,stroke:#dc2626,stroke-width:1.5px,color:#fecaca,font-weight:bold;
一致性哈希最核心的应用场景是缓存类服务——Redis Cluster 的分片——Dubbo 里的粘性路由——网关里按用户 ID 分流——都依赖这个算法。
⚠️ 新手提示:如果只有 2-3 个物理节点——一致性哈希环上节点分布会很稀疏——导致"hash 热区"——某些节点分到的请求明显更多。解决方案是虚拟节点(virtual node)——每个物理节点映射成 160 个虚拟节点——散落在环上——请求分布就均匀了。
六、三种策略选型的决策框架
| 场景 | 推荐策略 | 原因 |
|---|---|---|
| Provider 性能差异大——机器配置不同 | 加权随机 | 权重直接映射硬件能力——简单有效 |
| 需要粘性——同一用户的请求去同一台 | 一致性哈希 | 节点增减时迁移量最小 |
| Provider 数量少——性能差异未知 | 最少活跃调用 | 自动探测——无需手动配置权重 |
| 默认场景——Provider 同构——QPS 不高 | 加权随机 | 开销最低——够用 |
七、总结——流控的本质是"在确定性和效率之间找平衡"
| 算法 | 核心问题 | 交换 |
|---|---|---|
| 滑动窗口 | 怎么精确统计"过去一秒" | 用内存换精确度——bucket 越多越精确但越占内存 |
| 令牌桶 | 允许突发但不超平均 | 用桶容量换突发容忍——容量越大突发越强——但内存和检查开销越大 |
| 漏桶 | 强制匀速——削峰填谷 | 用排队延迟换平滑——排队越久越平滑——但延迟越高 |
| 一致性哈希 | 节点增减最少迁移 | 用虚拟节点数量换分布均匀——虚拟节点越多越均匀——内存开销也越大 |
回头看这一整个系列——从网络和时钟的不确定性——到 CAP 的取舍——到 Raft 的 majority——再到今天的流控算法——分布式系统的所有设计——本质上都是在一个不可靠的基础上——用受控的交换(tradeoff)——建造出可用的东西。
没有一个算法在所有场景下都是最优的——每个算法都在说同一句话:“在 XX 条件下——我能给你 YY 的保证——代价是 ZZ。"理解了这句话——就理解了分布式算法。
📖 本系列导航:
- 第一篇:网络与时间的不确定性
- 第二篇:CAP 定理与一致性模型
- 第三篇:Raft 选举、心跳与故障检测
- 本文:第四篇——流控算法