Article

从一个群聊问题想到节流,再走到时间窗口限次

更新于:2026-03-11

前几天我在群里看到一个问题:限制一个函数在 $m$ 毫秒内只能执行 $n$ 次。

我看到这个题目的第一反应,其实是它和节流很像。

因为节流做的事情,本质上也是在一段时间内限制函数的触发频率。最常见的节流写法,是让函数在固定时间间隔内最多执行一次。这个题目只是把“最多执行一次”放宽成了“最多执行 $n$ 次”,所以我一开始会自然地把它往节流的方向去理解。

第一反应:这是节流的变种

我最开始的判断是,这题应该可以参考节流的思路来做。

普通节流通常会维护一个上次执行时间:

function throttle(fn, delay) {
  let lastTime = 0;

  return function (...args) {
    const now = Date.now();
    if (now - lastTime >= delay) {
      lastTime = now;
      fn.apply(this, args);
    }
  };
}

如果把这个思路继续往下推,就会发现这里和普通节流还是有一个明显差别:

  • 节流限制的是“一段时间内最多执行 1 次”
  • 这个题限制的是“一段时间内最多执行 $n$ 次”

也就是说,这道题确实和节流很像,但不能直接照搬普通节流的实现。

我一开始踩到的坑

最开始我尝试过用 setTimeout 去做,思路大概是:

  1. 调用时判断当前次数有没有超过上限。
  2. 如果没超过,就注册一个 $m$ 毫秒后的定时器。
  3. 定时器执行时再去真正调用函数,并修改计数。

这种写法一开始看起来很顺,但很快就会出现问题。

比如下面这种思路:

function limitFunction(fn, m, n) {
  let callCount = 0;

  return function (...args) {
    if (callCount >= n) return;

    setTimeout(() => {
      fn.apply(this, args);
      callCount++;
    }, m);
  };
}

问题在于,callCount++ 发生得太晚了。

如果我在很短时间内连续调用很多次,前面的调用都还没等到定时器执行,这时候 callCount 还是旧值,后面的调用就还是会继续放行。结果就是:本来想限制调用次数,实际上却放进去了很多个延迟任务。

换句话说,这个版本限制的不是“调用时刻的次数”,而更像是在错误的时机做统计,所以它并不能真正表达“$m$ 毫秒内最多执行 $n$ 次”这件事。

第二步理解:计数必须在调用当下生效

后来我意识到,关键不是“什么时候执行函数”,而是“什么时候占掉一个名额”。

如果这次调用已经被允许,那它就应该在调用当下立刻占用一个名额,并且函数也应该立即执行。真正需要延后的是“名额释放”这件事,而不是函数执行本身。

于是我把思路改成了这样:

function limitFunction(fn, m, n) {
  let callCount = 0;

  return function (...args) {
    if (callCount >= n) return;
    callCount++;

    fn.apply(this, args);

    setTimeout(() => {
      callCount--;
    }, m);
  };
}

这一版比前一版好很多,因为名额是在调用发生的那一刻就被占用了,而且函数也在被允许的那个瞬间立刻执行。

但我后来又发现,这里还有一个更细的区别:

这段代码其实已经不是我后来理解的“固定窗口”了,它实现出来的更像是“滑动窗口”。

关键分歧:题目里的“$m$ 毫秒内”到底是什么意思

这道题真正不严谨的地方,其实就在这里。

“$m$ 毫秒内最多执行 $n$ 次”这句话,至少可以有两种理解:

  1. 固定窗口:从某个窗口起点开始,接下来的 $m$ 毫秒里最多执行 $n$ 次;窗口结束后,整体重新开始计数。
  2. 滑动窗口:对任意一个当前时刻,都去看它往前数 $m$ 毫秒这一段时间里已经执行了多少次,只要达到 $n$ 次,就不能再放行。

这两种理解看起来很像,但实现方式和结果都不一样。

为什么 setTimeout 这版其实是滑动窗口

先看这段代码:

function limitFunction(fn, m, n) {
  let callCount = 0;

  return function (...args) {
    if (callCount >= n) return;
    callCount++;

    fn.apply(this, args);

    setTimeout(() => {
      callCount--;
    }, m);
  };
}

它的本质不是“整段窗口一起重置”,而是“每一次成功调用,都单独占用一个名额,并在各自的 $m$ 毫秒之后释放”。

也就是说:

  • 第 1 次调用,有自己的释放时间
  • 第 2 次调用,也有自己的释放时间
  • 第 3 次调用,还是有自己的释放时间

每个名额都是独立过期的,所以窗口不是整块往前跳,而是随着时间连续滑动。这就是滑动窗口的味道。

比如这组调用:

const limitedFn = limitFunction(() => console.count("调用次数"), 3000, 3);

limitedFn(); // 输出:调用次数: 1

setTimeout(() => {
  limitedFn(); // 输出:调用次数: 2
}, 1000);

setTimeout(() => {
  limitedFn(); // 输出:调用次数: 3
}, 1000);

setTimeout(() => {
  limitedFn(); // 输出:调用次数: 4
}, 3100);

setTimeout(() => {
  limitedFn(); // 不输出,因为前面 2 个名额还没释放
}, 3200);

