系统设计 / 任务运行时 · 从一次 run() 到可观测的调度层 / 失败之后:重试与降级 待审核 6 / 10
韧性 middleware

失败之后:重试与降级

前面几页的 taskFn 都会成功。真实的 taskFn 不会:网络抖一下、服务端回一个 429、对端迟迟不响应、缓存穿透打到一个没准备好的下游。运行时对这些情况给出三个签名相同的 middleware,retrywithTimeoutwithFallback 各自是一次 (taskFn) => taskFn 的纯变换,可以任意叠放。它们各自的行为由错误的类型决定,所以本页从错误模型开始,再逐个看这三层,最后看叠起来之后语义会变成什么。

1 · 错误模型与可重试性

@vega/job 定义了五个错误类。CancelledErrorTimeoutErrorQueueStoppedErrorQueueFullError 描述的是任务与调度器之间发生了什么,taskFn 的业务逻辑对它们无话可说;TaskError 才是留给 taskFn 自己的结构化错误,带 typeretryableretryAfter 三个字段。retryAfter 的单位是毫秒,而 HTTP 的 Retry-After 响应头以秒计(RFC 9110 §10.2.3),从响应头转过来要先乘 1000。

retryable 是本页的枢纽。task:done 事件的 payload 里带一个同名布尔,由 task-observer.ts 的分类器算出:QueueFullErrorQueueStoppedErrorTimeoutError 一律记 falseTaskError 听自己的字段,其余错误乐观地记 truelab/facts.test.ts §1)。事件流与 devtools 那一页的面板上,「可重试」标记读的就是它。

警示 · TaskErrorretryable 默认是 falsenew TaskError('connection refused') 不带第二个参数时该字段取 falseretry() 拿到它一次都不重试(lab/facts.test.ts §1)。要让它进重试循环必须显式写 retryable: true,只标 type: 'network' 没有用。

调度器一侧还有一道策略,与队列与并发里的并发上限同属 SchedulerOptionsonError 默认 'continue',一个任务失败不影响后面的;改成 'stop' 后第一个 error 会让调度器进终态 stopped,pending 队列当场被 drain,每个还在排队的任务各拿一个 QueueStoppedError,之后再 enqueue 的任务也立刻拿到同样的错误(lab/facts.test.ts §1)。四个任务顺序提交、第二个失败时,'continue' 收尾于三个 success 加一个 error,'stop' 收尾于一个 success 加三个 error。

停摆这一支上有一处旧演示没跟上实现。example/cases/on-error-policies.ts 把停摆后的任务按 cancelled 分支打印成「skipped」,而实测终态是 errorscheduler.tsrejectIfBlockeddrainStopped 都用 task.fail(err) 而不是 task.skip(),注释写明这是有意的,为的是让 state、onTaskError hook 与被 reject 的 promise 说同一个故事。那条打印 skipped 的分支现在跑不到。

图 1-1 · 四个任务顺序进同一台并发 1 的运行时,第二个抛出选定的错误。可切换错误类型与 onError 策略,观察 task:done 记下的 retryable 与后续任务的去向。

2 · 重试与退避

retry(taskFn, options) 在 taskFn 外面套一个循环,retries 默认 2,总尝试次数是 1 + retriescore/scenarios.test.ts §1 锁下三条与时序无关的事实:前两次抛可重试错、retries: 3 时共 3 次尝试后成功,onAttempt 被调用 2 次;抛不可重试的 TaskError 时一次都不重试;每次都失败时尝试次数等于 1 + retries

循环有四个不进入下一次尝试的出口:CancelledErrorTimeoutErrorretryable: falseTaskError,以及调用方传的 retryable 谓词否掉的错误。前三个写死在代码里,谓词排在它们后面,这一点在 §4 会有后果。

间隔由 delay 决定,可以是一个毫秒数,也可以是 (retryIndex, error) => number。两者都不给时,若抛的是 TaskError 就读它的 retryAfterexponentialBackoff() 是现成的 delay 工厂,默认 base: 100factor: 2cap: 30_000,第 ii 次重试前等 min(30000,1002i)\min(30000, 100 \cdot 2^i) 毫秒,即 100、200、400、800(lab/facts.test.ts §2)。这条曲线叫 backoff:故障往往源于下游过载,等得越久越有希望。

它的默认 jitter'full',把算出来的延迟随机压到 [0,ms)[0, ms) 区间里。jitter 解决的是同步问题:同一时刻观察到同一次故障的所有 client 会按同一条曲线重试,在同一个瞬间再次压上来,把刚缓过来的服务重新打垮。'full' 把这批 client 摊平在整个窗口上,'equal' 保留一半延迟作下限,'decorrelated' 用上一次的延迟做种子,只能经 exponentialBackoff 取用,因为它有状态。

