一条「转圈圈」的早高峰投诉
「回声」上线后的第一个促销日,客服收到一批相似的投诉:用户在早上九点半发私信,界面一直转圈,最后提示「网络超时」。值班同学查了模型供应商,响应正常;查了网关错误率,也很低。问题出在网关自己的内存队列里:请求已经排了几十秒,但用户看到的只是最后一个超时错误。
这类故障最迷惑人的地方,是它看起来像「模型慢」。实际上模型一直在按正常速度工作,只是排队的人太多,前面的请求把后面的请求挤到了截止时间之外。队列把过载伪装成了延迟,而延迟又伪装成了网络问题。
本节讨论三个相互关联的决策:队列设多长、按什么顺序出队、什么样的请求根本不该进队列。文中所有比例与毫秒数,均来自 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 等待 |
|---|---|---|---|---|---|
| FIFO | 82 | 0 | 8 | 560ms | 510ms |
| 纯优先级 | 68 | 18 | 4 | 70ms | 1670ms |
| 优先级 + 600ms 老化 | 75 | 9 | 6 | 90ms | 1140ms |
纯优先级把交互 P95 等待从 560ms 压到 70ms,代价是批量 P95 等待升到 1670ms,30 条批量里有 18 条迟到。老化把批量 P95 拉回 1140ms、迟到降到 9 条,交互 P95 只从 70ms 升到 90ms。这组数字说明:老化不是免费午餐,它买的是「批量任务不会无限等」,付的是「交互偶尔被插队」。
准入的对照更能说明它的定位。同一份老化配置下,关闭准入会得到 16 条迟到、0 条显式拒绝;打开准入后变成 9 条迟到加 6 条显式拒绝,P95 等待从 1260ms 降到 1020ms。迟到总数没有消失,只是其中一部分被提前转换成了明确的拒绝。准入的价值是让失败更早、更可解释,而不是凭空增加容量。
小结
队列解决的是「先做哪个」,准入解决的是「哪些根本不做」。有界队列让过载可见;优先级让紧急请求先走;老化让低优先级不被饿死;准入用预估完成时间把注定超时的请求挡在入口。
下一节把视角从「排队顺序」转到「同时在跑多少」:并发上限如何推导、信号量如何工作,以及背压、降级、拒绝三者的边界。
阅读导航:上一节:1.3 压测基线与负载模型 · 下一节:2.2 并发上限与背压 。
常见错误与边界
- 用无界队列「先收下再说」。过载被伪装成延迟,直到内存或超时先爆。
- 只设队列长度,不设拒绝原因。调用方拿到一个笼统的失败,无法决定重试还是降级。
- 认为优先级能提升总吞吐。它只改变顺序,不增加容量。
- 不给低优先级做老化。批量任务会在持续的高优先级流量下永久等待。
- 老化没有上限。低优先级会反超高优先级,交互体验被批量任务拖垮。
- 把准入估算当成精确预测。它只是比「先排队」更早给出答案,误差要靠监控修正。
- 三个拒绝原因的优先级随意排列。预算不足被当成「稍后重试」,用户会白等。
- 把降级、拒绝、超时混成一个错误率。三个指标对应三种完全不同的处置。
- 只按请求数设容量,不看每个请求的服务时间。长请求会让实际负载远超估计。
- 在单机上验证队列策略就宣布结论。多实例、重试放大和上游限流都会改变到达分布。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。