《互联网产品的 AI 架构·推理性能与成本工程》2.1 队列、优先级与准入

从「回声」早高峰的排队投诉出发,讨论有界队列、优先级老化与准入判据:为什么无限排队会把过载伪装成延迟,为什么纯优先级会让批量任务饿死,以及怎样用预估等待时间在入口直接拒绝。文中给出可运行的 TypeScript 队列与准入模块,并在 90 条确定性样本上比较 FIFO、优先级与老化三种顺序的真实运行结果。

一条「转圈圈」的早高峰投诉

「回声」上线后的第一个促销日,客服收到一批相似的投诉:用户在早上九点半发私信,界面一直转圈,最后提示「网络超时」。值班同学查了模型供应商,响应正常;查了网关错误率,也很低。问题出在网关自己的内存队列里:请求已经排了几十秒,但用户看到的只是最后一个超时错误。

这类故障最迷惑人的地方,是它看起来像「模型慢」。实际上模型一直在按正常速度工作,只是排队的人太多,前面的请求把后面的请求挤到了截止时间之外。队列把过载伪装成了延迟,而延迟又伪装成了网络问题。

本节讨论三个相互关联的决策:队列设多长、按什么顺序出队、什么样的请求根本不该进队列。文中所有比例与毫秒数,均来自 ai-lab 确定性模拟在示例样本上的运行结果,不是生产实测。

有界队列:把过载暴露出来

无界队列的诱惑很直接:请求不丢,全部收下,等系统慢慢处理。它的问题也同样直接:内存会涨,延迟会涨,而且这两件事都不会触发告警。队列深度是一个滞后指标,等它高到引人注意时,用户已经等了很久。

有界队列把「收不下」变成一个立刻可见的事件。队列满了就拒绝,调用方马上知道系统没有余量,可以重试、降级或提示用户。拒绝率上升是难看的指标,但它诚实;无界队列的 P99 延迟上升同样难看,却很难归因。

有界不等于越小越好。容量应当覆盖一个短促峰值的吸收量,而不是覆盖整段高峰。一个常用的起点是「并发槽位数 × 目标排队时长 / 平均服务时间」,再用压测调整。容量太小会把正常抖动当成过载,容量太大则等于把无界队列缩小了一点。

优先级:先救谁,以及会饿死谁

AI 请求天然分层:交互式对话要求首字快,标准业务可以等一两秒,离线摘要可以等几十秒。把它们放在同一个 FIFO 队列里,一个批量任务就能挡住后面所有交互请求,这就是典型的队头阻塞。

优先级队列解决队头阻塞,却引入另一个问题:低优先级任务可能永远排不上。只要高优先级流量持续存在,批量任务就会一直往后挪。这不是理论风险,批量任务通常正是成本优化最想跑的那部分,饿死它等于放弃优化收益。

做法是老化:请求每等待一段时间,有效优先级提升一级。老化必须有上限,否则低优先级会无限逼近甚至超过交互优先级。本节实现默认允许提升两级,即批量任务最多升到与交互同级;后面的对比实验只用一级,是为了观察「只把批量提到标准级」这个中间点的效果。它带来的是「有边界的优先级反转」:交互请求偶尔会被等急了的批量任务插队,但批量任务不会无限期等待。

另一种常见做法是给交互流量预留并发槽位,Running 中的推理无法抢占,所以预留比抢占更现实。老化解决排队顺序,预留解决执行资源,两者不是替代关系。

准入:在入口拒绝,而不是在出口超时

最贵的失败不是拒绝,而是让用户等满 30 秒再告诉他超时。这 30 秒里,队列槽位被占用、上游连接被占用、用户注意力也被占用,最后什么也没交付。

准入判据应当在前端回答一个问题:这个请求还赶得上它的截止时间吗?我们用排队深度、并发数和平均服务时间估一个等待时长,再加上服务时间,如果超过截止时间就直接拒绝。估算不需要精确,它只需要比「先收下再说」更早给出答案。

