容量只够六成时,先给谁
大促当天,回声的共享推理池只剩 20,000 微元可用。队列里同时有内容团队的三个摘要任务、客服团队的一条回复、交易团队的一条下单咨询。按先来先到,内容团队的批量摘要会先跑完;按成本从低到高,下单咨询便宜,但它可能只差一次模型判断就能成交。
两种排法都能给出一个顺序,但都不是治理意义上的公平。先来先到奖励的是「谁先提交」,成本排序奖励的是「谁便宜」,两者都没有回答「资源紧张时,平台承诺先保谁」。
公平性要同时看三层:租户之间的合同份额、团队之间的窗口、任务本身的价值。三层都写清楚,调度顺序才是可解释、可验收的。
三层公平判据
| 层 | 判据 | 证据从哪里来 | 失效时的表现 |
|---|---|---|---|
| 租户 | 合同份额与当前用量 | 三层配额账本 | 大租户挤占小租户 |
| 团队 | 团队窗口与队列深度 | 调度快照 | 一个团队占满整轮 |
| 任务 | critical / standard / batch | 链路结构声明 | 低价值任务挡住关键动作 |
租户层解决「谁跟谁分」。同样是 1,000 次调用,大租户合同里买了 60% 的容量,就不能因为提交得早而拿走 90%。租户层的判据不是「谁先到」,而是「本窗口内已占用多少、合同允许多少」。
团队层解决「租户内部怎么分」。一个租户下可能有内容、客服、交易三个团队,它们的份额已经由 4.1 的配额定义。公平调度要保证单轮里没有哪个团队把窗口占满,否则其他团队即使有份额也排不进去。
任务层解决「同一团队内部先做哪件」。内容团队既有当天要发布的摘要,也有夜间回填的历史摘要;两者都合法,但价值不同。平台不能替业务判断价值,只能要求业务在链路声明里标注价值分类,并对标注负责。
价值分类先于基础优先级
优先级最容易变成「谁嗓门大谁高」。平台给的解法是把两个概念分开:价值分类由链路声明决定,基础优先级由业务在分类内部微调。价值分类的量级远大于基础优先级,后者就无法越过前者。
| 分类 | 回声中的例子 | 默认权重 | 允许的基础优先级 |
|---|---|---|---|
| critical | 下单咨询、客服回复 | 1,000 | 0–100 |
| standard | 内容发布前的摘要与审核 | 300 | 0–100 |
| batch | 夜间回填、离线重排 | 100 | 0–100 |
示例里 batch 任务把基础优先级拉到 95,critical 只有 10。若只比基础优先级,batch 会排前面;加入价值权重后,critical 得分 1,010,batch 只有 195,顺序被纠正。这不是「critical 永远正确」,而是「基础优先级只能在分类内部比较」。
分类本身要可审计。链路声明里写 critical,必须能回答:这条链路失败会影响什么?是营收动作、对外承诺,还是内部报表?答不上来的,默认降到 standard。分类被滥用一次,整套权重就失去意义。
优先级反转的两个来源
第一个来源是排队顺序。FIFO 会把「先提交」误当成「更重要」,一个夜间回填任务只要比下单咨询早入队几毫秒,就能挡住后者。解法不是取消 FIFO,而是让分数包含价值分类,FIFO 只作为同分时的稳定排序。
第二个来源是共享资源被长期占用。一个 batch 任务拿到工作线程后跑很久,critical 任务只能等它结束。配额与调度能决定「谁先开始」,但不能在模型调用中途抢占。平台能做的是把长任务切小、在节点边界检查优先级、给 critical 预留并发,而不是承诺「随时抢占」。
代码里的分数由四部分组成:价值权重、基础优先级、有上限的老化、强制位。
score = valueWeight[value] + basePriority + min(waitTicks, maxAgingTicks) * agingPerTick
+ (waitTicks >= starvationTicks ? 10000 : 0)
老化的上限是 10 个 tick,每 tick 加 40,最多加 400。这个上限保证 batch 靠等待最多追到 600 分,仍然越不过 critical 的 1,000 分底。强制位则是另一回事,它只在等待超过 12 个 tick 时触发,直接加 10,000 分,让饥饿任务插到最前。
饥饿防护要有配额
只有老化没有强制位,低价值任务在持续高负载下可能永远轮不到;只有强制位没有上限,饥饿防护会变成新的插队工具,每个 batch 任务等够 12 轮就插到 critical 前面。
示例策略给强制位加了三道限制:等待至少 12 个 tick;每轮每个租户最多强制 1 个;强制任务仍然受租户与团队窗口约束。这样最坏情况是 critical 被一个饥饿任务挤后一个位置,而不是整轮被 batch 占满。
这是一个明确的取舍:用一次受控的反转,换低价值任务不被无限期饿死。平台要把这个取舍写进策略,而不是让每个团队自己实现一套「等久了就提优先级」。
调度实现
fairness.ts 只做三件事:给任务打分、按分数排序、在租户与团队窗口内选出一轮。它不读数据库、不调用模型,输入是任务数组与容量,输出是可复核的顺序与每个租户、团队的占用。
// file: src/gov/fairness.ts
// tier: L2
// verify: node --test test/gov/quota.test.ts
/** 任务价值分类是公平判据的第一层,不直接用「谁先到」。 */
export type ValueClass = "critical" | "standard" | "batch";
export interface FairTask {
taskId: string;
tenantId: string;
teamId: string;
value: ValueClass;
/** 业务声明的基础优先级,0..100;它不能越过价值分类。 */
basePriority: number;
waitTicks: number;
costMicros: number;
}
export interface FairnessPolicy {
valueWeight: Readonly<Record<ValueClass, number>>;
agingPerTick: number;
maxAgingTicks: number;
/** 单轮中每个租户、每个团队最多被选中的任务数。 */
perTenantWindow: number;
perTeamWindow: number;
/** 等待超过该 tick 数的任务强制进入候选。 */
starvationTicks: number;
/** 单轮每租户最多强制几个任务,防止饥饿防护变成新的插队。 */
maxForced: number;
}
export const DEFAULT_FAIRNESS: FairnessPolicy = {
valueWeight: { critical: 1000, standard: 300, batch: 100 },
agingPerTick: 40,
maxAgingTicks: 10,
perTenantWindow: 2,
perTeamWindow: 2,
starvationTicks: 12,
maxForced: 1,
};
export interface RankedTask {
task: FairTask;
score: number;
forced: boolean;
}
/**
* 分数 = 价值权重 + 基础优先级 + 有上限的老化 + 强制位。
* 价值权重量级远大于优先级,保证 critical 不会被 batch 的高 basePriority 反转。
*/
export function scoreTask(task: FairTask, policy: FairnessPolicy): RankedTask {
const aged = Math.min(task.waitTicks, policy.maxAgingTicks) * policy.agingPerTick;
const forced = task.waitTicks >= policy.starvationTicks;
const score = policy.valueWeight[task.value] + task.basePriority + aged + (forced ? 10_000 : 0);
return { task, score, forced };
}
export function rankTasks(tasks: readonly FairTask[], policy: FairnessPolicy): RankedTask[] {
return tasks
.map((task) => scoreTask(task, policy))
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.task.taskId.localeCompare(right.task.taskId);
});
}
export interface ScheduleResult {
admitted: FairTask[];
deferred: FairTask[];
order: string[];
spentMicros: number;
byTenant: Record<string, { count: number; micros: number }>;
byTeam: Record<string, { count: number; micros: number }>;
}
function bump(
table: Record<string, { count: number; micros: number }>,
key: string,
micros: number,
): void {
const bucket = table[key] ?? { count: 0, micros: 0 };
bucket.count += 1;
bucket.micros += micros;
table[key] = bucket;
}
/**
* 单轮调度:按分数选任务,租户与团队各有一个窗口上限。
* 容量不够时跳过当前任务继续看后面的小任务,而不是整轮结束。
*/
export function scheduleRound(
tasks: readonly FairTask[],
capacityMicros: number,
policy: FairnessPolicy = DEFAULT_FAIRNESS,
): ScheduleResult {
const ranked = rankTasks(tasks, policy);
const admitted: FairTask[] = [];
const deferred: FairTask[] = [];
const byTenant: ScheduleResult["byTenant"] = {};
const byTeam: ScheduleResult["byTeam"] = {};
const forcedByTenant: Record<string, number> = {};
let spent = 0;
for (const entry of ranked) {
const task = entry.task;
const tenant = byTenant[task.tenantId] ?? { count: 0, micros: 0 };
const team = byTeam[task.teamId] ?? { count: 0, micros: 0 };
const forcedUsed = forcedByTenant[task.tenantId] ?? 0;
const forcedBlocked = entry.forced && forcedUsed >= policy.maxForced;
if (
forcedBlocked ||
tenant.count >= policy.perTenantWindow ||
team.count >= policy.perTeamWindow ||
task.costMicros > capacityMicros - spent
) {
deferred.push(task);
continue;
}
admitted.push(task);
if (entry.forced) forcedByTenant[task.tenantId] = forcedUsed + 1;
spent += task.costMicros;
bump(byTenant, task.tenantId, task.costMicros);
bump(byTeam, task.teamId, task.costMicros);
}
return {
admitted,
deferred,
order: admitted.map((task) => task.taskId),
spentMicros: spent,
byTenant,
byTeam,
};
}
/** 公平性验收:单租户占用不超过「均分 + 一个任务」视为通过。 */
export function maxTenantDeviation(result: ScheduleResult): number {
const values = Object.values(result.byTenant).map((item) => item.micros);
if (values.length === 0) return 0;
const max = Math.max(...values);
const min = Math.min(...values);
return max - min;
}
示例样本上的一轮调度
任务与参数如下,容量 20,000 微元:
| 任务 | 租户 / 团队 | 分类 | 基础优先级 | 等待 tick | 成本 |
|---|---|---|---|---|---|
| a-summary-old | content / content | batch | 5 | 12 | 6,200 |
| a-summary-1 | content / content | standard | 90 | 0 | 6,200 |
| a-summary-2 | content / content | standard | 80 | 0 | 6,200 |
| b-reply-1 | support / support | critical | 10 | 1 | 4,200 |
| c-order-1 | trade / trade | critical | 20 | 0 | 3,000 |
运行结果是顺序 a-summary-old → b-reply-1 → c-order-1 → a-summary-1,共花 19,600 微元。三个租户的占用分别是内容 2 个任务 12,400、客服 1 个 4,200、交易 1 个 3,000;a-summary-2 被推迟,原因是内容租户窗口已满,剩余容量也不够它的 6,200。
这个顺序里有两个容易被误读的地方。第一,排第一的是 batch 任务,不是 critical。它等待了 12 个 tick,触发了强制位,得分 10,505;这是饥饿防护的预期行为,且每轮每租户只允许一个。第二,两个 critical 任务排在 standard 前面,但都在强制任务之后;如果把强制位去掉,它们会排在第二、第三位。
a-summary-2 的推迟说明窗口的作用:内容租户已经进了 2 个任务,达到 perTenantWindow,即使容量还剩 400 微元,也不会再放第三个内容任务。如果没有这个窗口,内容团队可以在单轮里占满所有位置,客服与交易只能等下一轮。
公平性怎么验收
公平性不能只看平均成本,要看四个可计算的量:每个租户的实际占用与合同份额的偏差;critical 任务被 batch 越过的次数,且越过的 batch 没有强制标记;队列等待时间的 P99;过期未执行的队列条目比例。
示例阈值可以是:单轮租户占用偏差不超过一个最大任务成本;无标记的反转次数为 0;critical 等待 P99 不超过两个调度周期;过期比例低于 1%。这些是示例口径,不是生产实测;真正上线前要用自己的流量回放校准。
验收还要区分「调度不公」与「容量不足」。如果所有租户都排满、P99 一起上升,问题在容量,不在公平性;如果只有小租户的 P99 上升,而大租户平稳,才说明窗口或权重失效。
小结
公平性有三层判据:租户看合同份额,团队看单轮窗口,任务看价值分类。价值权重量级大于基础优先级,避免高 basePriority 的 batch 反转 critical;老化有上限,保证等待最多追到 600 分;饥饿防护有强制位,但每轮每租户只允许一个。
示例样本上,一轮 20,000 微元容量接受了 4 个任务、花费 19,600,顺序是饥饿的 batch、两个 critical、一个 standard;内容租户因窗口上限推迟了第三个任务。公平不是平均,而是每一层都能说清楚「为什么是它先」。
阅读导航:上一节:4.1 三层配额:总额、份额、单次 · 下一节:4.3 借用、回收与隔离 。
常见错误与边界
- 用 FIFO 代替公平。先提交不等于更重要,批量任务会挡住关键动作。
- 跨价值分类比较基础优先级。batch 的 95 分不能压过 critical 的 10 分。
- 老化没有上限。等待越久分数越高,最终所有任务都变成同一优先级。
- 强制位没有数量限制。饥饿防护会变成新的插队通道。
- 只做租户窗口,不做团队窗口。租户内部仍可能被一个团队占满。
- 窗口设成 1。大任务团队每轮只能进一个,小任务团队反而占便宜。
- 只看平均份额不看尾部。平均值正常,小租户的 P99 可能已经失控。
- 把公平当性能优化。公平会牺牲部分吞吐,必须显式接受这个取舍。
- 队列没有到期时间。排不到的任务不会消失,只会堆积并拉长尾延迟。
- 价值分类由调用方随意填写。没有审计,所有任务都会变成 critical。
- 承诺模型调用中途可抢占。平台只能在节点边界调整顺序,不能随时打断。
- 用生产数字背书示例。本文所有调度结果都来自示例样本,不是线上流量。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。