06:00 没有关上的窗口
「回声」的日报作业要在 06:00 前处理 12 万条会话。团队把并发调到 64,批大小设为 1,认为「并发高就快」。实际跑到 06:00 只完成了 96,000 条,剩下 24,000 条。值班同学为了让日报按时发出,把已完成部分直接发布,并在标题上写了「全量」。两个问题同时发生。第一,批大小为 1 时,窗口产能受调用速率限制,不是受并发限制;并发再高,每秒能发的请求数没变。第二,错过窗口后没有降级预案,只能在「发半成品」和「什么都不发」之间二选一,最后选了最糟的那个:把不完整的数据伪装成完整。本节把窗口当成一等资源:批大小与等待窗口决定批次形态,限流与并发决定窗口产能,单位成本决定批大小上限,错过窗口则按固定顺序降级。数字来自本机确定性模拟,标注为示例样本上的运行结果。
批大小与等待窗口
一个批次在两个条件下关闭:满员,或者等待窗口到期。只有满员会牺牲延迟,只有时间会牺牲摊薄。示例样本 12,000 条、每小时均匀到达(每 300ms 一条),五种策略:
| 批大小 / 等待 | 批次数 | 平均批 | P50 等待 | P95 等待 | 单位成本 | 总成本(微元) |
|---|---|---|---|---|---|---|
| 1 / 0 | 12,000 | 1.0 | 0 | 0 | 1,320 | 15,840,000 |
| 32 / 2s | 1,715 | 7.0 | 1,100 | 2,000 | 549 | 6,583,500 |
| 32 / 5s | 706 | 17.0 | 2,600 | 5,000 | 473 | 5,675,400 |
| 64 / 20s | 188 | 63.8 | 9,300 | 18,000 | 435 | 5,209,200 |
| 128 / 30s | 119 | 100.8 | 15,000 | 28,500 | 429 | 5,147,100 |
32 / 5s 这一行值得注意:批大小是 32,但到达率只有 3.33 条/秒,等满 32 条需要 9.6 秒,超过 5 秒窗口,所以实际平均只有 17 条。批大小是上限,不是承诺。把窗口从 2 秒放宽到 5 秒,批次数从 1,715 降到 706,单位成本从 549 降到 473,代价是 P95 等待从 2 秒涨到 5 秒。再放大到 64 / 20s,单位成本只降到 435,P95 等待却到 18 秒。从 128 继续放大,单位成本从 428 降到 422,收益已经很薄,等待继续上升。批大小不是越大越好,拐点在「固定开销摊到可忽略」的位置,示例里大约在 64 到 128 之间。
窗口产能:限流与并发谁先到
窗口能处理多少条,取两个上限的较小值:
限流产能 = 每秒调用数 × 批大小
并发产能 = 并发数 × 批大小 / 单批服务时间
示例参数:每秒 2 次调用、并发 4、每批固定 900ms、每条 45ms,窗口 2 小时:
| 批大小 | 窗口产能(条) | 受限于 | 单位成本 |
|---|---|---|---|
| 1 | 14,400 | 调用速率 | 1,320 |
| 8 | 115,200 | 调用速率 | 533 |
| 32 | 393,846 | 并发 | 449 |
| 128 | 553,513 | 并发 | 428 |
| 512 | 615,939 | 并发 | 422 |
批大小为 1 时,并发 4 也救不了:每秒只能发 2 次调用,2 小时就是 14,400 条,50,000 条的作业必然错过窗口。批大小为 8 时,限流产能 16 条/秒,已经能覆盖。批大小到 32,瓶颈从调用速率切到并发;再往上加批大小,产能提升变缓,因为单批服务时间随条数线性增长。这也是「并发调到 64 却仍然跑不完」的原因:并发不是吞吐,吞吐是并发、批大小与服务时间的组合。1.2 已经建立了请求级的服务时间口径,本节把它用在小时级窗口上。
单位成本曲线
单位成本 =(每批固定开销 + 批大小 × 条目成本)/ 批大小。固定开销永远摊不干净,所以曲线单调下降但趋近条目成本。示例里条目成本 420 微元,批大小 1 时单位成本 1,320,8 时 533,32 时 449,128 时 428,512 时 422。从 1 到 32,单位成本降了 66%;从 32 到 128,只再降 4.7%;从 128 到 512,降 1.4%。如果等待带来的时效损失按每条每 10 秒 1 微元计,64 / 20s 比 32 / 5s 多等 13 秒,每条多付 1.3 微元,但省下 38 微元,仍然划算;128 / 30s 比 64 / 20s 多等 10.5 秒,只省 6 微元,就不一定划算。判据是「单位成本节省 > 等待的时效损失」,不是「批越大越省」。
错过窗口的降级顺序
降级不是「发半成品」。固定顺序是:
- 先用窗口结束后的剩余产能收尾。
- 允许延期且在最大延期内,申请延期并标记
late。 - 延期不够,把必须当天产出的关键子集升级到在线路径。
- 其余条目顺延到下一窗口;不允许部分发布时整批扣住。
- 任何一步都不把旧结果标记成新结果。
示例:12 万条作业,06:00 时剩 24,000 条,窗口后产能 4,000 条/小时,最大延期 2 小时。需要 6 小时,延期不够;关键子集 3,000 条,其余 21,000 条顺延:
| 处置 | 条数 | 单位成本 | 小计(微元) |
|---|---|---|---|
| 关键子集走在线 | 3,000 | 1,320 | 3,960,000 |
| 其余顺延 | 21,000 | 428 | 8,988,000 |
| 合计增量 | 24,000 | — | 12,948,000 |
全部走在线是 31,680,000 微元,全部顺延是 10,272,000 微元,但关键子集会迟到。混合方案多花 2,676,000 微元买当天时效,这个数字应该由业务确认,而不是由值班同学在 06:00 临时决定。如果作业不允许部分发布,同一份输入会返回 hold:只发布独立的关键子集,其余 21,000 条整批扣住,等修复后重跑。半成品不能因为「只差 20%」就发布。
实现
下面的模块包含批次形成、窗口产能、单位成本与错过窗口的降级计划。它只做计划,不发请求;服务时间与成本参数由调用方按自己的模型档案填入。
// file: src/perf/batch-schedule.ts
// tier: L1
// verify: node --test test/perf/batch.test.ts
import { AiError } from "../core/errors.ts";
/** 作业级批处理参数;服务时间口径沿用 1.2,但这里关心的是小时级窗口。 */
export interface BatchPolicy {
maxBatchSize: number;
maxWaitMs: number;
fixedCallMicros: number;
perItemMicros: number;
serviceFixedMs: number;
servicePerItemMs: number;
callsPerSecond: number;
concurrency: number;
}
export interface Arrival {
key: string;
atMs: number;
}
export interface PlannedBatch {
items: Arrival[];
closeAtMs: number;
serviceMs: number;
}
export interface BatchPlan {
batches: PlannedBatch[];
totalCostMicros: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `batch-schedule: ${message}`);
}
function requireInt(value: number, field: string, min: number): number {
if (!Number.isInteger(value) || value < min) fail(`${field} must be an integer >= ${min}`);
return value;
}
function requireNumber(value: number, field: string, min: number): number {
if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
fail(`${field} must be a finite number >= ${min}`);
}
return value;
}
/** 同一批在“满员”或“窗口到期”时关闭;两个条件都要有,缺一个就会牺牲另一头。 */
export function planBatches(arrivals: readonly Arrival[], policy: BatchPolicy): BatchPlan {
requireInt(policy.maxBatchSize, "maxBatchSize", 1);
requireInt(policy.maxWaitMs, "maxWaitMs", 0);
requireInt(policy.fixedCallMicros, "fixedCallMicros", 0);
requireInt(policy.perItemMicros, "perItemMicros", 0);
requireNumber(policy.serviceFixedMs, "serviceFixedMs", 0);
requireNumber(policy.servicePerItemMs, "servicePerItemMs", 0);
for (let index = 1; index < arrivals.length; index += 1) {
if (arrivals[index].atMs < arrivals[index - 1].atMs) fail("arrivals must be sorted by atMs");
}
const batches: PlannedBatch[] = [];
let cursor = 0;
while (cursor < arrivals.length) {
const openAtMs = arrivals[cursor].atMs;
const items: Arrival[] = [];
let closedBy: "size" | "window" | "end" = "end";
while (cursor < arrivals.length) {
if (items.length >= policy.maxBatchSize) {
closedBy = "size";
break;
}
const next = arrivals[cursor];
if (items.length > 0 && next.atMs > openAtMs + policy.maxWaitMs) {
closedBy = "window";
break;
}
items.push(next);
cursor += 1;
}
const lastAtMs = items[items.length - 1].atMs;
const closeAtMs = closedBy === "window" ? openAtMs + policy.maxWaitMs : lastAtMs;
const serviceMs = policy.serviceFixedMs + items.length * policy.servicePerItemMs;
batches.push({ items, closeAtMs, serviceMs });
}
return {
batches,
totalCostMicros: batches.length * policy.fixedCallMicros + arrivals.length * policy.perItemMicros,
};
}
export interface BatchStats {
batches: number;
items: number;
averageBatchSize: number;
p50WaitMs: number;
p95WaitMs: number;
maxWaitMs: number;
unitCostMicros: number;
totalCostMicros: number;
}
function percentileNearestRank(values: readonly number[], ratio: number): number {
if (values.length === 0) fail("percentile of empty sample");
const sorted = [...values].sort((left, right) => left - right);
const rank = Math.min(sorted.length - 1, Math.max(0, Math.ceil(ratio * sorted.length) - 1));
return sorted[rank];
}
/** 等待 = 批次关闭时刻 - 条目到达时刻;这是条目在窗口内的排队代价。 */
export function summarizePlan(plan: BatchPlan, windowMs: number): BatchStats {
if (plan.batches.length === 0) fail("cannot summarize an empty plan");
requireNumber(windowMs, "windowMs", 1);
const waits: number[] = [];
for (const batch of plan.batches) {
for (const item of batch.items) waits.push(batch.closeAtMs - item.atMs);
}
return {
batches: plan.batches.length,
items: waits.length,
averageBatchSize: waits.length / plan.batches.length,
p50WaitMs: percentileNearestRank(waits, 0.5),
p95WaitMs: percentileNearestRank(waits, 0.95),
maxWaitMs: percentileNearestRank(waits, 1),
unitCostMicros: Math.ceil(plan.totalCostMicros / waits.length),
totalCostMicros: plan.totalCostMicros,
};
}
export interface WindowCapacity {
items: number;
limitedBy: "rate" | "concurrency";
}
/** 窗口产能取限流与并发两个上限的较小值,不能只按并发算。 */
export function windowCapacity(policy: BatchPolicy, windowMs: number, batchSize: number): WindowCapacity {
requireInt(batchSize, "batchSize", 1);
requireNumber(windowMs, "windowMs", 1);
const serviceMs = policy.serviceFixedMs + batchSize * policy.servicePerItemMs;
const byRate = policy.callsPerSecond * batchSize;
const byConcurrency = (policy.concurrency * 1_000 * batchSize) / serviceMs;
const throughputPerSecond = Math.min(byRate, byConcurrency);
return {
items: Math.floor((throughputPerSecond * windowMs) / 1_000),
limitedBy: byRate <= byConcurrency ? "rate" : "concurrency",
};
}
/** 单位成本 =(每批固定开销 + 条目成本)/ 批大小;固定开销永远摊不干净。 */
export function unitCostMicros(policy: BatchPolicy, batchSize: number): number {
requireInt(batchSize, "batchSize", 1);
return Math.ceil((policy.fixedCallMicros + batchSize * policy.perItemMicros) / batchSize);
}
export interface MissedWindowInput {
remainingItems: number;
/** 窗口结束后每小时还能处理多少条。 */
capacityPerHour: number;
hoursLeft: number;
/** 必须当天产出、可以改走在线路径的条数。 */
criticalItems: number;
allowLate: boolean;
maxLateHours: number;
allowPartialPublish: boolean;
onlineUnitMicros: number;
carryOverUnitMicros: number;
}
export interface WindowAction {
action: "finish" | "extend" | "escalate-online" | "carry-over" | "hold";
items: number;
micros: number;
detail: string;
}
export interface MissedWindowPlan {
actions: WindowAction[];
published: number;
unpublished: number;
lateHours: number;
extraMicros: number;
}
/**
* 错过窗口的降级顺序:先用剩余产能、再申请延期、再升级关键子集、
* 最后才顺延或扣住。任何一步都不能把旧结果伪装成新结果发布。
*/
export function planMissedWindow(input: MissedWindowInput): MissedWindowPlan {
requireInt(input.remainingItems, "remainingItems", 0);
requireInt(input.capacityPerHour, "capacityPerHour", 0);
requireNumber(input.hoursLeft, "hoursLeft", 0);
requireInt(input.criticalItems, "criticalItems", 0);
requireNumber(input.maxLateHours, "maxLateHours", 0);
requireInt(input.onlineUnitMicros, "onlineUnitMicros", 0);
requireInt(input.carryOverUnitMicros, "carryOverUnitMicros", 0);
const capacityLeft = Math.floor(input.capacityPerHour * input.hoursLeft);
if (input.remainingItems <= capacityLeft) {
return {
actions: [{ action: "finish", items: input.remainingItems, micros: 0, detail: "剩余产能足够" }],
published: input.remainingItems,
unpublished: 0,
lateHours: 0,
extraMicros: 0,
};
}
const shortfall = input.remainingItems - capacityLeft;
const requiredLateHours = Math.ceil(shortfall / Math.max(1, input.capacityPerHour));
if (input.allowLate && requiredLateHours <= input.maxLateHours) {
return {
actions: [{ action: "extend", items: input.remainingItems, micros: 0, detail: `延期 ${requiredLateHours} 小时` }],
published: input.remainingItems,
unpublished: 0,
lateHours: requiredLateHours,
extraMicros: 0,
};
}
const critical = Math.min(input.criticalItems, input.remainingItems);
const carryOver = Math.max(0, input.remainingItems - capacityLeft - critical);
const actions: WindowAction[] = [];
if (capacityLeft > 0) {
actions.push({ action: "finish", items: capacityLeft, micros: 0, detail: "窗口结束前已完成" });
}
if (critical > 0) {
actions.push({
action: "escalate-online",
items: critical,
micros: critical * input.onlineUnitMicros,
detail: "关键子集改走在线,换时效",
});
}
const carryAction = input.allowPartialPublish ? "carry-over" : "hold";
if (carryOver > 0) {
actions.push({
action: carryAction,
items: carryOver,
micros: carryOver * input.carryOverUnitMicros,
detail: input.allowPartialPublish ? "其余顺延到下一窗口" : "不允许部分发布,整批扣住",
});
}
return {
actions,
published: input.allowPartialPublish ? capacityLeft + critical : critical,
unpublished: input.allowPartialPublish ? carryOver : input.remainingItems - critical,
lateHours: 0,
extraMicros: critical * input.onlineUnitMicros + carryOver * input.carryOverUnitMicros,
};
}
planBatches 要求到达时间有序。乱序输入会让批次边界随输入顺序漂移,同一份数据两次运行得到不同批次,成本与等待都不可复现。生产上应在进入计划器前按到达时间排序,并记录排序键。
windowCapacity 取限流与并发的较小值,但没有考虑排队。窗口末尾的批次可能排到窗口之外,实际产能要再打一个折扣;这个折扣应该用 1.3 的压测基线校准,而不是拍一个 80%。
planMissedWindow 的降级顺序是硬编码的。顺序本身就是策略:先延期、再升级、最后顺延或扣住。把顺序做成可配置,等于把 06:00 的决策重新交给值班同学。
测试与运行结果
调度与窗口相关测试:
node --test --test-name-pattern="批大小与等待窗口|放大批大小|单条批次|窗口产能|单位成本|剩余产能|允许延期|错过窗口|不允许部分|乱序到达" test/perf/batch.test.ts
本机一次运行的真实输出摘要:
ℹ tests 10
ℹ suites 0
ℹ pass 10
ℹ fail 0
ℹ duration_ms 147.464042
测试覆盖了五个策略的批次形态、单条批次最贵、窗口产能的两种瓶颈、单位成本下限,以及降级的四条分支:产能足够、可以延期、混合升级、整批扣住。最后一条最容易写错:不允许部分发布时,published 只剩关键子集,不能把已完成部分算进去。
与 7.1、7.2、1.2 的衔接
7.1 决定作业是否离开在线路径,并按窗口预留预算;7.2 保证重跑不重复计费;本节决定窗口内怎样排布批次、批大小取多少、错过窗口时先救谁。三者共用同一组成本参数:7.1 的每条节省额、7.2 的重试率、本节的固定开销,必须来自同一份模型档案,否则三张表的结论会互相打架。1.2 的批处理度量回答「一次调用服务多少条、吞吐多少」,本节回答「一个窗口能排多少批、单位成本多少」。前者是测量,后者是计划;计划必须用测量校准,不能用计划反推测量。
小结
批大小与等待窗口共同决定批次形态:满员触发省固定开销,时间触发保时效。窗口产能取限流与并发的较小值,单条批次再高的并发也救不了调用速率。单位成本随批大小下降,但拐点在固定开销摊薄之后,继续放大的收益很薄。错过窗口按固定顺序降级:剩余产能、延期、关键子集升级在线、其余顺延,不允许部分发布时整批扣住。任何一步都不能把旧结果当新结果发布。下一章从单个作业走到容量规划:业务量怎样换算成 token 需求,峰值与余量怎样预留。
阅读导航:上一节:7.2 分片、重试与幂等 · 下一节:8.1 从业务量到 token 需求 。
常见错误与边界
- 只调并发不看批大小。并发不是吞吐,调用速率可能先成为瓶颈。
- 把批大小当承诺。到达率不足时,时间窗口先触发,实际批次远小于上限。
- 只设满员触发。低峰期批次永远等不满,延迟无限拉长。
- 用理论产能当实际产能。没有扣除排队与窗口末尾的溢出。
- 为了省单位成本无限放大批次。等待的时效损失可能超过省下的微元。
- 错过窗口就发半成品。不完整数据被标成完整,比晚发更糟。
- 不允许部分发布却发布已完成部分。这违反全有或全无语义。
- 三张成本表用不同参数。7.1、7.2、7.3 的结论会互相矛盾。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。