判据有三个分支,优先级是固定的:队列已满、预算不足、截止时间不可达。顺序不能随便换。队列满时估等待没有意义;预算不足是确定性失败,重试也不会变好;截止时间不可达才是「现在不行,也许稍后可以」。把三者混成一个 rejected 计数,会丢掉最重要的可操作信息。

实现

下面的模块只做三件事:有界优先级队列、老化排序、准入判定。它不访问网络,也不依赖时钟;所有时间都由调用方传入,因此可以在测试里精确复现。

// file: src/perf/scheduling.ts
// tier: L1
// verify: node --test test/perf/scheduling.test.ts

import { AiError } from "../core/errors.ts";

export type PriorityClass = "interactive" | "standard" | "batch";

/** 数值越小越先被调度。老化只能把等级提到 0,不能造出比交互更高的级别。 */
const CLASS_RANK: Record<PriorityClass, number> = {
  interactive: 0,
  standard: 1,
  batch: 2,
};

export interface SchedulingRequest {
  id: string;
  tenantId: string;
  priority: PriorityClass;
  enqueuedAtMs: number;
  /** 绝对截止时间,等于入队时刻加上业务可接受的等待。 */
  deadlineMs: number;
  estimatedServiceMs: number;
  estimatedCostMicros: number;
}

export interface QueueEntry {
  request: SchedulingRequest;
  sequence: number;
}

export interface PriorityQueueOptions {
  /** 有界容量;满了直接拒绝,而不是无限堆积。 */
  capacity: number;
  /** 每等待多少毫秒提升一级;Infinity 表示关闭老化。 */
  agingStepMs?: number;
  /** 最多提升几级。等级 2 的批量任务最多升到 0,与交互任务同档竞争。 */
  maxAgingLevels?: number;
  /** 对照组:忽略优先级,严格按入队顺序出队。 */
  fifo?: boolean;
}

export interface QueueSnapshot {
  depth: number;
  byClass: Record<PriorityClass, number>;
  oldestWaitMs: number;
}

function requireCount(value: number, name: string): number {
  if (!Number.isInteger(value) || value < 0) {
    throw new AiError("invalid-request", `scheduling: ${name} 必须是非负整数`);
  }
  return value;
}

function validateRequest(request: SchedulingRequest): void {
  if (request.id.trim() === "" || request.tenantId.trim() === "") {
    throw new AiError("invalid-request", "scheduling: id 与 tenantId 不能为空");
  }
  if (!(request.priority in CLASS_RANK)) {
    throw new AiError("invalid-request", `scheduling: 未知优先级 ${String(request.priority)}`);
  }
  requireCount(request.enqueuedAtMs, "enqueuedAtMs");
  requireCount(request.estimatedServiceMs, "estimatedServiceMs");
  requireCount(request.estimatedCostMicros, "estimatedCostMicros");
  if (request.deadlineMs < request.enqueuedAtMs) {
    throw new AiError("invalid-request", "scheduling: deadlineMs 不能早于入队时刻");
  }
}

/**
 * 有效等级 = 基础等级 - 已等待的老化级数。
 * 低优先级等待足够久后会追平高优先级;这是有边界的优先级反转,
 * 用来换取“批量任务不会永远排在队尾”。
 */
export function effectiveRank(
  entry: QueueEntry,
  nowMs: number,
  agingStepMs: number,
  maxAgingLevels: number,
): number {
  const base = CLASS_RANK[entry.request.priority];
  if (!Number.isFinite(agingStepMs) || agingStepMs <= 0) return base;
  const waited = Math.max(0, nowMs - entry.request.enqueuedAtMs);
  const levels = Math.min(maxAgingLevels, Math.floor(waited / agingStepMs));
  return Math.max(0, base - levels);
}

/**
 * 有界优先级队列。出队时扫描全部条目而不是维护堆:
 * 老化会随时间改变顺序,堆只能按固定键排序,扫一遍反而更诚实。
 * 容量是硬上限,常见配置在数千级,线性扫描的成本可以接受。
 */
