时间窗口:debounce 与 throttle
令牌桶 · 漏桶 · 窗口计数回答的是单位时间内放行多少请求,超出配额的被拒绝或排队。本页的问题只换了一半:同一个调用在一秒内被触发几十次,其中几次值得真的执行。搜索框每敲一个字符派一次请求,滚动容器每一帧算一次可见区间,编辑器每次按键存一次草稿,这类重复的问题不在压力,在语义:中间那些结果没有人要,要的只有最后一个,或者只有第一个,或者每隔一段取一个。@vega/job
把这件事收进三个调用侧的 shaper,它们都不修改运行时的队列,只决定哪几次调用有资格进队列。
1 · debounce 的窗口
debounce 按 key 分桶。debounce(runtime, taskFn, { ms, key, leading, trailing, maxWait, run }) 里的 key 决定这次调用进哪个桶,ms 是窗口长度。桶里已经缓冲着一次调用时又来一次,窗口整体后移到新调用的时刻加 ms,前一次被替换。窗口安静满
ms 之后,桶里剩下的那一次才真的派发,派发的方式是拿 run 里的选项去调 run(runtime, taskFn, run)。
它返回 Promise<T> 而非 Task<T>。1.x 时代 debounce 是 RunOptions.schedule 的一个字段,由运行时内部实现,自然有句柄可拿;2.0 把它挪到调用侧之后,窗口闭合之前运行时并不知道有这么一次调用存在,也就没有句柄可发。被替换的调用以
CancelledError reject,调用方从 catch 分支知道自己出局。这一点在测量上很干净:同 key 连发 5 次只登记 1 个 task,另外 4 次连队都没排上(lab/facts.test.ts §1)。
leading 与 trailing 决定窗口的哪一端派发。5 次调用间隔 10 毫秒、窗口 60 毫秒,三种配置的结果如下(core/scenarios.test.ts §1):
| 配置 | 执行次数 | 其余调用的结局 |
|---|---|---|
trailing(默认) |
1 | 前 4 次被替换 |
leading + trailing |
2 | 中间 3 次被替换 |
leading only |
1 | 后 4 次直接丢弃 |
警示 · leading 与 trailing 同时为 false 是用法错误,返回的 Promise 以一个普通 Error reject,消息里写着至少要开一端。这条与「被替换」走的是同一个 catch 分支,而按
CancelledError 判类型的调用方会把它当成一次正常的合并悄悄咽掉(lab/facts.test.ts §1)。
2 · throttle 的固定窗口
throttle 与 debounce 的差别只有一处:窗口不随新调用延长。第一次调用开出一个长 ms 的窗口,窗口期内无论来多少次,边界都停在原处;边界一到,窗口关闭并结算。默认 leading 与 trailing 都为真,于是一个满载的窗口至多派发两次,一次在开头,一次在结尾。
9 次调用间隔 10 毫秒、窗口 40 毫秒,实际执行次数落在 3 次以上、9 次以下(core/scenarios.test.ts §2)。测试断言的是这个区间而不是一个确切的数:调用间隔与窗口边界的相位关系随真实时间抖动,某一次调用落在边界前一毫秒还是后一毫秒会改变结算次数。凡是与真实时间有关的量,本系列一律只锁不等式。
注 · 实现上 throttle 复用 debounce 的状态机,throttle(taskFn, { ms }) 展开成 debounce(taskFn, { leading: true, trailing: true, maxWait: ms })。maxWait 是「连续调用超过这么久就强制派发一次」的预算,把它设成窗口长度本身,等价于「每
ms 至多派发一次」。这与 lodash 的同名参数有一处口径差异:本实现的 maxWait 从窗口开始时刻起算固定边界,lodash 从上一次派发时刻起算。
3 · delay 与 shaper 的共同形状
delay(runtime, taskFn, { ms, signal, run }) 没有 key,每次调用彼此独立,作用只是把 run() 推迟 ms 毫秒。等待期间 signal 一旦 abort,返回的 Promise 以 CancelledError reject,taskFn 一次都不会被调用,运行时里连一条
task:registered 都没有(lab/facts.test.ts §3)。
三个 shaper 因而是同一个形状:接一台 runtime、一个 taskFn、一个选项包,返回 Promise<T>,在某个时刻替调用方去调 run()。它们都活在运行时之外,队列、优先级、并发上限一概不知情。
import { createRuntime, debounce, throttle, delay } from '@vega/job';
const rt = createRuntime({ scheduler: { concurrency: 4 } });
/* 停止输入 300ms 后才发请求;期间的键入合并成这一次 */
await debounce(rt, search, { ms: 300, key: ['search'] });
/* 滚动期间每 100ms 至多重算一次可见区间 */
await throttle(rt, recompute, { ms: 100, key: ['scroll'] });
/* 800ms 后再显示 loading 骨架,signal 提前 abort 就不显示 */
await delay(rt, showSkeleton, { ms: 800, signal: ctl.signal });
窗口层与队列层的这条分界有一个直接后果:debounce 合并掉的调用不占并发额度,也不会出现在 inspect(runtime) 的任何一张表里。它们存在过的唯一痕迹是 §5 的两个诊断事件。
4 · 搜索框里的合并与取消
一个搜索框把本页与相邻两页的机制串在一起。用户每敲一个字符就是一次调用,而真正要保证的是「屏幕上显示的结果对应当前输入框里的字符串」。三道处理依次淘汰掉不合格的请求。
第一道是 debounce:连续键入落在同一个窗口里,只有最后一次出窗口,中途那些请求从未发出。第二道针对已经发出的请求,withCoordinator(taskFn, restartCoordinator, { key }) 让同 key 的后来者取消仍在飞的前者,细节见同 key 协调。第三道是调用方自己做的:给每次调用编号,结果回来时若编号小于已采纳的最大编号就丢弃。前两道都不能完全免除第三道,因为不看 signal 的 taskFn 被取消之后仍会把结果送回来。
协调器是插件而不是内建能力,这台运行时必须写成 createRuntime({ scheduler, plugins: [installCoordination] })。少了这一步,withCoordinator 包过的 taskFn 一进 run() 就被内核的守卫拒绝,整批调用变成 error 而不是被协调。
5 · 窗口的诊断事件
窗口层唯一的观测面是 subscribe(runtime) 上的两个事件:debounce:armed 在一个空桶里缓冲下第一次 trailing 调用时发出,debounce:replaced 在桶里已有调用又被顶掉时发出。同 key 连发 5 次,事件流上恰好是 1 条 armed 加 4 条 replaced(lab/facts.test.ts
§5)。throttle 复用 debounce 引擎,它的窗口活动也走这两个事件,运行时里没有 throttle:* 这一类。
这两条事件携带的信息比表面上看起来少。ShaperJobEvent 的两个成员形状完全相同,payload 里只有一个 key 字段,是 debounce key 的可读序列化(schedule/events.ts,lab/facts.test.ts §5)——没有 id,没有延迟长度,没有「被谁顶掉」。原因是这一层压根还没有
task:被合并掉的调用从未进过 run(runtime),它没有 id 可报。所以「窗口里第几次调用替换了第几次」这个问题在事件流上是答不出来的,只能由调用方自己在 debounce() 的返回 promise 上记账。
顺带一提,throttle 不发自己的事件。它复用的就是 debounce 的状态机(throttle(taskFn, ms) 等价于 debounce(taskFn, ms, { leading: true, trailing: true, maxWait: ms })),所以节流窗口的活动也是从这两条 debounce:* 里观察的。
观测面还有一处盲区。leading 边派发的那一次调用走的是 debounce 实现里的快路径,直接 fire 并返回,不经过 emit,因此事件流上看不到任何 scheduler 事件,只看得到它带出的 task:registered(lab/facts.test.ts §5)。也就是说 armed 与 replaced
记录的是被缓冲与被替换,不是被派发。要统计真实派发次数,得数 task:registered 而不是数 scheduler 事件。
窗口层决定「一段时间里执行几次」;同一个 key 上并存的几次调用彼此如何相处,是下一页的题目:同 key 协调。