3. 常见限流算法(上):计数器算法(固定窗口)原理、实现与临界问题
计数器算法(固定窗口算法)是限流领域最直观、最容易实现的算法。它以一个固定的时间窗口(如 1 秒、1 分钟)为单位,统计在该窗口内通过的请求总数,一旦超过预设阈值,后续请求将被直接拒绝或排队等待。本节将深入剖析其原理、Python/Go 实现,并重点揭示其固有的“临界问题”。
3.1 算法原理与核心要素
固定窗口算法的核心思想可以概括为:“切分时间、计数清零”。它将时间轴划分为若干个等长的时间段(窗口),每个窗口维护一个独立的计数器。
| 要素 | 说明 | 典型值 |
|---|---|---|
| 时间窗口 | 限流的基本时间单位,窗口长度固定 | 1 秒、1 分钟、1 小时 |
| 阈值 | 单个窗口内允许通过的最大请求数 | 100 次/秒、1000 次/分钟 |
| 计数器 | 记录当前窗口已通过的请求数量 | 从 0 开始递增 |
| 窗口重置 | 当前窗口结束后,计数器归零,进入下一个窗口 | 整点秒、整点分钟 |
算法执行流程如下:
- 获取当前时间,计算所属的时间窗口(例如,按秒限流时,时间戳除以 1000 取整即为窗口 ID)。
- 根据窗口 ID 获取对应的计数器(通常使用 Redis 或本地内存)。
- 如果计数器不存在或已过期,则创建新计数器并设置过期时间(等于窗口长度)。
- 判断当前计数器值是否小于阈值:
- 若小于,则计数器加 1,允许请求通过。
- 若大于等于,则拒绝请求(返回 429 Too Many Requests 或执行降级逻辑)。
适用场景: 固定窗口算法适用于对突发流量容忍度较高、且时间窗口边界精度要求不高的场景,如简单的 API 调用频率控制、非核心业务的限流保护。
3.2 固定窗口限流流程(SVG)
下图展示了固定窗口算法在单个时间窗口内的完整决策流程:
3.3 代码实现:Python 与 Go
以下分别给出 Python(基于本地内存)和 Go(基于 sync 包)的固定窗口限流器核心实现。
Python 实现(本地内存版)
import time
from threading import Lock
class FixedWindowRateLimiter:
def __init__(self, window_size: int, max_requests: int):
self.window_size = window_size # 窗口大小,单位:秒
self.max_requests = max_requests # 窗口内最大请求数
self.window_start = int(time.time()) # 当前窗口起始时间戳
self.counter = 0 # 当前窗口计数器
self.lock = Lock() # 线程安全锁
def allow_request(self) -> bool:
with self.lock:
now = int(time.time())
# 判断是否进入新的时间窗口
if now - self.window_start >= self.window_size:
self.window_start = now
self.counter = 0
if self.counter < self.max_requests:
self.counter += 1
return True
return False
# 使用示例:每秒最多允许 10 个请求
limiter = FixedWindowRateLimiter(window_size=1, max_requests=10)
for i in range(15):
print(f"请求 {i+1}: {'通过' if limiter.allow_request() else '拒绝'}")
Go 实现(并发安全版)
package limiter
import (
"sync"
"time"
)
type FixedWindowLimiter struct {
mu sync.Mutex
windowSize time.Duration
maxRequests int
windowStart time.Time
counter int
}
func NewFixedWindowLimiter(windowSize time.Duration, maxRequests int) *FixedWindowLimiter {
return &FixedWindowLimiter{
windowSize: windowSize,
maxRequests: maxRequests,
windowStart: time.Now(),
counter: 0,
}
}
func (l *FixedWindowLimiter) Allow() bool {
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now()
if now.Sub(l.windowStart) >= l.windowSize {
l.windowStart = now
l.counter = 0
}
if l.counter < l.maxRequests {
l.counter++
return true
}
return false
}
生产环境提示: 上述本地内存实现仅适用于单进程场景。在分布式系统中,应使用 Redis 的 INCR 命令配合 EXPIRE 实现原子计数和窗口过期,避免多实例间的计数不一致。
3.4 临界问题(Traffic Burst at Window Boundary)
固定窗口算法最致命的缺陷是临界问题,也称为“窗口边界突发流量”问题。由于窗口切换时计数器瞬间归零,攻击者或突发流量可以在窗口切换的瞬间发起大量请求,导致实际通过的请求数达到阈值的 2 倍。
问题复现
假设限流规则为:1 秒内最多允许 100 个请求。考虑以下时间线:
- 第 0.9 秒 ~ 第 1.0 秒: 窗口 1 的最后 0.1 秒内,涌入 100 个请求(窗口 1 计数器达到阈值,后续请求被拒)。
- 第 1.0 秒 ~ 第 1.1 秒: 窗口 2 开始,计数器归零,又涌入 100 个请求。
在 0.2 秒 的时间跨度内,系统实际处理了 200 个请求,是阈值的 2 倍。这种突发流量足以击穿后端服务。
临界问题示意图(SVG)
3.5 临界问题的危害与应对
| 危害维度 | 具体表现 | 影响等级 |
|---|---|---|
| 后端过载 | 数据库连接池耗尽、CPU 飙升、响应时间急剧增加 | 严重 |
| 限流失效 | 限流规则形同虚设,无法保护下游依赖 | 严重 |
| 雪崩效应 | 一个服务过载引发上游重试,导致级联故障 | 灾难性 |
应对策略:
- 升级算法: 采用滑动窗口算法(下一节内容),将窗口细分为多个小格子,平滑流量。
- 增加预热: 在窗口开始时限制最大速率,避免瞬间打满阈值。
- 配合熔断: 当后端错误率升高时,主动熔断,减少无效请求。
避坑指南: 在催收 API 场景中,临界问题可能导致催收通知在整秒边界瞬间爆发,造成短信/语音通道拥堵。务必评估业务对突发流量的容忍度,必要时直接选用滑动窗口或令牌桶算法。
3.6 本章小结
- 固定窗口算法 实现简单、性能高,适用于对突发流量不敏感的场景。
- 核心缺陷 是临界问题,窗口切换时可能产生 2 倍阈值的突发流量。
- 生产环境 建议使用 Redis 实现分布式计数器,并考虑结合其他算法弥补窗口边界缺陷。
- 下一节预告: 我们将学习滑动窗口算法,它通过细粒度时间分片彻底解决临界问题。