如果我按“固定窗口”的直觉去想,我会期待输出是 1 2 3 4 5

因为我会把前 3 秒看成一个整体窗口:

  • 第 1、2、3 次落在第一个窗口里
  • 到了 3100ms,窗口已经结束
  • 所以后面的第 4、5 次应该都落进第二个窗口里

但这段 setTimeout 版本实际不是这样工作的。

它的真实过程更像是:

  • t = 0 时第 1 次调用,占掉 1 个名额,t = 3000 释放
  • t = 1000 时第 2 次调用,占掉 1 个名额,t = 4000 释放
  • t = 1000 时第 3 次调用,占掉 1 个名额,t = 4000 释放
  • t = 3100 时第 1 个名额已经释放,所以第 4 次可以执行
  • t = 3200 时第 2、3 个名额还没释放,所以第 5 次会被拦住

所以输出会是 1 2 3 4,而不是 1 2 3 4 5

这也正是我后来才想清楚的点:我以为自己写的是“固定窗口限次”,其实写出来的是“滑动窗口限次”。

方案一:滑动窗口实现

如果你对题目的理解是“任意连续 $m$ 毫秒内最多执行 $n$ 次”,那上面的 setTimeout 版本其实已经可以成立。

它可以理解成一个很直观的滑动窗口写法:成功调用一次,就占用一个名额;$m$ 毫秒后,这个名额自动归还。

function limitFunctionSliding(fn, m, n) {
  let callCount = 0;

  return function (...args) {
    if (callCount >= n) return;
    callCount++;

    fn.apply(this, args);

    setTimeout(() => {
      callCount--;
    }, m);
  };
}

如果想把滑动窗口表达得更明确一点,也可以直接记录时间戳:

function limitFunctionSliding(fn, m, n) {
  const timestamps = [];

  return function (...args) {
    const now = Date.now();

    while (timestamps.length && now - timestamps[0] >= m) {
      timestamps.shift();
    }

    if (timestamps.length >= n) return;

    timestamps.push(now);
    fn.apply(this, args);
  };
}

这两个版本表达的是同一种语义,只是一个是“延迟释放名额”,另一个是“显式保存窗口内的调用时间”。

方案二:固定窗口实现

如果我理解的题意是“从窗口起点开始,接下来 $m$ 毫秒里最多执行 $n$ 次;窗口结束后统一重置”,那就应该用固定窗口的写法。

这种情况下,窗口里的调用不会各自单独过期,而是整段窗口一起结束。

function limitFunctionFixed(fn, m, n) {
  let windowStart = 0;
  let callCount = 0;

  return function (...args) {
    const now = Date.now();

    if (now - windowStart >= m) {
      windowStart = now;
      callCount = 0;
    }

    if (callCount >= n) return;

    callCount++;
    fn.apply(this, args);
  };
}

这版的逻辑是:

  1. 第一次调用时会自然开启一个窗口,之后只有超过窗口时长才会整体切到下一个窗口。
  2. 在这个窗口的 $m$ 毫秒内,只允许最多执行 $n$ 次。
  3. 一旦超过窗口时长,就整体开启下一个窗口,并把计数归零。

按这个语义,前面那组测试就会得到你直觉里的结果:前 3 次在第一个窗口内执行,3100ms 和 3200ms 的两次则落到新的窗口里,所以会输出 1 2 3 4 5

到这里我才真正想清楚:不是代码先错,而是语义先分叉了

我后面再回头看,会发现我最开始并不是单纯“代码写错了”,而是我在脑子里默认了“固定窗口”的题意,但写出来的实现却更符合“滑动窗口”。

所以真正需要先回答的问题不是“这题像不像节流”,而是:

这个题里的“$m$ 毫秒内”到底指的是哪一种窗口?

一旦这个前提没说清楚,讨论“对不对”其实很容易鸡同鸭讲。

这次思考里我最重要的收获

这道题对我来说最有价值的地方,不是最后写出了哪一版代码,而是把几个容易混在一起的概念区分开了:

  • 节流是频率控制
  • 时间窗口限次是额度控制
  • 固定窗口和滑动窗口,虽然都叫“$m$ 毫秒内最多 $n$ 次”,但含义并不一样
  • setTimeout 只能帮我延后某段逻辑,不会自动帮我得到正确的统计时机和窗口语义

我最开始看到题目时,第一反应是“这和节流很像”。现在回头看,这个直觉本身没有问题,它确实是一个很自然的切入点。

但继续往下想,真正重要的是把“像不像节流”再拆开一层,去问自己:

这个题到底要限制的是什么?是执行间隔,还是窗口内的总次数?是固定窗口,还是滑动窗口?

当我把这个问题想清楚以后,后面的实现方向就顺了很多。

小结

如果只用一句话总结我对这个题的理解,那就是:

我一开始把它当成节流的变种来理解,这个方向没错;但真正把题拆开之后,我发现它其实至少有两种常见语义:固定窗口限次和滑动窗口限次。

所以后来我更愿意先问清楚题意,再决定写哪一种实现。很多时候,不是代码先出了问题,而是题目本身没有把窗口语义说完整。