export class PriorityQueue {
  private readonly capacity: number;
  private readonly agingStepMs: number;
  private readonly maxAgingLevels: number;
  private readonly fifo: boolean;
  private readonly entries: QueueEntry[] = [];
  private sequence = 0;

  constructor(options: PriorityQueueOptions) {
    if (!Number.isInteger(options.capacity) || options.capacity <= 0) {
      throw new AiError("invalid-request", "scheduling: capacity 必须是正整数");
    }
    this.capacity = options.capacity;
    this.agingStepMs = options.agingStepMs ?? Number.POSITIVE_INFINITY;
    this.maxAgingLevels = options.maxAgingLevels ?? 2;
    this.fifo = options.fifo ?? false;
  }

  get depth(): number {
    return this.entries.length;
  }

  enqueue(request: SchedulingRequest): { ok: true; sequence: number } | { ok: false; reason: "queue-full" } {
    validateRequest(request);
    if (this.entries.length >= this.capacity) {
      return { ok: false, reason: "queue-full" };
    }
    const entry: QueueEntry = { request: { ...request }, sequence: this.sequence };
    this.sequence += 1;
    this.entries.push(entry);
    return { ok: true, sequence: entry.sequence };
  }

  /** 取出当前最该执行的请求;同等级按入队顺序先进先出。 */
  dequeue(nowMs: number): SchedulingRequest | undefined {
    if (this.entries.length === 0) return undefined;
    let bestIndex = 0;
    const rankOf = (entry: QueueEntry): number =>
      this.fifo ? 0 : effectiveRank(entry, nowMs, this.agingStepMs, this.maxAgingLevels);
    let bestRank = rankOf(this.entries[0]);
    for (let index = 1; index < this.entries.length; index += 1) {
      const rank = rankOf(this.entries[index]);
      if (
        rank < bestRank ||
        (rank === bestRank && this.entries[index].sequence < this.entries[bestIndex].sequence)
      ) {
        bestRank = rank;
        bestIndex = index;
      }
    }
    const [entry] = this.entries.splice(bestIndex, 1);
    return entry.request;
  }

  snapshot(nowMs: number): QueueSnapshot {
    const byClass: Record<PriorityClass, number> = { interactive: 0, standard: 0, batch: 0 };
    let oldest = 0;
    for (const entry of this.entries) {
      byClass[entry.request.priority] += 1;
      oldest = Math.max(oldest, nowMs - entry.request.enqueuedAtMs);
    }
    return { depth: this.entries.length, byClass, oldestWaitMs: Math.max(0, oldest) };
  }
}

/** 用排队深度与服务时间估算新请求还要等多久,不依赖任何历史分布。 */
export function estimateWaitMs(queueDepth: number, concurrency: number, averageServiceMs: number): number {
  if (queueDepth <= 0) return 0;
  if (!Number.isInteger(concurrency) || concurrency <= 0) {
    throw new AiError("invalid-request", "scheduling: concurrency 必须是正整数");
  }
  return Math.ceil(queueDepth / concurrency) * Math.max(0, averageServiceMs);
}

export type AdmissionRejection = "queue-full" | "budget-exceeded" | "deadline-infeasible";

export interface AdmissionInput {
  request: SchedulingRequest;
  nowMs: number;
  queueDepth: number;
  capacity: number;
  concurrency: number;
  averageServiceMs: number;
  remainingBudgetMicros: number;
}

export interface AdmissionDecision {
  admit: boolean;
  rejection: AdmissionRejection | null;
  estimatedWaitMs: number;
  estimatedCompletionMs: number;
}

/**
 * 准入判据:预算不够、队列已满、或预计完成时间晚于截止时间,都直接拒绝。
 * 拒绝发生在前端,成本只是一次判断;排队后再超时则同时浪费队列槽位与用户等待。
 */
