引言
在高并发系统中,限流(Rate Limiting)是保护核心服务不被流量冲垮的第一道防线。无论是秒杀活动、突发新闻还是爬虫攻击,流量往往以远超系统处理能力的速率涌来。合理的限流策略不仅关乎服务稳定性,更直接影响用户体验和成本控制。
本文将从经典限流算法入手,逐步深入到分布式场景下的工程实现,最后探讨自适应限流的最新进展。
一、经典单机限流算法
1.1 计数器算法(Counter)
计数器是最简单的限流方案:在一个固定时间窗口内累加请求数,超过阈值则拒绝。
class CounterRateLimiter:
def __init__(self, limit, window_size):
self.limit = limit
self.window_size = window_size # 秒
self.count = 0
self.start_time = time.time()
def allow(self):
now = time.time()
if now - self.start_time > self.window_size:
self.count = 0
self.start_time = now
if self.count < self.limit:
self.count += 1
return True
return False
缺陷:窗口边界问题——如果大量请求恰好集中在两个窗口的交界处,实际通过的请求量可能是阈值的 2 倍。
1.2 滑动窗口算法(Sliding Window)
滑动窗口将时间轴切分为多个小窗口,随着时间推移,窗口逐渐滑动,从而解决边界突变问题。
class SlidingWindowLimiter:
def __init__(self, limit, window_size, split_num=10):
self.limit = limit
self.window_size = window_size
self.split_num = split_num
self.unit_size = window_size / split_num
self.counters = [0] * split_num
def allow(self):
now = time.time()
idx = int((now % self.window_size) / self.unit_size)
# 清零过期窗口
old_idx = int(((now - self.window_size) % self.window_size) / self.unit_size)
if idx != old_idx:
self.counters[idx] = 0
total = sum(self.counters)
if total < self.limit:
self.counters[idx] += 1
return True
return False
滑动窗口在精度和内存之间取得了平衡,但sum(self.counters)的遍历操作在大分片数时成为性能瓶颈。
1.3 令牌桶算法(Token Bucket)
令牌桶以固定速率向桶中添加令牌,每个请求到达时消耗一个令牌。桶满时令牌丢弃,桶空时请求被限流。
type TokenBucket struct {
capacity int64 // 桶容量
tokens float64 // 当前令牌数
rate float64 // 令牌添加速率(个/秒)
lastTime time.Time // 上次更新时间
mu sync.Mutex
}
func (tb *TokenBucket) Allow() bool {
tb.mu.Lock()
defer tb.mu.Unlock()
now := time.Now()
elapsed := now.Sub(tb.lastTime).Seconds()
tb.tokens = math.Min(tb.capacity, tb.tokens + elapsed*tb.rate)
tb.lastTime = now
if tb.tokens >= 1.0 {
tb.tokens--
return true
}
return false
}
令牌桶的关键优势在于:允许突发流量(桶满时),同时持续控制平均速率。适合需要应对短时尖峰但又要控制整体 QPS 的场景。
1.4 漏桶算法(Leaky Bucket)
与令牌桶不同,漏桶以恒定速率"漏出"请求。即使瞬间涌入大量请求,系统也只以固定间隔处理,输出流量是平滑的。
令牌桶 vs 漏桶:令牌桶限制的是"平均速率"(允许突发),漏桶限制的是"输出速率"(强制匀速)。网关层需要突发能力用令牌桶,消息队列需要平滑输出用漏桶。
二、分布式限流的工程挑战
单机限流在分布式场景下会遇到三个核心问题:
- 状态一致性:多个节点各自计数,全局阈值难以准确控制
- 负载不均:Round-Robin 流量可能使某些节点提前触发限流,而其他节点仍有余量
- 时钟不同步:滑动窗口依赖时间戳,节点时钟漂移导致窗口错位
2.1 Redis + Lua 集中计数
最直接的方案:将所有节点的计数集中到 Redis。使用 Lua 脚本保证原子性。
-- KEYS[1]: 限流key, ARGV[1]: 阈值, ARGV[2]: 窗口大小
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) >= tonumber(ARGV[1]) then
return 0
end
redis.call('INCR', KEYS[1])
if not current then
redis.call('EXPIRE', KEYS[1], ARGV[2])
end
return 1
这种方案简单但有单点性能瓶颈——当 QPS 达到数万时,Redis 的网络开销和单线程模型会限制整体吞吐。
2.2 Redis-CINATOR:更高效的滑动窗口
利用 Redis Sorted Set(ZSET)实现精确滑动窗口限流。
-- ZSET: score=timestamp_micro, member=unique_request_id
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window_start = now - tonumber(ARGV[2]) * 1000
local limit = tonumber(ARGV[3])
-- 清除过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
-- 统计当前窗口内请求数
local current = redis.call('ZCARD', key)
if current >= limit then
return 0
end
-- 添加当前请求记录
redis.call('ZADD', key, now, ARGV[4])
redis.call('EXPIRE', key, ARGV[2])
return 1
优点是精度极高(可精确到微秒级),缺点是存储开销随 QPS 线性增长。
2.3 分层限流:本地 + 全局
工业界常用的折中方案:每个节点先做本地限流(快速拒绝明显超额的请求),再周期性同步到 Redis 进行全局校验。
- 本地限流阈值 = 全局阈值 / 节点数 × 安全系数(如 1.2)
- 每 100ms 向 Redis 上报本节点计数,合并后判断全局是否超限
- 全局超限时,按节点当前负载比例,按比例降低各节点本地限流阈值
这种分层策略将 Redis 交互频率降低 10-100 倍,在保证全局精度的同时支撑高吞吐。
三、自适应限流(Adaptive Rate Limiting)
传统静态阈值无法应对系统负载的动态变化。自适应限流根据系统健康指标(RT、错误率、CPU 利用率)动态调整限流阈值。
3.1 BBR 启发的限流策略
借鉴 TCP BBR 拥塞控制思想:系统持续探测最大可持续吞吐量,当 RT 陡增或错误率上升时主动降低阈值。
// 采样周期内测量
deliveryRate = successRequests / sampleInterval
RTprop = min(RT over measurement window)
BtlBw = deliveryRate
// 当 RT 超过基线的 1.5 倍时,阈值 = BtlBw * RTprop
// 保守比例: inflight = BtlBw * RTprop * 0.75
3.2 Netflix Concurrency Limits
Netflix 开源的自适应限流库,核心思路是追踪服务器当前的并发请求数(inflight),当 newly measured RT 比历史估计值升高时,降低并发上限。
其梯度下降策略:new_limit = current_limit × (1 ± gradient),其中 gradient 由 RT 变化率决定。
3.3 Sentinel 的匀速排队策略
阿里 Sentinel 提供的"匀速通过"模式(RuleConstant.CONTROL_BEHAVIOR_RATE_LIMITER),将请求以固定时间间隔排队放行,本质上是一种漏桶的工程实现。对于突发流量,超过阈值的请求会按预期等待时间排队,超出最大排队时间的请求被拒绝。
四、工程实践要点
4.1 限流粒度设计
- 接口级:对每个 API 端点独立限流
- 用户/租户级:按用户 ID 或 API Key 区分配额
- 全局级:保护底层资源(数据库连接、第三方 API)
- 优先级:核心接口(支付、登录)高配额,非核心接口(推荐、搜索)低配额
4.2 限流响应策略
- 直接拒绝:返回 429 Too Many Requests + Retry-After
- 排队等待:请求进入队列,定时放行(保证公平但增加 RT)
- 降级响应:返回缓存数据或简化版本的响应
- 熔断:当限流持续触发时,触发熔断层彻底阻断流量
4.3 监控与告警
- 实时监控:限流触发次数、被拒绝请求比例、排队等待时间分布
- 告警阈值:当限流触发率 > 5% 时通知 oncall
- 联动降级:限流率超阈值时自动触发下游降级预案
五、总结
| 算法 | 突发容忍 | 精度 | 内存开销 | 适用场景 |
|---|---|---|---|---|
| 计数器 | 高(边界突变) | 低 | O(1) | 粗粒度限制 |
| 滑动窗口 | 中 | 中 | O(N) | 通用场景 |
| 令牌桶 | 高(桶容量内) | 高 | O(1) | 网关/API限流 |
| 漏桶 | 无 | 高 | O(1) | 消息队列/流量整形 |
| 自适应限流 | 动态调整 | 高 | O(1) | 复杂生产环境 |
没有银弹。单机场景直接用 Guava RateLimiter 或自实现令牌桶;分布式场景优先考虑 Redis 分层限流;对稳定性有极高要求的生产系统,建议采用 Sentinel、Envoy 等成熟框架并开启自适应模式。
限流的本质是在"拒绝一部分请求"和"保护整个系统"之间找平衡点。工程上常常需要在精确度和性能之间做取舍,关键是理解每种算法的行为边界,在合适的场景选择合适的工具。

发表评论 取消回复