← 中文资源

简体中文版本

带突发计量的固定窗口限流器

按客户端键限制请求,支持显式成本、重置时间、重试提示,并用有界状态控制内存。

阅读英文原文

功能说明

该示例实现按 key 计数的固定窗口限流器,每个窗口内累计消耗的成本不能超过 limit。consume 返回是否放行、剩余额度、窗口重置时间以及被拒绝时的建议等待毫秒数,可直接映射为 429 响应与 Retry-After 头。适合 API 网关、登录尝试限制、单用户导出配额等进程内限流场景。

关键代码

状态用 Map 按 key 保存计数与窗口结束时间。访问时若条目不存在或已过期,就创建新窗口;创建前若键数量达到 maxKeys,先清理所有过期窗口,仍满则按插入顺序删除最旧的键,避免恶意 key 无限增长。成本必须是正整数,允许一次操作扣多个额度。窗口内累计成本不超过 limit 时放行并累加;否则保持计数不变并返回剩余等待时间。

使用注意

固定窗口在边界处可能产生双倍突发:前一窗口末尾与后一窗口开头各放行一批请求,需要更平滑的限流时应改用滑动窗口或令牌桶。该实现是单进程内存状态,多实例部署需换成 Redis 等共享存储并处理原子性。状态按插入顺序淘汰,不按最近访问时间,热点键与冷键混用时需评估 maxKeys 大小。reset 可用于测试或管理端解封。

验证方式

用可注入时钟测试:同一窗口内前两次成本为一的请求放行,第三次超限时返回 allowed 为 false 与正确的 retryAfterMs;推进时钟越过重置点后额度恢复;测试一次成本大于 limit 永远拒绝;连续创建超过 maxKeys 的键,断言 Map 大小受限且过期键被优先清除;非法 limit、windowMs 与成本抛 RangeError。

代码示例

export interface RateLimitResult {
  allowed: boolean;
  remaining: number;
  resetAt: number;
  retryAfterMs: number;
}

export interface RateLimiterOptions {
  limit: number;
  windowMs: number;
  now?: () => number;
  maxKeys?: number;
}

interface WindowState {
  count: number;
  resetAt: number;
}

export class FixedWindowRateLimiter {
  private readonly limit: number;
  private readonly windowMs: number;
  private readonly now: () => number;
  private readonly maxKeys: number;
  private readonly windows = new Map<string, WindowState>();

  constructor(options: RateLimiterOptions) {
    if (!Number.isInteger(options.limit) || options.limit < 1) {
      throw new RangeError("limit must be a positive integer");
    }
    if (!Number.isFinite(options.windowMs) || options.windowMs <= 0) {
      throw new RangeError("windowMs must be a positive finite number");
    }
    const maxKeys = options.maxKeys ?? 10_000;
    if (!Number.isInteger(maxKeys) || maxKeys < 1) {
      throw new RangeError("maxKeys must be a positive integer");
    }
    this.limit = options.limit;
    this.windowMs = options.windowMs;
    this.now = options.now ?? Date.now;
    this.maxKeys = maxKeys;
  }

  consume(key: string, cost: number = 1): RateLimitResult {
    if (typeof key !== "string" || key.length === 0) {
      throw new TypeError("key must be a non-empty string");
    }
    if (!Number.isInteger(cost) || cost < 1) {
      throw new RangeError("cost must be a positive integer");
    }
    const now = this.now();
    if (!Number.isFinite(now)) throw new RangeError("now must return a finite timestamp");

    let state = this.windows.get(key);
    if (!state || now >= state.resetAt) {
      if (!state && this.windows.size >= this.maxKeys) this.evict(now);
      state = { count: 0, resetAt: now + this.windowMs };
      this.windows.set(key, state);
    }

    const allowed = state.count + cost <= this.limit;
    if (allowed) state.count += cost;
    return {
      allowed,
      remaining: Math.max(0, this.limit - state.count),
      resetAt: state.resetAt,
      retryAfterMs: allowed ? 0 : Math.max(0, state.resetAt - now),
    };
  }

  reset(key?: string): void {
    if (key === undefined) this.windows.clear();
    else this.windows.delete(key);
  }

  get size(): number {
    return this.windows.size;
  }

  private evict(now: number): void {
    for (const [key, state] of this.windows) {
      if (now >= state.resetAt) this.windows.delete(key);
    }
    if (this.windows.size < this.maxKeys) return;
    const oldest = this.windows.keys().next();
    if (!oldest.done) this.windows.delete(oldest.value);
  }
}