计数器算法:固定窗口计数器原理、实现方式与临界问题分析
限流算法里最直观的一种,就是计数器算法。说白了,就是在一个固定时间窗口内,数一数来了多少个请求。超过阈值就拦,没超过就放。
我刚开始接触限流时,第一个用的就是它。为什么?因为简单啊。但用着用着就发现坑不少。今天咱们把它的原理、实现和那个著名的「临界问题」一次讲透。
固定窗口计数器原理
核心思想就一句话:把时间切成固定大小的窗口,每个窗口内维护一个计数器。
举个例子。我设定窗口大小为 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的最后 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 限流 → 固定窗口 + 缓冲区,简单够用
总结
固定窗口计数器,优点就一个字:简单。实现简单、理解简单、排查问题也简单。
但它的缺点也很明显:临界问题。窗口边界处的流量尖峰,可能让你的后端服务瞬间崩溃。
用不用它,取决于你的场景。如果你能接受偶尔的流量尖峰,或者你的后端有足够的冗余,那固定窗口是个不错的选择。否则,我建议你看看后面章节要讲的滑动窗口和令牌桶。
嗯,固定窗口就聊到这儿。记住一句话:简单的东西往往有隐藏的代价。用之前,先想清楚你的场景能不能承受这个代价。