注 · retry() 是纯 TaskFn → TaskFn 变换,不认识 runtime,也就没有总线可发——onAttempt 的默认值就是 undefined,一次重试在事件流上不留任何痕迹。运行时从头到尾只看见一个 task:一次 task:registered、一次 task:done,中间那几次尝试全在 taskFn 内部。要把重试画进时间轴,只能自己传 onAttempt 并在里面记账(本页 lab 的 retry.store.ts 就是这么做的)。回调收到的 RetryAttemptInfo 里,次数上限那个字段叫 maxRetries(= 传给 retry()retries),不是尝试总数——attempt 是 1-based 的重试序号,初次执行不计入。

图 2-1 · retry() 的尝试时间轴,实心条是一次尝试,虚线框是尝试之间的间隔。可调 retries、失败次数、错误的可重试性与退避策略,观察尝试次数与间隔长度。

3 · 超时的两个时刻

withTimeout(taskFn, ms) 到时做的第一件事是 abort 一个从 ctx.signal 派生出来的 bridge signal,第二件事是等 taskFn 自己退出,然后才抛 TimeoutError。它不提前 resolve,也没有把并发槽提前还给调度器的办法。时间轴上因此有两个不同的时刻:timeout 触发点,与 taskFn 函数体真正退出的那一点。

协作的 taskFn 让这两点几乎重合。把 ctx.signal 交给 sleep(ms, signal)fetch(url, { signal }) 的 taskFn,在 abort 的那一刻就 reject 了(core/scenarios.test.ts §2)。不看 signal 的 taskFn 则把第二个时刻推到自己跑完为止:同一段测试里,taskFn 耗时 80ms、预算 20ms 时,协作版在 60ms 之前退出,不协作版要到 60ms 之后(同上)。两者抛的错误一模一样,代价的差别全在退出时刻上。

代价具体是什么,取决于并发上限。并发 1 时后面排队的任务要等到 taskFn 退出才起跑,超时预算于是只限制了调用方等多久,没有限制资源被占多久。这是任务与状态机 §3 那条结论在 middleware 层的复现:AbortSignal 只是一个通知,退出与否是 taskFn 的事。

TimeoutErrorretryable 写死成 false as const,是类上的只读字段而非构造参数。

图 3-1 · 同一份 timeout 预算加在协作与不协作的两个 taskFn 上,竖虚线是 timeout 触发点,三角是 taskFn 函数体的退出点。可调 taskFn 时长与 timeout,观察跟随任务被推迟多久起跑。

同一族里还有 withActivityTimeout(taskFn, ms),预算按「距离上一次 ctx.resetTimeout() 有多久」计,适合分块到达的流:只要还有数据进来就不算超时,卡住不动才算。

4 · middleware 的叠放次序

withFallback(primary, fallback, shouldFallback) 在 primary 抛错时改跑 fallback。第三个参数不传时走默认策略,而默认策略拒绝三类错误:CancelledErrorTimeoutError,以及 retryable: falseTaskErrorlab/facts.test.ts §4)。

这条默认策略值得单独记住,因为它把最常见的那个诉求排除在外了。「主接口超时就读本地缓存」在默认配置下不成立:超时抛的 TimeoutError 走的是重新抛出那一支,fallback 根本不会被调用。要让它降级,得显式传一个放行超时的 shouldFallback,此时任务终态是 success,拿到的是 fallback 的返回值(core/scenarios.test.ts §3)。默认策略的立场是:取消与超时是结构性信号,一个说调用方不要了,一个说预算耗尽了,两者都不适合用一份陈旧数据顶上;只有业务失败才是降级的场合。

三层叠起来的写法是 withFallback(withTimeout(retry(taskFn)))。中间两层换个次序,语义就不同:withTimeout(retry(taskFn)) 的预算覆盖整条重试链,retry(withTimeout(taskFn)) 则给每次尝试各配一份预算。第二种写法看起来更精细,实际用处却有限,因为 TimeoutError 不可重试,超时一旦发生,两种次序下 taskFn 都只被调用一次(lab/facts.test.ts §4)。

补一个谓词也救不回来。retryable?: (error: Error) => boolean 这个签名读起来像是能改写整套判据,实际上它在判定链的最后一环:三条硬编码出口——CancelledErrorTimeoutErrorretryable: falseTaskError——依次排在谓词之前 throw,谓词只能否决不能放行。传 retryable: () => true 之后 TimeoutError 照样一次都不重试(lab/facts.test.ts §4)。要做「每次尝试限时且超时也重试」,只能让内层 taskFn 自己捕获超时并翻译成一个 retryable: trueTaskError

建议 · 三层的常见配比是:retry 处理瞬时故障,次数给到 2–3 次并带 jitter;withTimeout 定调用方的等待上限,套在重试链外面;withFallback 只接业务失败,需要它兜住超时时把 shouldFallback 写明白。三者都是纯变换,不依赖运行时,在单元测试里可以脱离 Runtime 直接调。

图 4-1 · withFallbackwithTimeoutretry 三层 middleware的开关与叠放次序。可逐层开关并切换嵌套次序,观察主链的尝试次数与最终是否走到 fallback。

失败的处理到此为止。下一页换一个维度看生命期:一组任务如何随着组件一起消失,作用域与 effects