计数器算法:固定窗口计数器原理、实现方式与临界问题分析

限流算法里最直观的一种,就是计数器算法。说白了,就是在一个固定时间窗口内,数一数来了多少个请求。超过阈值就拦,没超过就放。

我刚开始接触限流时,第一个用的就是它。为什么?因为简单啊。但用着用着就发现坑不少。今天咱们把它的原理、实现和那个著名的「临界问题」一次讲透。

固定窗口计数器原理

核心思想就一句话:把时间切成固定大小的窗口,每个窗口内维护一个计数器。

举个例子。我设定窗口大小为 1 秒,阈值是 10 个请求。那么:

  • 第 0 秒到第 1 秒,计数器从 0 开始累加
  • 请求来了,计数器 +1
  • 如果计数器达到 10,后续请求直接拒绝
  • 第 1 秒结束时,计数器归零,进入下一个窗口

听起来是不是很简单?嗯,确实简单。但简单背后藏着问题。

核心公式:
当前窗口请求数 < 阈值 → 放行
当前窗口请求数 ≥ 阈值 → 拒绝

实现方式:从单机到分布式

实现方式分两种场景:单机版和分布式版。我分别说说。

单机版实现(Java 伪代码)

单机版最简单,用 AtomicInteger 就能搞定。我在早期项目里就是这么干的。

public class FixedWindowCounter {
    private final long windowSizeMs;      // 窗口大小,单位毫秒
    private final int threshold;          // 阈值
    private final AtomicInteger counter;  // 计数器
    private long windowStart;             // 窗口开始时间

    public FixedWindowCounter(long windowSizeMs, int threshold) {
        this.windowSizeMs = windowSizeMs;
        this.threshold = threshold;
        this.counter = new AtomicInteger(0);
        this.windowStart = System.currentTimeMillis();
    }

    public synchronized boolean tryAcquire() {
        long now = System.currentTimeMillis();
        // 如果当前时间超过了窗口结束时间,重置窗口
        if (now - windowStart >= windowSizeMs) {
            counter.set(0);
            windowStart = now;
        }
        // 检查是否超过阈值
        if (counter.get() < threshold) {
            counter.incrementAndGet();
            return true;
        }
        return false;
    }
}

这段代码有几个关键点:

  • synchronized:保证重置和计数操作的原子性
  • 窗口重置:时间到了就归零,简单粗暴
  • 阈值检查:先判断再计数,避免超限

我的经验:单机版用 synchronized 就够了,别一上来就上 CAS 或者锁分段。除非你压测发现 synchronized 是瓶颈,否则别过度优化。我曾经见过有人用 ConcurrentHashMap 做计数器,结果性能还不如 AtomicInteger。

分布式版实现(Redis + Lua)

分布式场景下,单机计数器就不够用了。多个实例需要共享同一个计数器。这时候 Redis 是首选。

我习惯用 Lua 脚本保证原子性。为什么?因为 Redis 的 INCR 和 EXPIRE 不是原子操作,中间可能出问题。

-- Lua 脚本:固定窗口计数器
-- KEYS[1]:计数器 key
-- ARGV[1]:阈值
-- ARGV[2]:窗口大小(秒)

local key = KEYS[1]
local threshold = tonumber(ARGV[1])
local windowSize = tonumber(ARGV[2])

-- 获取当前计数
local current = redis.call('GET', key)

if current and tonumber(current) >= threshold then
    -- 超过阈值,拒绝
    return 0
end

-- 递增计数
local count = redis.call('INCR', key)

-- 如果是第一次设置,设置过期时间
if count == 1 then
    redis.call('EXPIRE', key, windowSize)
end

if count <= threshold then
    -- 未超过阈值,放行
    return 1
else
    -- 超过阈值,拒绝
    return 0
end

调用方式也很简单:

// Java 调用 Redis Lua 脚本
String luaScript = "..."; // 上面的脚本内容
List<String> keys = Arrays.asList("rate_limit:api_xxx");
List<String> args = Arrays.asList("10", "1"); // 阈值10,窗口1秒

Long result = redisTemplate.execute(
    new DefaultRedisScript<>(luaScript, Long.class),
    keys, args
);