export function decideAdmission(input: AdmissionInput): AdmissionDecision {
  const waitMs = estimateWaitMs(input.queueDepth, input.concurrency, input.averageServiceMs);
  const completionMs = input.nowMs + waitMs + Math.max(0, input.request.estimatedServiceMs);
  const base = { estimatedWaitMs: waitMs, estimatedCompletionMs: completionMs };
  if (input.queueDepth >= input.capacity) {
    return { admit: false, rejection: "queue-full", ...base };
  }
  if (input.request.estimatedCostMicros > input.remainingBudgetMicros) {
    return { admit: false, rejection: "budget-exceeded", ...base };
  }
  if (completionMs > input.request.deadlineMs) {
    return { admit: false, rejection: "deadline-infeasible", ...base };
  }
  return { admit: true, rejection: null, ...base };
}

PriorityQueue 出队时扫描全部条目,而不是维护堆。原因是老化会随时间改变排序键,堆只能按固定键维持顺序;对数千级容量的队列,线性扫描换来的是可读且可验证的行为。真要支撑十万级队列,应改用分层时间轮或分段队列,而不是给这段实现加缓存。

decideAdmission 把拒绝原因单独返回,而不是抛异常。准入是正常业务分支,不是程序错误;调用方需要根据原因决定回 429、回 503 还是直接走模板降级。

测试与运行结果

测试覆盖 FIFO 顺序、优先级顺序、老化等级、容量上限、三类准入拒绝,以及三种调度顺序在固定样本上的对比。执行:

node --test test/perf/scheduling.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 14
ℹ pass 14
ℹ fail 0

模拟样本由 30 条交互、30 条标准、30 条批量组成,共 90 条;2 个并发槽位,每 120ms 到达的请求需要约 310ms 服务时间,负载约为容量的 1.29 倍。三种顺序在同一批样本上的结果如下,均为示例样本上的运行结果:

顺序按时完成迟到显式拒绝交互 P95 等待批量 P95 等待
FIFO8208560ms510ms
纯优先级6818470ms1670ms
优先级 + 600ms 老化759690ms1140ms

纯优先级把交互 P95 等待从 560ms 压到 70ms,代价是批量 P95 等待升到 1670ms,30 条批量里有 18 条迟到。老化把批量 P95 拉回 1140ms、迟到降到 9 条,交互 P95 只从 70ms 升到 90ms。这组数字说明:老化不是免费午餐,它买的是「批量任务不会无限等」,付的是「交互偶尔被插队」。

准入的对照更能说明它的定位。同一份老化配置下,关闭准入会得到 16 条迟到、0 条显式拒绝;打开准入后变成 9 条迟到加 6 条显式拒绝,P95 等待从 1260ms 降到 1020ms。迟到总数没有消失,只是其中一部分被提前转换成了明确的拒绝。准入的价值是让失败更早、更可解释,而不是凭空增加容量。

小结

队列解决的是「先做哪个」,准入解决的是「哪些根本不做」。有界队列让过载可见;优先级让紧急请求先走;老化让低优先级不被饿死;准入用预估完成时间把注定超时的请求挡在入口。

下一节把视角从「排队顺序」转到「同时在跑多少」:并发上限如何推导、信号量如何工作,以及背压、降级、拒绝三者的边界。

阅读导航:上一节:1.3 压测基线与负载模型 · 下一节:2.2 并发上限与背压 。

常见错误与边界

  • 用无界队列「先收下再说」。过载被伪装成延迟,直到内存或超时先爆。
  • 只设队列长度,不设拒绝原因。调用方拿到一个笼统的失败,无法决定重试还是降级。
  • 认为优先级能提升总吞吐。它只改变顺序,不增加容量。
  • 不给低优先级做老化。批量任务会在持续的高优先级流量下永久等待。
  • 老化没有上限。低优先级会反超高优先级,交互体验被批量任务拖垮。
  • 把准入估算当成精确预测。它只是比「先排队」更早给出答案,误差要靠监控修正。
  • 三个拒绝原因的优先级随意排列。预算不足被当成「稍后重试」,用户会白等。
  • 把降级、拒绝、超时混成一个错误率。三个指标对应三种完全不同的处置。
  • 只按请求数设容量,不看每个请求的服务时间。长请求会让实际负载远超估计。
  • 在单机上验证队列策略就宣布结论。多实例、重试放大和上游限流都会改变到达分布。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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