3. 常见限流算法(上):计数器算法(固定窗口)原理、实现与临界问题

计数器算法(固定窗口算法)是限流领域最直观、最容易实现的算法。它以一个固定的时间窗口(如 1 秒、1 分钟)为单位,统计在该窗口内通过的请求总数,一旦超过预设阈值,后续请求将被直接拒绝或排队等待。本节将深入剖析其原理、Python/Go 实现,并重点揭示其固有的“临界问题”。

3.1 算法原理与核心要素

固定窗口算法的核心思想可以概括为:“切分时间、计数清零”。它将时间轴划分为若干个等长的时间段(窗口),每个窗口维护一个独立的计数器。

要素 说明 典型值
时间窗口 限流的基本时间单位,窗口长度固定 1 秒、1 分钟、1 小时
阈值 单个窗口内允许通过的最大请求数 100 次/秒、1000 次/分钟
计数器 记录当前窗口已通过的请求数量 从 0 开始递增
窗口重置 当前窗口结束后,计数器归零,进入下一个窗口 整点秒、整点分钟

算法执行流程如下:

  1. 获取当前时间,计算所属的时间窗口(例如,按秒限流时,时间戳除以 1000 取整即为窗口 ID)。
  2. 根据窗口 ID 获取对应的计数器(通常使用 Redis 或本地内存)。
  3. 如果计数器不存在或已过期,则创建新计数器并设置过期时间(等于窗口长度)。
  4. 判断当前计数器值是否小于阈值:
    • 若小于,则计数器加 1,允许请求通过。
    • 若大于等于,则拒绝请求(返回 429 Too Many Requests 或执行降级逻辑)。
适用场景: 固定窗口算法适用于对突发流量容忍度较高、且时间窗口边界精度要求不高的场景,如简单的 API 调用频率控制、非核心业务的限流保护。

3.2 固定窗口限流流程(SVG)

下图展示了固定窗口算法在单个时间窗口内的完整决策流程:

固定窗口限流算法流程图 收到请求 计算当前时间窗口 ID 获取窗口计数器 计数器 < 阈值? 是 计数器 +1,允许通过 否 拒绝请求(429) 说明:窗口切换时计数器自动归零,无需手动重置。

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)

固定窗口临界问题示意图(阈值=100 请求/秒) 0.0s 1.0s 2.0s 窗口 1(0.0s ~ 1.0s) 窗口 2(1.0s ~ 2.0s) 100 请求 100 请求 ⚠ 临界问题:窗口切换瞬间(1.0s 附近),0.2s 内涌入 200 个请求 实际吞吐量达到阈值的 2 倍,后端服务面临突发冲击风险。 理想限流:每 1 秒均匀 100 请求 固定窗口实际:0.2 秒内 200 请求

3.5 临界问题的危害与应对

危害维度 具体表现 影响等级
后端过载 数据库连接池耗尽、CPU 飙升、响应时间急剧增加 严重
限流失效 限流规则形同虚设,无法保护下游依赖 严重
雪崩效应 一个服务过载引发上游重试,导致级联故障 灾难性

应对策略:

  • 升级算法: 采用滑动窗口算法(下一节内容),将窗口细分为多个小格子,平滑流量。
  • 增加预热: 在窗口开始时限制最大速率,避免瞬间打满阈值。
  • 配合熔断: 当后端错误率升高时,主动熔断,减少无效请求。
避坑指南: 在催收 API 场景中,临界问题可能导致催收通知在整秒边界瞬间爆发,造成短信/语音通道拥堵。务必评估业务对突发流量的容忍度,必要时直接选用滑动窗口或令牌桶算法。

3.6 本章小结

  • 固定窗口算法 实现简单、性能高,适用于对突发流量不敏感的场景。
  • 核心缺陷 是临界问题,窗口切换时可能产生 2 倍阈值的突发流量。
  • 生产环境 建议使用 Redis 实现分布式计数器,并考虑结合其他算法弥补窗口边界缺陷。
  • 下一节预告: 我们将学习滑动窗口算法,它通过细粒度时间分片彻底解决临界问题。