首屏的十七个请求
「回声」的发现页首屏看起来只有十二张卡片,实际发出的请求却是十七个:十二条内容各要一次详情,作者信息合并成一次批量查询,封面图另走图片通道,再加推荐位、未读数与实验配置。在办公室 Wi-Fi 下,这些请求几乎同时返回,页面在一秒内拼好;到了地铁里,它们争抢同一条不稳定的连接,用户看到的是每一张卡片都在转圈,却没有一张先出来。
问题不在某个请求写得慢,而在没有一个地方回答“这些请求应该怎么排队”。浏览器对同源并发有默认限制,图片还有自己的连接池,但应用层没有优先级、没有总预算、没有统一的取消入口。每个组件各自 fetch,失败各自重试,最后叠加出的是不可预测的流量形状。
数据层的第一件事不是缓存,而是把请求当成一个需要调度的整体。本节要回答五个问题:同时发多少、先发谁、不要了怎么停、总共能等多久、失败后怎么再来。答案会落成一个不依赖 DOM 与真实定时器的调度器,所有时间来源从外部注入,测试可以精确控制超时与退避。
文中出现的数值,除非标注为本机运行输出,都是案例假设值,用来演示取舍,不是任何真实产品的实测。
调度器要回答的五个问题
并发上限
并发上限不是越大越好。端上的连接数、CPU、内存都有限,弱网下同时开太多请求会让每个请求都变慢,还会挤掉真正关键的那一个。案例里把上限设为 4:首屏内容、作者信息、未读数、配置四类可以并行,其余排队。
上限应该按网络档位动态调整:Wi-Fi 下可以到 6,弱网降到 2,离线直接为 0 并把请求转进离线队列。调度器本身只接收一个数字,档位判断属于网络状态模块,两者不要混在一起。
优先级
优先级回答“谁先出队”。案例用三档:0 是阻塞首屏的关键请求,1 是当前可见区域,2 是预取与埋点。数字越小越先执行,同优先级按提交顺序,保证行为可预测。
优先级必须有饿死保护。如果 0 档请求源源不断,2 档可能永远轮不到。调度器没有实现老化,这是有意的边界:端上的高优先级请求通常有明确上限(首屏就那么几个),真正需要老化的是持续高频的后台任务,应该由提交方限流,而不是让调度器猜。
取消
用户滑走列表、退出页面、切换筛选条件,之前发出的请求就不再需要。取消有两个层次:不再关心结果,以及不再占用网络。前者只需忽略回调,后者必须把信号传到请求实现里。
调度器采用协作式取消:排队中的任务直接从队列移除并立即拒绝;运行中的任务置位 aborted 并立即释放并发槽位,底层实现需要轮询这个信号并主动断开。调度器不能替底层杀掉一个已经发出的 HTTP 请求,这是运行时边界,不是设计遗漏。
总预算
总预算不是单次超时。一个任务重试三次,每次各给 2 秒,用户实际等了 6 秒还多。正确做法是给整个任务一个预算,例如 2.5 秒,首次尝试、退避等待、后续尝试都从同一个池子里扣。预算耗尽就失败,不再重试。
预算还要和退避一起判断:如果下一次退避之后剩余时间已经不够一次有意义的尝试,就直接超时,而不是先睡再失败。这段判断在实现里是 spent + delay >= budget,它避免了“等了两秒,发出一毫秒就超时”的假重试。
重试与退避
只重试值得重试的错误:网络中断、5xx、超时可以在预算内重试;参数错误、鉴权失败、业务拒绝不应该重试。案例用错误对象上的 retryable 字段表达这个区别,默认允许重试,显式设为 false 时立即失败。
退避采用指数增长加抖动:100ms、200ms、400ms,上限 2 秒,每次乘以 0.8 到 1.2 之间的随机系数。抖动的作用是避免大量客户端在同一时刻一起重试,把服务端从“短暂抖动”推成“雪崩”。测试注入固定的 random,让退避序列可断言。
调度器实现
下面这个文件把五个问题收进一个类。它不依赖 DOM、fetch 或真实定时器:now 与可取消的 sleep 都从构造参数注入,因此测试可以在几毫秒内走完两秒的预算。
// file: src/data/request-scheduler.ts
// tier: L1
// verify: node --test test/data/request-scheduler.test.ts
export type Priority = 0 | 1 | 2;
/** 只读取消信号:任务内部轮询 aborted,不依赖真实 AbortController 的时序。 */
export interface CancelSignal {
readonly aborted: boolean;
}
/** 可取消的 sleep。测试注入手动时钟,生产环境用 setTimeout 实现。 */
export interface TimerHandle {
promise: Promise<void>;
cancel: () => void;
}
export interface TaskSpec<T> {
key: string;
/** 0 最高,2 最低;同优先级按提交顺序执行 */
priority?: Priority;
/** 整个任务(含重试与退避)的总预算,毫秒 */
budgetMs: number;
/** 含首次在内的总尝试次数,默认 3 */
maxAttempts?: number;
run: (signal: CancelSignal) => Promise<T>;
}
export interface SchedulerOptions {
maxConcurrency: number;
now: () => number;
sleep: (ms: number) => TimerHandle;
/** 返回 [0,1),测试注入 0.5 可以去掉抖动 */
random?: () => number;
baseBackoffMs?: number;
maxBackoffMs?: number;
}
export class TaskCancelledError extends Error {
readonly key: string;
constructor(key: string) {
super(`task cancelled: ${key}`);
this.name = "TaskCancelledError";
this.key = key;
}
}
export class TaskTimeoutError extends Error {
readonly key: string;
readonly budgetMs: number;
constructor(key: string, budgetMs: number) {
super(`task exceeded budget: ${key} (${budgetMs}ms)`);
this.name = "TaskTimeoutError";
this.key = key;
this.budgetMs = budgetMs;
}
}
interface QueueItem<T> {
spec: TaskSpec<T>;
seq: number;
priority: Priority;
controller: { aborted: boolean };
onCancel: (() => void) | null;
resolve: (value: T) => void;
reject: (reason: unknown) => void;
}
function isRetryable(error: unknown): boolean {
if (error instanceof TaskCancelledError) return false;
if (typeof error === "object" && error !== null && "retryable" in error) {
return (error as { retryable?: boolean }).retryable !== false;
}
return true;
}
/** 并发上限 + 优先级 + 去重 + 取消 + 总预算 + 指数退避,时间来源全部注入。 */
export class RequestScheduler {
private readonly maxConcurrency: number;
private readonly now: () => number;
private readonly sleep: (ms: number) => TimerHandle;
private readonly random: () => number;
private readonly baseBackoffMs: number;
private readonly maxBackoffMs: number;
private readonly queue: QueueItem<unknown>[] = [];
private readonly running = new Map<string, QueueItem<unknown>>();
private readonly inflight = new Map<string, Promise<unknown>>();
private active = 0;
private seq = 0;
constructor(options: SchedulerOptions) {
if (options.maxConcurrency < 1) throw new Error("maxConcurrency must be >= 1");
this.maxConcurrency = options.maxConcurrency;
this.now = options.now;
this.sleep = options.sleep;
this.random = options.random ?? Math.random;
this.baseBackoffMs = options.baseBackoffMs ?? 100;
this.maxBackoffMs = options.maxBackoffMs ?? 2_000;
}
get stats(): { active: number; queued: number } {
return { active: this.active, queued: this.queue.length };
}
/** 同一 key 在未结束前只执行一次;失败或取消结束后允许重新提交。 */
submit<T>(spec: TaskSpec<T>): Promise<T> {
const existing = this.inflight.get(spec.key);
if (existing) return existing as Promise<T>;
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const task = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
const item: QueueItem<T> = {
spec,
seq: this.seq++,
priority: spec.priority ?? 1,
controller: { aborted: false },
onCancel: null,
resolve,
reject,
};
this.inflight.set(spec.key, task);
this.queue.push(item as QueueItem<unknown>);
const cleanup = (): void => {
if (this.inflight.get(spec.key) === task) this.inflight.delete(spec.key);
};
task.then(cleanup, cleanup);
this.pump();
return task;
}
/** 排队中取消立即拒绝;运行中取消置位信号并释放槽位,底层必须轮询 signal。 */
cancel(key: string): boolean {
const index = this.queue.findIndex((item) => item.spec.key === key);
if (index >= 0) {
const [item] = this.queue.splice(index, 1);
item.reject(new TaskCancelledError(key));
return true;
}
const running = this.running.get(key);
if (running) {
running.controller.aborted = true;
running.onCancel?.();
return true;
}
return false;
}
private pump(): void {
if (this.active >= this.maxConcurrency) return;
this.queue.sort((a, b) => a.priority - b.priority || a.seq - b.seq);
while (this.active < this.maxConcurrency && this.queue.length > 0) {
const item = this.queue.shift();
if (!item) break;
this.active += 1;
this.running.set(item.spec.key, item);
this.execute(item).then(item.resolve, item.reject).finally(() => {
this.active -= 1;
this.running.delete(item.spec.key);
this.pump();
});
}
}
private async execute<T>(item: QueueItem<T>): Promise<T> {
const startedAt = this.now();
const attempts = item.spec.maxAttempts ?? 3;
let lastError: unknown = new Error("unreachable");
for (let attempt = 1; attempt <= attempts; attempt += 1) {
if (item.controller.aborted) throw new TaskCancelledError(item.spec.key);
const remaining = item.spec.budgetMs - (this.now() - startedAt);
if (remaining <= 0) throw new TaskTimeoutError(item.spec.key, item.spec.budgetMs);
try {
return await this.attempt(item, remaining);
} catch (error) {
lastError = error;
if (error instanceof TaskCancelledError) throw error;
if (!isRetryable(error) || attempt === attempts) throw error;
const delay = this.backoff(attempt);
if (this.now() - startedAt + delay >= item.spec.budgetMs) {
throw new TaskTimeoutError(item.spec.key, item.spec.budgetMs);
}
await this.sleep(delay).promise;
}
}
throw lastError;
}
/** 一次尝试的竞速:真实请求、超时、取消三者先到先算。 */
private attempt<T>(item: QueueItem<T>, remaining: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
let settled = false;
const finish = (action: () => void): void => {
if (settled) return;
settled = true;
item.onCancel = null;
action();
};
const timer = this.sleep(remaining);
let running: Promise<T>;
try {
running = item.spec.run(item.controller.signal);
} catch (error) {
timer.cancel();
finish(() => reject(error));
return;
}
running.then(
(value) => {
timer.cancel();
finish(() => resolve(value));
},
(error) => {
timer.cancel();
finish(() => reject(error));
},
);
timer.promise.then(() => {
finish(() => reject(new TaskTimeoutError(item.spec.key, item.spec.budgetMs)));
});
item.onCancel = () => finish(() => reject(new TaskCancelledError(item.spec.key)));
});
}
private backoff(attempt: number): number {
const base = Math.min(this.maxBackoffMs, this.baseBackoffMs * 2 ** (attempt - 1));
const jitter = 0.2;
return Math.round(base * (1 - jitter + 2 * jitter * this.random()));
}
}
代码里有几个决定值得单独说明。
去重发生在入队之前。 inflight 以 key 为索引,同一 key 在结束前只对应一个 Promise。发现页滚动时,同一个详情可能被三个组件同时请求,最终只发一次。失败或取消后必须删除索引,否则后续重试会一直拿到同一个已经拒绝的 Promise。
超时是竞速,不是轮询。 每次尝试同时等待三件事:真实请求、预算计时器、取消信号,先到先算。计时器在请求结束时取消,避免留下无意义的回调。实现里先启动请求再挂计时器回调,是因为在手动时钟下,sleep(0) 可能比一个已经完成的 Promise 更早进入微任务队列;顺序写错,测试会出现“请求明明成功却被判超时”的假失败。
取消立即释放槽位,但不保证底层停止。 这是协作式取消的代价。如果底层不轮询信号,请求会继续占用网络,只是结果被丢弃。生产实现应该在 run 里把信号接到 AbortController,并在每次 await 后检查。
退避与预算共用同一个时钟。 execute 在每次尝试前重新计算剩余预算,退避等待也推进时钟。测试里的手动时钟会记录每个计时器的时长,因此“先等 100ms、再等 200ms”是可验证的事实,而不是靠日志猜测。
测试与真实输出
测试覆盖去重、并发上限、优先级、两种取消、退避、预算耗尽、不可重试错误与失败后重新提交九个场景。时间全部由手动时钟推进,不等待真实时间:
node --test test/data/request-scheduler.test.ts
本机一次运行的真实输出如下,耗时随机器变化:
✔ 同一 key 的并发提交只执行一次 (2.463167ms)
✔ 并发上限约束真实执行数,完成后自动补位 (1.186ms)
✔ 高优先级任务先出队,同优先级保持提交顺序 (0.593709ms)
✔ 取消排队中的任务立即拒绝,且不会执行 (0.642667ms)
✔ 取消运行中的任务会释放槽位 (0.409292ms)
✔ 失败按指数退避重试,成功后停止 (0.647417ms)
✔ 总预算耗尽时抛出超时,而不是继续退避 (0.540042ms)
✔ 不可重试错误直接失败 (0.167375ms)
✔ 失败结束后同一 key 可以重新提交 (0.483375ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 210.316666
输出里的耗时是整次运行的墙钟时间,不是单个用例的累计。判定标准只有两个:退出码为 0,以及 fail 0。九个用例能在两百毫秒内跑完,是因为没有任何一处调用真实 setTimeout;如果某个测试突然变慢,通常意味着有实现绕过注入的时钟。
一次无上限重试的事故
某次版本里,详情页为了“提高成功率”,给每个请求加了五次重试,单次超时 3 秒,退避从 200ms 开始。上线后弱网用户反馈“页面一直白屏,流量还特别大”。复盘发现三个问题叠加:
- 首屏十七个请求同时发出,没有并发上限,弱网下互相拖慢,几乎全部触发超时。
- 超时后各自重试,最坏情况下 17 × 5 = 85 次尝试,把本就拥塞的连接彻底压死。
- 重试没有总预算,用户等了二十多秒,页面仍然没有可用内容。
根因不是重试本身,而是重试没有被编排。修复方案不是“把重试次数改成两次”,而是把并发、优先级、预算与退避放进同一个调度器:首屏关键请求优先,图片与预取降级,每个任务独立预算,失败后由页面决定是否重新提交。重试次数只是预算下的一个参数,不再是独立策略。
这个事故也说明,客户端的“失败”很少是单点失败。一个请求变慢会改变其他请求的排队位置,最终表现为整体不可用。服务端可以用扩容吸收突发流量,客户端只能靠调度保持形状。
与服务端限流的对照
服务端也做流量控制,但目标与手段不同:
| 维度 | 服务端限流 | 客户端调度 |
|---|---|---|
| 目标 | 保护自身与下游 | 保护用户体验与设备资源 |
| 决策位置 | 入口网关或服务内部 | 请求发出之前 |
| 主要手段 | 令牌桶、漏桶、并发信号量 | 队列、优先级、预算、取消 |
| 拒绝方式 | 429 与 Retry-After | 本地排队或直接失败 |
| 观测 | 集中指标与日志 | 端上统计,可能丢失 |
两者不是替代关系。服务端限流保护系统,客户端调度保护体验;客户端做得好,服务端收到的流量形状更平滑,但服务端仍然必须有自己的限流,因为客户端版本不可控,旧版本可能完全没有调度。
小结
请求编排把“很多个请求”变成一个可管理的整体:并发上限控制形状,优先级决定顺序,取消释放资源,总预算约束等待,退避与重试处理失败。五者必须一起设计,单独调任何一个参数都可能把问题推到别处。
调度器的所有时间来源都应该可注入。这样超时、退避与取消才能被确定性测试,而不是靠“跑一遍看看”。本节的手动时钟不是为了炫技,而是为了让“预算耗尽时不再退避”这种边界成为一条可回归的断言。
下一节转向读路径:请求发出去之后,结果放在哪里、什么时候算过期、离线时还能不能读。
阅读导航:上一节:6.3 深链与状态恢复 · 下一节:7.2 端上缓存与离线优先 。
常见错误与边界
- 把单次超时当总预算。三次重试各给三秒,用户实际等了九秒以上。
- 重试非幂等写请求。超时只说明“不知道结果”,不代表服务端没有执行。
- 去重键不含参数。不同筛选条件共用同一个 key,用户看到的是上一次的结果。
- 失败后不清理 inflight。后续重试永远拿到同一个已拒绝的 Promise。
- 取消只清 UI 不停请求。页面已经离开,网络与电量还在为旧请求付出。
- 退避没有抖动。大量客户端在同一时刻重试,会把短暂抖动放大成雪崩。
- 无限重试或预算不设上限。弱网下等于主动制造拥塞。
- 优先级没有饿死保护。低优先级任务可能永远排不到,埋点与预取最先受害。
- 用调度器替代服务端限流。客户端版本不可控,服务端必须保留自己的保护。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。