if (result == 1) {
    // 放行
} else {
    // 限流
}

注意:Redis 版的计数器有一个隐藏问题——时钟漂移。如果 Redis 实例的时钟不准,窗口边界会偏移。我遇到过因为 NTP 同步导致窗口提前重置,瞬间流量冲垮了后端。建议所有实例都用同一台 NTP 服务器。

临界问题分析

好了,重点来了。固定窗口计数器最大的坑——临界问题。

什么叫临界问题?说白了就是:窗口边界处的流量尖峰。

我画个图你就明白了。

固定窗口计数器临界问题示意图 窗口1(0s ~ 1s) 窗口2(1s ~ 2s) 边界 请求1~10 请求11~20 临界问题 假设阈值 = 10 请求/秒 窗口1末尾 0.9s ~ 1.0s:涌入 10 个请求(放行) 窗口2开头 1.0s ~ 1.1s:涌入 10 个请求(放行) 实际 0.2 秒内通过了 20 个请求,远超阈值!

看到了吗?问题出在窗口边界。窗口1的最后 0.1 秒和窗口2的最初 0.1 秒,各自都能通过 10 个请求。加起来就是 0.2 秒内通过了 20 个请求。

阈值明明是 10 请求/秒,结果实际流量达到了 100 请求/秒。这就是临界问题。

临界问题的本质:固定窗口只保证了「每个窗口内」不超过阈值,但无法保证「任意连续窗口大小的时间段」内不超过阈值。窗口边界处存在「流量真空期」。

临界问题的实际影响

这个影响有多大?我举个例子。

假设你的 API 网关配置了固定窗口限流:

  • 窗口大小:1 秒
  • 阈值:100 请求
  • 后端服务能承受的峰值:150 请求/秒

你觉得安全吗?

不安全。因为临界问题可能导致:

  • 窗口1末尾 0.1 秒:100 个请求
  • 窗口2开头 0.1 秒:100 个请求
  • 实际 0.2 秒内:200 个请求
  • 后端服务:直接被打垮

我在一个电商大促项目里就遇到过。当时用固定窗口限流,压测时一切正常。上线后流量一上来,后端直接 OOM。查了半天才发现是临界问题导致的流量尖峰。

避坑指南:如果你后端服务的承载能力刚好卡在阈值附近,千万别用固定窗口。我曾经因为这个问题,在大促前三天紧急把限流算法换成了滑动窗口。那三天,我基本没合眼。

如何缓解临界问题

既然固定窗口有这个问题,那有没有办法缓解?有,但只是缓解,不是根治。

方案 原理 效果 代价
缩小窗口 把 1s 窗口改成 100ms 降低尖峰持续时间 计数器重置更频繁,性能开销增加
增加缓冲区 阈值设为后端承载能力的 70% 留出余量应对尖峰 资源利用率降低
配合令牌桶 固定窗口做第一层,令牌桶做第二层 平滑流量 架构复杂度增加
改用滑动窗口 不再固定边界,而是滑动时间窗口 彻底解决临界问题 实现复杂度高,内存开销大

我个人建议:如果对流量平滑度要求高,直接上滑动窗口或令牌桶。固定窗口只适合那些「偶尔超一点没关系」的场景。

我的选择标准:

  • 日志采集、监控告警这类非关键链路 → 固定窗口够用
  • 支付、下单、秒杀这类核心链路 → 别用固定窗口,换滑动窗口或令牌桶
  • 内部服务间的 RPC 限流 → 固定窗口 + 缓冲区,简单够用

总结

固定窗口计数器,优点就一个字:简单。实现简单、理解简单、排查问题也简单。

但它的缺点也很明显:临界问题。窗口边界处的流量尖峰,可能让你的后端服务瞬间崩溃。

用不用它,取决于你的场景。如果你能接受偶尔的流量尖峰,或者你的后端有足够的冗余,那固定窗口是个不错的选择。否则,我建议你看看后面章节要讲的滑动窗口和令牌桶。

嗯,固定窗口就聊到这儿。记住一句话:简单的东西往往有隐藏的代价。用之前,先想清楚你的场景能不能承受这个代价。