3、计数器算法详解:固定窗口计数器实现、临界问题分析、代码实战(Python实现)

限流算法里,计数器算法是最直观的一种。说白了,就是在一个时间窗口内数请求次数,超过了就拦。我刚开始做API网关时,第一个限流方案用的就是它——简单、好理解、容易实现。

但你别因为它简单就小看它。用不好,坑也不少。今天咱们就把固定窗口计数器掰开揉碎了讲清楚。

3.1 固定窗口计数器原理

固定窗口计数器的思路特别直白:

  • 把时间切成固定大小的窗口(比如1秒、1分钟)
  • 每个窗口维护一个计数器
  • 请求来了,计数器加1
  • 计数器超过阈值,直接拒绝
  • 窗口结束,计数器归零

举个例子:我限制某个API每秒最多100次请求。那么从00:00:00到00:00:01这1秒内,计数器从0开始,每来一个请求加1。到100了,后面的请求全部返回429。等到了00:00:01,计数器重置,重新计数。

核心要点:固定窗口算法的关键在于“窗口边界对齐”。窗口的起始时间通常是自然时间(比如整秒、整分钟),而不是从第一个请求到达开始算。

3.2 临界问题(Traffic Burst at Boundaries)

固定窗口有个著名的坑——临界问题。我当年第一次上线就踩了这个雷。

为什么会这样?你想想看:

假设我限制QPS=100。窗口A(00:00:00-00:00:01)在最后100ms内来了100个请求,窗口B(00:00:01-00:00:02)在最开始100ms内又来了100个请求。从单个窗口看,都没超限。但从连续时间看,这200ms内实际处理了200个请求——QPS瞬间飙到1000。

这就是典型的窗口边界突发流量。

时间点 窗口A计数器 窗口B计数器 实际QPS
00:00:00.900 99 0 —
00:00:00.950 100(满) 0 —
00:00:01.050 重置为0 100(满) 200/0.2s = 1000

避坑指南:我曾经在生产环境遇到过这个场景——秒杀活动刚开始那几秒,监控显示QPS曲线像心电图一样,每秒钟都在“正常-爆涨-正常-爆涨”之间反复横跳。查了半天,就是固定窗口的临界问题在作祟。

解决思路有两个方向:

  • 滑动窗口:把窗口粒度切得更细,比如1秒切成10个100ms的小格子,用滑动的方式统计——这个后面章节会细讲
  • 加随机延迟:在窗口边界附近引入少量随机等待,打散突发——但治标不治本

3.3 代码实战:Python实现固定窗口计数器

下面我写一个生产可用的固定窗口计数器。注意,我加了线程安全处理,因为API限流通常是多线程环境。

import time
import threading

class FixedWindowRateLimiter:
    """
    固定窗口计数器限流器
    支持多线程安全
    """
    def __init__(self, max_requests: int, window_size: float = 1.0):
        """
        :param max_requests: 窗口内允许的最大请求数
        :param window_size: 窗口大小,单位秒,默认1秒
        """
        self.max_requests = max_requests
        self.window_size = window_size
        self.lock = threading.Lock()
        
        # 当前窗口信息
        self.current_window_start = time.time()
        self.counter = 0
    
    def allow_request(self) -> bool:
        """
        判断当前请求是否允许通过
        返回True表示允许,False表示拒绝
        """
        now = time.time()
        
        with self.lock:
            # 检查是否进入新的时间窗口
            if now - self.current_window_start >= self.window_size:
                # 重置窗口
                self.current_window_start = now
                self.counter = 0
            
            # 判断是否超限
            if self.counter < self.max_requests:
                self.counter += 1
                return True
            else:
                return False
    
    def get_current_count(self) -> int:
        """获取当前窗口的请求计数"""
        with self.lock:
            return self.counter

# ========== 使用示例 ==========
if __name__ == "__main__":
    # 限制每秒最多5个请求
    limiter = FixedWindowRateLimiter(max_requests=5, window_size=1.0)
    
    # 模拟并发请求
    def send_request(request_id):
        if limiter.allow_request():
            print(f"[{time.strftime('%H:%M:%S')}] 请求 {request_id}: ✅ 通过")
        else:
            print(f"[{time.strftime('%H:%M:%S')}] 请求 {request_id}: ❌ 被限流")
    
    # 启动10个线程同时发请求
    threads = []
    for i in range(10):
        t = threading.Thread(target=send_request, args=(i,))
        threads.append(t)
        t.start()
    
    for t in threads:
        t.join()
    
    print(f"\n当前窗口计数: {limiter.get_current_count()}")

个人经验:实际项目中,我习惯把限流器做成装饰器或者中间件,而不是在每个接口里手动调用。这样代码侵入性小,也方便统一管理限流策略。

3.4 固定窗口 vs 其他算法:一张图看懂

下面这张SVG图把固定窗口的核心逻辑和临界问题画清楚了。

固定窗口计数器限流原理 窗口A(00:00:00 - 00:00:01) 窗口B(00:00:01 - 00:00:02) 请求数: 100(满) 请求数: 100(满) ⚠ 临界区 200ms内200个请求 临界问题:窗口边界突发流量 窗口A末尾 + 窗口B开头 = 短时间内请求量翻倍 实际QPS可达阈值的2倍甚至更高 ✅ 方案一:滑动窗口 将窗口切分为更细粒度的小格子 ⚠ 方案二:随机延迟 边界附近引入随机等待(治标不治本)

3.5 适用场景与局限性

固定窗口计数器不是万能的,但它有自己的一亩三分地。

维度 说明
✅ 适用场景 对突发流量不敏感的业务;日配额/小时配额等粗粒度限流;内部系统间的调用限制
❌ 不适用场景 对延迟敏感的实时接口;需要精确控制瞬时QPS的场景;秒杀、抢购等流量尖峰场景
💡 我的建议 如果业务能接受2倍左右的突发,固定窗口完全够用。别为了追求完美而过度设计。

一句话总结:固定窗口计数器是限流算法的“Hello World”——简单、直观、容易上手。但生产环境用之前,一定想清楚你的业务能不能接受边界突发。如果不能,后面我们会讲滑动窗口和令牌桶,它们能解决这个问题。