系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / 时间窗口:debounce 与 throttle 待审核 4 / 10
窗口内的合并

时间窗口: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)。

leadingtrailing 决定窗口的哪一端派发。5 次调用间隔 10 毫秒、窗口 60 毫秒,三种配置的结果如下(core/scenarios.test.ts §1):

配置 执行次数 其余调用的结局
trailing(默认) 1 前 4 次被替换
leading + trailing 2 中间 3 次被替换
leading only 1 后 4 次直接丢弃
图 1-1 · debounce 的滚动时间轴:蓝色竖线是调用,浅色区块是当前窗口,绿线是派发,红线是被替换。可切换 edge 与窗口长度,连点 tap 观察窗口随每次调用整体后移。

警示 · leadingtrailing 同时为 false 是用法错误,返回的 Promise 以一个普通 Error reject,消息里写着至少要开一端。这条与「被替换」走的是同一个 catch 分支,而按 CancelledError 判类型的调用方会把它当成一次正常的合并悄悄咽掉(lab/facts.test.ts §1)。

2 · throttle 的固定窗口

throttle 与 debounce 的差别只有一处:窗口不随新调用延长。第一次调用开出一个长 ms 的窗口,窗口期内无论来多少次,边界都停在原处;边界一到,窗口关闭并结算。默认 leadingtrailing 都为真,于是一个满载的窗口至多派发两次,一次在开头,一次在结尾。

9 次调用间隔 10 毫秒、窗口 40 毫秒,实际执行次数落在 3 次以上、9 次以下(core/scenarios.test.ts §2)。测试断言的是这个区间而不是一个确切的数:调用间隔与窗口边界的相位关系随真实时间抖动,某一次调用落在边界前一毫秒还是后一毫秒会改变结算次数。凡是与真实时间有关的量,本系列一律只锁不等式。

图 2-1 · throttle 的滚动时间轴,控件与图 1-1 完全相同。可对照观察:窗口区块的宽度始终等于设定的毫秒数,密集连点也不会把它撑长。

注 · 实现上 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 被取消之后仍会把结果送回来。

图 4-1 · 真实输入框驱动的三道处理,列表按提交编号倒序记录每个请求死在哪一道。可调 debounce 窗口与模拟网络延迟,对比快速连打与逐字停顿两种输入节奏。

协调器是插件而不是内建能力,这台运行时必须写成 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.tslab/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:registeredlab/facts.test.ts §5)。也就是说 armed 与 replaced 记录的是被缓冲与被替换,不是被派发。要统计真实派发次数,得数 task:registered 而不是数 scheduler 事件。

窗口层决定「一段时间里执行几次」;同一个 key 上并存的几次调用彼此如何相处,是下一页的题目:同 key 协调