账上还有六千万,为什么这次调用被拒
月底对账时,内容团队拿着一条拒绝记录来问:租户月度总额 1 亿微元,账上明明还剩 6,000 多万,为什么一次 6,200 微元的摘要请求会被拒?
答案是团队份额用完了。内容团队分到 4,000 万,已经花掉 39,996,200,只剩 3,800,而这次请求要 6,200。租户总额确实还有 60,003,800,但那是三个团队共用的池子,不是内容团队可以继续透支的理由。
这个场景说明一件事:多租户配额不是「一个租户一个数」,而是三层约束叠在一起。只设总额,一个团队就能把全租户的预算花光;只设团队份额,租户层面的商务承诺又无人兜底;只设单次上限,等于没有总量控制。
三层分别回答什么问题
三层配额各有自己的分母和归属,不能互相替代。
| 层 | 计量单位 | 分母 | 归属 | 回答的问题 |
|---|---|---|---|---|
| 租户总额 | 微元 / 月 | 整个租户 | 商务与平台共同确认 | 这个租户一个月最多花多少 |
| 团队份额 | 微元 / 月 | 租户内的一个团队 | 团队负责人,平台审批 | 这个团队能自主支配多少 |
| 单次上限 | 微元 / 请求 | 团队 × 能力 | 能力提供方与平台 | 一次调用最多能花多少 |
三层的单位必须统一成整数微元。用「次数」做配额,跨能力就无法比较:一次摘要 6,200 微元,一次回复 4,200 微元,同样是 1,000 次,成本差 47.6%。用 token 做配额,又漏掉了单价差异,同样的 token 数在不同模型上不是同一笔钱。
分母也要写清楚。租户总额的分母是「一个自然月内的全部成功与失败调用」;团队份额的分母是「该团队在该租户下的全部调用」;单次上限的分母是「一次请求的预扣成本」。分母不写,季度结算时一定对不上账。
回声的例子是:租户总额 1 亿微元,内容团队 4,000 万,客服团队 3,000 万,交易团队 3,000 万;单次上限分别是摘要 6,200、回复 4,200、下单 3,000 微元。三组数字都来自同一套整数微元口径,可以直接相加、相减、比较。
扣减顺序:单次、份额、总额
扣减顺序固定为三层:先看单次上限,再看团队份额,最后看租户总额。顺序不是实现细节,它决定了拒绝理由和调用方该做什么。
单次上限放在最前面,因为它回答的是「这次请求本身是否合法」。一个 62,000 微元的摘要请求,超过 6,200 的上限十倍,通常意味着调用方把批量任务塞进了一次调用,或者模型档位配错了。这种请求即使团队还有额度,也应该立刻拒绝,让调用方改配置,而不是让它慢慢吃掉预算。
团队份额放在租户总额之前,因为团队是预算的实际所有者。团队份额用完时,正确的动作是找团队负责人或平台申请借用,而不是去动租户总额。租户总额是最后一道兜底,处理的是份额配置透支、借用额度越界或未归属开销。
账本本身不处理时间,窗口切换时由上层新建账本,这样扣减逻辑保持确定性。三层必须原子扣减:只有三层都通过,才同时记到团队和租户两个账上。如果先扣团队、再发现租户不够,就会留下「团队已花、租户没花」的孤儿账,后续对账永远平不了。代码里的 checkQuota 先算三层余额,全部通过后才一次性落账;任何一层拒绝,remaining 都保持拒绝前的值。
用回声的例子走一遍。内容团队连续调用 6,451 次摘要,每次 6,200 微元,累计 39,996,200,团队剩余 3,800。第 6,452 次请求仍然是 6,200:单次上限通过(6,200 ≤ 6,200),团队份额失败(6,200 > 3,800),于是停在团队层。此时租户总额还剩 60,003,800,但这次请求不会碰到它。
超限不是一种行为,而是三种
「超限」在不同层、不同商务场景下应该有不同动作。平台至少要支持三种:拒绝、降档、排队。
| 超限层 | 默认动作 | 调用方看到什么 | 主要风险 |
|---|---|---|---|
| 单次上限 | 拒绝 | 立即失败,附配置错误提示 | 批量任务被拆错,需改调用方式 |
| 团队份额 | 拒绝或排队 | 失败并给出重试时间,或进入有到期时间的队列 | 队列过长会放大尾延迟 |
| 租户总额 | 降档或排队 | 换经济档继续,或等到下一窗口 | 降档后质量下降,需业务确认 |
单次上限永远拒绝,不参与降档或排队。它是配置合法性检查,不是容量调度问题;把一个超限十倍的请求排进队列,只会让它在轮到时再次失败。
团队层的默认动作通常是拒绝。团队份额是团队自己的预算,用完就应该由团队决定是申请借用还是降低调用量。若平台允许无限排队,团队会把排队当成免费扩容,队列会掩盖真实的容量缺口。
租户层的默认动作可以是降档,因为租户总额对应的是商务承诺。租户的钱花完了,但业务不能停,平台可以按事先约定的经济档继续服务,并把降档次数记入账单与质量报告。降档的前提是经济档确实更便宜:示例里摘要从 6,200 降到 4,030(示例值,按 65% 估算),脚本才允许降档;如果经济档不便宜,降档只是换了个名字的浪费。
排队则必须有到期时间。没有到期时间的队列等于把拒绝推迟到用户已经离开之后。代码里的 expireQueue 在每轮调度前移除过期条目,避免它们永久占据租户的排队窗口。
账本、校验与超限动作
quota.ts 把三层配额、扣减、借用额度、结算退回和超限动作放在同一个模块里。它不依赖任何存储或网络,所有函数都是确定性的,测试可以用同一组数字复现每一次拒绝。
// file: src/gov/quota.ts
// tier: L2
// verify: node --test test/gov/quota.test.ts
/** 三层配额账本:租户总额、团队份额、单次上限;金额一律整数微元。 */
export type TeamId = string;
export type CapabilityId = string;
export type QuotaLayer = "request" | "team" | "tenant";
export type OverLimitAction = "reject" | "degrade" | "queue";
export interface TeamShare {
teamId: TeamId;
shareMicros: number;
/** 能力名到单次上限的显式映射;没有映射视为不可调用。 */
requestCaps: Readonly<Record<CapabilityId, number>>;
}
export interface TenantQuota {
tenantId: string;
totalMicros: number;
teams: readonly TeamShare[];
/** 团队层与租户层的默认动作;单次上限永远拒绝。 */
teamAction: OverLimitAction;
tenantAction: OverLimitAction;
}
export interface LedgerUsage {
tenantMicros: number;
teamMicros: Record<TeamId, number>;
/** 借用得到的临时额度,归还时扣回;它不是份额的一部分。 */
creditMicros: Record<TeamId, number>;
}
export interface Ledger {
quota: TenantQuota;
usage: LedgerUsage;
}
export type QuotaReason =
| "request-cap" | "team-share" | "tenant-total"
| "unknown-team" | "unknown-capability" | "invalid-amount";
export interface QuotaDecision {
ok: boolean;
micros: number;
layer?: QuotaLayer;
reason?: QuotaReason;
action: "allow" | OverLimitAction;
remaining: { request: number; team: number; tenant: number };
}
export interface QuotaRequest {
teamId: TeamId;
capability: CapabilityId;
micros: number;
}
export function createLedger(quota: TenantQuota): Ledger {
const teamMicros: Record<TeamId, number> = {};
const creditMicros: Record<TeamId, number> = {};
for (const team of quota.teams) {
teamMicros[team.teamId] = 0;
creditMicros[team.teamId] = 0;
}
return { quota, usage: { tenantMicros: 0, teamMicros, creditMicros } };
}
function deny(
micros: number,
layer: QuotaLayer | undefined,
reason: QuotaReason,
action: OverLimitAction,
remaining: QuotaDecision["remaining"],
): QuotaDecision {
return { ok: false, micros, layer, reason, action, remaining };
}
/**
* 扣减顺序固定为:单次上限 -> 团队份额 -> 租户总额。
* 任一层拒绝都不落账,remaining 仍是拒绝前的余额。
*/
export function checkQuota(ledger: Ledger, request: QuotaRequest): QuotaDecision {
const { quota, usage } = ledger;
const tenantRemaining = quota.totalMicros - usage.tenantMicros;
const base = { request: 0, team: 0, tenant: tenantRemaining };
if (!Number.isInteger(request.micros) || request.micros <= 0) {
return deny(0, undefined, "invalid-amount", "reject", base);
}
const team = quota.teams.find((item) => item.teamId === request.teamId);
if (team === undefined) return deny(request.micros, undefined, "unknown-team", "reject", base);
const cap = team.requestCaps[request.capability];
const teamUsed = usage.teamMicros[team.teamId] ?? 0;
const credit = usage.creditMicros[team.teamId] ?? 0;
const teamRemaining = team.shareMicros + credit - teamUsed;
const remaining = {
request: cap === undefined ? 0 : Math.max(0, cap - request.micros),
team: Math.max(0, teamRemaining),
tenant: Math.max(0, tenantRemaining),
};
if (cap === undefined) return deny(request.micros, "request", "unknown-capability", "reject", remaining);
if (request.micros > cap) return deny(request.micros, "request", "request-cap", "reject", remaining);
if (request.micros > teamRemaining) {
return deny(request.micros, "team", "team-share", quota.teamAction, remaining);
}
if (request.micros > tenantRemaining) {
return deny(request.micros, "tenant", "tenant-total", quota.tenantAction, remaining);
}
usage.tenantMicros += request.micros;
usage.teamMicros[team.teamId] = teamUsed + request.micros;
return {
ok: true, micros: request.micros, action: "allow",
remaining: {
request: cap - request.micros,
team: teamRemaining - request.micros,
tenant: tenantRemaining - request.micros,
},
};
}
/** 借用成功后授予临时额度;额度不改变份额,也不改变租户总额。 */
export function grantCredit(ledger: Ledger, teamId: TeamId, micros: number): void {
if (micros <= 0 || !(teamId in ledger.usage.creditMicros)) return;
ledger.usage.creditMicros[teamId] += micros;
}
/** 归还借用额度;已花掉的部分通过 release 退回团队与租户两层。 */
export function revokeCredit(ledger: Ledger, teamId: TeamId, micros: number): void {
const current = ledger.usage.creditMicros[teamId] ?? 0;
ledger.usage.creditMicros[teamId] = Math.max(0, current - Math.max(0, micros));
}
export function release(ledger: Ledger, teamId: TeamId, micros: number): void {
const team = ledger.quota.teams.find((item) => item.teamId === teamId);
if (team === undefined || micros <= 0) return;
const used = ledger.usage.teamMicros[teamId] ?? 0;
ledger.usage.teamMicros[teamId] = Math.max(0, used - micros);
ledger.usage.tenantMicros = Math.max(0, ledger.usage.tenantMicros - micros);
}
export function remainingQuota(ledger: Ledger): { tenant: number; teams: Record<TeamId, number> } {
const teams: Record<TeamId, number> = {};
for (const team of ledger.quota.teams) {
const credit = ledger.usage.creditMicros[team.teamId] ?? 0;
teams[team.teamId] = team.shareMicros + credit - (ledger.usage.teamMicros[team.teamId] ?? 0);
}
return { tenant: ledger.quota.totalMicros - ledger.usage.tenantMicros, teams };
}
/** 份额之和不得超过总额;否则租户层会先于团队层拒绝,说明配置已经透支。 */
export function validateQuota(quota: TenantQuota): string[] {
const issues: string[] = [];
const seen = new Set<TeamId>();
let sum = 0;
for (const team of quota.teams) {
if (team.teamId.trim() === "") issues.push("team id is empty");
if (seen.has(team.teamId)) issues.push(`duplicate team ${team.teamId}`);
seen.add(team.teamId);
if (!Number.isInteger(team.shareMicros) || team.shareMicros <= 0) {
issues.push(`team ${team.teamId}: share must be a positive integer`);
}
for (const [capability, cap] of Object.entries(team.requestCaps)) {
if (!Number.isInteger(cap) || cap <= 0) {
issues.push(`team ${team.teamId}: cap ${capability} must be a positive integer`);
}
}
sum += team.shareMicros;
}
if (!Number.isInteger(quota.totalMicros) || quota.totalMicros <= 0) {
issues.push("tenant total must be a positive integer");
} else if (sum > quota.totalMicros) {
issues.push(`shares ${sum} exceed total ${quota.totalMicros}`);
}
return issues;
}
// ---- 超限动作:把配额判定翻译成调用方可见的行为 ----
export interface LimitContext {
/** 降档后重新估算的成本;必须低于原请求才允许降档。 */
economyMicros: number;
deadlineMs: number;
queueDepth: number;
queueLimit: number;
nowMs: number;
}
export type LimitKind = "allow" | "reject" | "degrade" | "queue";
export interface LimitOutcome {
kind: LimitKind;
chargedMicros: number;
reason: string;
retryAfterMs?: number;
expiresAtMs?: number;
}
const RETRY_AFTER_MS = 60_000;
/** 单次上限永远拒绝:它通常意味着配置错了,重试或排队都不会变对。 */
export function applyLimit(decision: QuotaDecision, context: LimitContext): LimitOutcome {
if (decision.ok) {
return { kind: "allow", chargedMicros: decision.micros, reason: "within-quota" };
}
const reason = `${decision.layer ?? "unknown"}:${decision.reason ?? "unknown"}`;
const action: OverLimitAction = decision.layer === "request" ? "reject" : decision.action;
if (action === "degrade") {
if (context.economyMicros >= decision.micros) {
return {
kind: "reject", chargedMicros: 0,
reason: `${reason}:degrade-not-cheaper`, retryAfterMs: RETRY_AFTER_MS,
};
}
return { kind: "degrade", chargedMicros: context.economyMicros, reason };
}
if (action === "queue" && hasQueueRoom(context)) {
return { kind: "queue", chargedMicros: 0, reason, expiresAtMs: context.nowMs + context.deadlineMs };
}
return { kind: "reject", chargedMicros: 0, reason, retryAfterMs: RETRY_AFTER_MS };
}
export function hasQueueRoom(context: LimitContext): boolean {
return context.queueDepth < context.queueLimit && context.deadlineMs > 0;
}
/** 过期条目必须在调度前移除,否则它们会永久占据租户的排队窗口。 */
export function expireQueue(
entries: readonly { taskId: string; expiresAtMs: number }[],
nowMs: number,
): { active: string[]; expired: string[] } {
const active: string[] = [];
const expired: string[] = [];
for (const entry of entries) {
if (entry.expiresAtMs <= nowMs) expired.push(entry.taskId);
else active.push(entry.taskId);
}
return { active, expired };
}
示例样本上的运行结果
用回声配额跑两组场景,数字都来自 node --test test/gov/quota.test.ts 覆盖的同一套实现。
第一组是正常分配:1 亿总额,内容 4,000 万,客服 3,000 万,交易 3,000 万。内容团队调用 6,451 次摘要后,租户已用 39,996,200,团队剩余 3,800;第 6,452 次请求被团队层拒绝,拒绝理由是 team-share,返回的重试建议是 60,000ms,租户层余额仍是 60,003,800。这条记录能直接回答开头那个问题。
第二组是份额透支:总额仍是 1 亿,但内容分 6,000 万、客服分 5,000 万,两者相加 1.1 亿,已经超过总额。内容先花 6,000 万,客服再花 39,996,200,租户累计 99,996,200;此时客服团队还剩 10,003,800,但租户只剩 3,800,下一条 6,200 的请求停在租户层,理由是 tenant-total。按示例策略降档后,实际记账 4,030。
第二组暴露了一个容易忽略的事实:当团队份额之和正好等于租户总额时,租户层几乎永远不会先于团队层触发,因为租户剩余等于各团队剩余之和。租户层真正生效的场景,是份额之和超过总额、存在未分配开销,或者借用额度把某个团队抬过了份额。validateQuota 会把「shares 110000000 exceed total 100000000」直接报出来,但校验通过不代表运行时不会发生透支,平台仍要在扣减时保留这道兜底。
配额变更也是治理流程
配额数字会变,变更过程比数字本身更重要。一个可执行的规则是:租户总额由商务与平台共同确认,按合同周期生效;团队份额由团队负责人申请、平台审批,变更在下一个计费窗口生效;单次上限由能力提供方提出、平台确认,因为它同时影响成本与质量。
变更还要留三样东西:变更前后额度、变更原因、受影响的链路与租户。没有这三样,季度复盘时无法判断某次成本上升是业务增长还是配额放宽。紧急借用不属于变更,它有独立的借用与归还规则,下一节展开。
小结
三层配额分别回答「租户最多花多少」「团队能自主花多少」「一次调用最多花多少」。三层统一用整数微元计量,扣减顺序固定为单次上限、团队份额、租户总额,并且必须原子落账。超限动作分拒绝、降档、排队三种:单次上限永远拒绝,团队层默认拒绝,租户层可以按商务约定降档。
示例样本上,内容团队花掉 39,996,200 后,下一条 6,200 的请求停在团队层,团队剩余 3,800,租户还剩 60,003,800;当份额之和超过总额时,请求会停在租户层,说明配置已经透支。配额治理的关键不是把数字算对一次,而是让每次拒绝都能指出是哪一层、还剩多少、调用方下一步该做什么。
阅读导航:上一节:3.3 版本矩阵与弃用窗口 · 下一节:4.2 公平性与优先级 。
常见错误与边界
- 只设租户总额。一个团队可以把全租户预算花光,其他团队无额度可用。
- 用次数或 token 代替微元。跨能力的「一次」和「一千 token」不是同一笔钱,无法比较。
- 份额之和大于总额却不校验。租户层会先于团队层拒绝,拒绝理由会误导调用方。
- 扣减不是原子的。团队已扣、租户失败会留下孤儿账,月底对账永远对不平。
- 单次超限也排队。配置错误重试多少次都不会变对,只会浪费队列位置。
- 超限行为由每个团队自选。同一个平台会出现拒绝、降档、排队三套用户体验。
- 排队没有到期时间。拒绝被推迟到用户离开之后,指标上看起来只是「慢」。
- 降档不校验是否更便宜。经济档若没有更低的预扣成本,降档只是换名字。
- 借用额度直接改份额。份额是预算所有权,借用是临时信用,两者混在一起无法回收。
- 配额变更不留记录。成本上升时无法区分业务增长与额度放宽。
- 把租户总额当第一道防线。份额之和等于总额时它几乎不触发,真正的约束在团队层。
- 配额单位与计费单位不一致。配额用微元、账单用金额,汇率一变就对不上。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。