《互联网产品的客户端架构·端形态与体验基础》7.1 请求编排与去重

从「回声」发现页首屏同时发出的十七个请求出发,把并发上限、优先级、取消、总预算与指数退避放进同一张调度表,给出可运行的 TypeScript 调度器与九个注入手动时钟的确定性测试,说明去重键、超时竞速与协作式取消的边界,并复盘一次无上限重试把弱网彻底拖垮的事故。

首屏的十七个请求

「回声」的发现页首屏看起来只有十二张卡片,实际发出的请求却是十七个:十二条内容各要一次详情,作者信息合并成一次批量查询,封面图另走图片通道,再加推荐位、未读数与实验配置。在办公室 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 不停请求。页面已经离开,网络与电量还在为旧请求付出。
  • 退避没有抖动。大量客户端在同一时刻重试,会把短暂抖动放大成雪崩。
  • 无限重试或预算不设上限。弱网下等于主动制造拥塞。
  • 优先级没有饿死保护。低优先级任务可能永远排不到,埋点与预取最先受害。
  • 用调度器替代服务端限流。客户端版本不可控,服务端必须保留自己的保护。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查