← 中文资源

简体中文版本

滑动窗口限流器

按 key 在滑动时间窗口内限制请求量,支持配额消耗、重试提示、剩余额度与过期清理。

阅读英文原文

功能说明

SlidingWindowRateLimiter 为每个 key 保存一个请求时间戳数组,每次消费时先丢弃窗口外的记录,再判断剩余配额是否足够。与固定窗口计数器相比,它不会在两个窗口交界处允许双倍突发流量。结果包含是否允许、剩余额度、建议重试等待时间以及窗口重置时间,调用方可以直接映射为 429 响应头。

consume 支持一次消耗多个配额,适合按请求成本计费的接口;成本不能超过窗口上限。prune 可以定期清理长时间不活跃的 key,避免 Map 无限增长。构造函数允许注入时钟,因此在测试中不需要真实等待。

关键代码

消费时先按当前时间过滤过期记录,再判断配额:

const timestamp = this.now();
const cutoff = timestamp - this.windowMs;
const timestamps = (this.hits.get(key) ?? []).filter(
  (value) => value > cutoff,
);

if (timestamps.length + cost > this.limit) {
  const oldest = timestamps[0] ?? timestamp;
  return {
    allowed: false,
    remaining: Math.max(0, this.limit - timestamps.length),
    retryAfterMs: Math.max(0, oldest + this.windowMs - timestamp),
    resetAt: oldest + this.windowMs,
  };
}

被拒绝时仍会把清理后的数组写回,防止过期记录长期滞留。

使用注意

该实现是单进程内存限流,多实例部署时每个实例各自计数,无法形成全局配额;需要跨实例限流时应使用 Redis 等共享存储。时间戳数组按请求顺序追加,依赖时钟单调递增;如果注入的时钟会回退,旧记录可能排在后面,导致重试时间计算不准。

retryAfterMs 表示最早一条记录滑出窗口所需的时间,而不是整个窗口重置时间;在持续有请求的情况下,可能需要多次重试。成本大于 limit 会直接抛错,因为这种请求永远无法通过。prune 只清理完全没有活跃记录且已过期的 key,长期低频但未过期的 key 仍会保留。reset 只删除指定 key。

验证方式

示例使用可控时钟:在 0 毫秒连续消费三次后,第四次被拒绝并返回 1000 毫秒重试时间。时间推进到 600 毫秒时一次消费 2 个配额仍被拒绝,重试时间为 400 毫秒。推进到 1001 毫秒后旧记录过期,消费恢复允许,剩余额度按新请求重新计算。最后把时钟推到 3000 毫秒调用 prune,应清理两个不活跃 key 并返回 2。