《互联网产品的 AI 架构·推理性能与成本工程》7.2 分片、重试与幂等

12 万条质检作业在 63% 处崩溃,重跑后多付了 2,702,700 微元,还给客户重复发了邮件。本节说明分片键为何必须绑定业务条目而不是数组下标、重试预算为何要放在作业级共享池、幂等键与去重窗口如何覆盖崩溃重跑,以及部分失败时如何隔离毒条目而不是整批作废。

一次 63% 处的崩溃

「回声」的会话质检作业跑到第 63% 时,模型提供方返回了一轮 429。脚本退出,值班同学直接重跑。第二天财务发现,同一批 6,300 条会话被计费了两次,多付 2,702,700 微元;运营也发现,部分客户收到了两封一模一样的质检通知。

这次事故里没有哪一行代码「写错了」。分片按数组下标切,重跑时输入顺序变了,旧条目落到新分片;重试按整批进行,失败就把游标回退到分页起点;写结果没有幂等键,重复执行就重复落库。三个问题叠在一起,才让一次普通的重试变成重复计费。

本节把离线作业内部的三件事拆开:分片键怎么选、重试预算放在哪里、幂等键与去重窗口如何覆盖崩溃重跑。数字来自本机确定性模拟,标注为示例样本上的运行结果。

分片键:稳定的是条目到桶

分片的第一原则不是「切得均匀」,而是「同一条目在任何一次运行里都落到同一个位置」。按数组下标切最均匀,也最不稳定:前面插入 500 条新数据,后面所有条目的下标都后移,分片全变。

示例样本上,10,000 条旧数据前面插入 500 条新数据,按 64 个分片的下标切法,10,000 条全部换了分片;按键分片(业务键 → 256 个虚拟桶)则是 0 条漂移。差别不是精度,而是「重跑时还能不能认出这是同一条」。

虚拟桶还有一层作用:扩容时移动的是桶,不是条目身份。示例样本从 8 个 worker 扩到 12 个,仍有 6,566/10,000 条换了 worker,因为桶到 worker 的映射变了。这不可怕,前提是幂等键不包含 worker 或分片号;否则每次扩容都会把「同一条」变成「新的一条」。

所以分片键取业务身份:订单号、会话 id、tenantId + bizDate + itemId。不要取数组下标、游标位置、文件行号,也不要把 worker 号拼进幂等键。

重试预算:作业级共享池

每个条目各给三次重试,在单条失败时很合理,在上游整体故障时会变成灾难:12 万条同时失败,重试流量是首轮的三倍,提供方还没恢复就被打垮。

作业级重试预算把重试当成有限资源:

剩余预算 = min(绝对上限, floor(首次尝试数 × 比例上限)) - 已用重试

示例作业 10,000 条,绝对上限 600,比例上限 5%,预算 500 次。注入 589 条瞬时失败和 1 条永久失败后:500 条重试成功,89 条瞬时失败因预算耗尽被隔离,1 条永久失败直接隔离。最终 9,910 条成功,90 条隔离,失败率 0.90%,总尝试 10,500 次,成本 4,504,500 微元。

不设预算、全部重试的成本是 4,542,681 微元,只多 38,181 微元,但重试次数从 500 涨到 589,且上游故障时没有上限。重试预算省下的通常不是钱,而是「故障时不把提供方打死」的确定性。

预算耗尽后必须有下一步:隔离并记录,等下一个窗口重跑,或者升级到人工。最糟的处理是「预算没了就静默跳过」,那会让失败率看起来很低,实际缺了一批结果。

幂等键与去重窗口

幂等键回答「这是不是同一条工作」。它应该由业务身份和输入版本组成,例如 sha256(jobId | itemKey | promptVersion)。jobId 里带业务日期,避免今天的作业复用昨天的结果;promptVersion 让提示词变更后的结果不被旧缓存顶掉;worker、分片号、重试次数一律不进键。

去重窗口要覆盖三段时间:作业最长运行时间、重试尾巴、时钟偏差。示例作业最长 4 小时、重试尾巴 30 分钟、时钟偏差 5 分钟,窗口下限是 4 小时 35 分。窗口短于重跑间隔,第二次运行就会把同一条目当成新工作。

示例样本上,作业完成 6,300 条后崩溃,用 7 天窗口重跑:6,300 条直接复用,3,700 条待处理,重复调用 0 次,省下 2,702,700 微元。把窗口改成 1 小时、2 小时后重跑:6,300 条全部过期,重复调用 6,300 次。差别只在窗口长度,不在代码逻辑。

存储里要区分三种状态:fresh 可以调用,in-flight 表示另一个 worker 正在处理,done 直接复用结果。begin 负责占位,peek 只读不占位。断点续跑用 peek 统计缺口,真正开始处理时才 begin,避免「统计一遍就把所有键占住」。

部分失败:隔离,而不是整批作废

离线作业的结果不是二元的。每条条目有四种结局:成功、可重试失败、永久失败、被隔离。作业汇总后有三种提交方式:

  • 全部成功,正常提交。
  • 失败率在阈值内且允许部分发布,提交成功子集,隔离失败条目并输出清单。
  • 失败率超阈值或不允许部分发布,整批扣住,等修复后重跑。

示例里毒条目 1 条、总失败率 0.01%,阈值 1%,允许部分发布,于是提交 9,999 条并隔离 1 条。如果业务要求「要么全有要么全无」,同一份汇总会返回 hold,不能因为 99.99% 成功就发布。

部分失败还有一个成本含义:被隔离的条目已经调用过模型,成本真实发生。卷一 8.2 讲过「失败也要计量」,离线作业要把这部分成本记在作业账上,而不是因为没产出就记零。

实现

下面的模块包含分片、重试预算、幂等存储、结果汇总与提交判定。它不依赖队列或数据库,时钟由外部注入,因此可以确定性测试。

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

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

export const VIRTUAL_BUCKETS = 256;

function fail(message: string): never {
  throw new AiError("invalid-request", `batch-shard: ${message}`);
}

function fnv1a(text: string): number {
  let hash = 0x811c9dc5;
  for (let index = 0; index < text.length; index += 1) {
    hash ^= text.charCodeAt(index);
    hash = Math.imul(hash, 0x01000193) >>> 0;
  }
  return hash;
}

/**
 * 条目先落到固定的虚拟桶,桶再分配给 worker。
 * 这样条目到桶的映射不随并发数变化,扩缩容只移动桶。
 */
export function bucketOf(key: string, buckets = VIRTUAL_BUCKETS): number {
  if (key.trim() === "") fail("key is required");
  if (!Number.isInteger(buckets) || buckets <= 0) fail("buckets must be a positive integer");
  return fnv1a(key) % buckets;
}

export interface ShardPlan {
  workerCount: number;
  buckets: number;
  /** 下标是桶号,值是 worker 号。 */
  workerOfBucket: number[];
}

export function planShards(workerCount: number, buckets = VIRTUAL_BUCKETS): ShardPlan {
  if (!Number.isInteger(workerCount) || workerCount <= 0) fail("workerCount must be a positive integer");
  if (!Number.isInteger(buckets) || buckets <= 0) fail("buckets must be a positive integer");
  const workerOfBucket: number[] = [];
  for (let bucket = 0; bucket < buckets; bucket += 1) workerOfBucket.push(bucket % workerCount);
  return { workerCount, buckets, workerOfBucket };
}

export function shardOf(key: string, plan: ShardPlan): number {
  return plan.workerOfBucket[bucketOf(key, plan.buckets)];
}

export type IdempotencyState = "fresh" | "in-flight" | "done";

export interface IdempotencyLookup {
  state: IdempotencyState;
  result?: string;
}

interface Entry {
  status: "in-flight" | "done";
  expiresAtMs: number;
  result?: string;
}

/**
 * 幂等键窗口必须覆盖“作业最长重跑时间 + 重试尾巴 + 时钟偏差”。
 * 窗口短于重跑间隔时,第二次运行会把同一条目重新计费。
 */
export class IdempotencyStore {
  private readonly ttlMs: number;
  private readonly clock: Clock;
  private readonly entries = new Map<string, Entry>();

  constructor(options: { ttlMs: number; clock: Clock }) {
    if (!Number.isInteger(options.ttlMs) || options.ttlMs <= 0) fail("ttlMs must be a positive integer");
    this.ttlMs = options.ttlMs;
    this.clock = options.clock;
  }

  private prune(nowMs: number): void {
    for (const [key, entry] of this.entries) {
      if (entry.expiresAtMs <= nowMs) this.entries.delete(key);
    }
  }

  /** 只读查询,不占用键;用于断点续跑前统计还差多少。 */
  peek(key: string): IdempotencyLookup {
    this.prune(this.clock.now());
    const entry = this.entries.get(key);
    if (entry === undefined) return { state: "fresh" };
    return entry.status === "done" ? { state: "done", result: entry.result } : { state: "in-flight" };
  }

  /** 返回 fresh 才能调用模型;in-flight 表示另一个 worker 正在处理。 */
  begin(key: string): IdempotencyLookup {
    const lookup = this.peek(key);
    if (lookup.state === "fresh") {
      this.entries.set(key, { status: "in-flight", expiresAtMs: this.clock.now() + this.ttlMs });
    }
    return lookup;
  }

  complete(key: string, result: string): void {
    if (key.trim() === "") fail("key is required");
    this.entries.set(key, { status: "done", result, expiresAtMs: this.clock.now() + this.ttlMs });
  }

  size(): number {
    this.prune(this.clock.now());
    return this.entries.size;
  }
}

/** 窗口下限 = 最长作业时长 + 重试尾巴 + 时钟偏差。 */
export function requiredWindowMs(jobDurationMs: number, retryTailMs: number, clockSkewMs: number): number {
  for (const [value, field] of [
    [jobDurationMs, "jobDurationMs"],
    [retryTailMs, "retryTailMs"],
    [clockSkewMs, "clockSkewMs"],
  ] as const) {
    if (!Number.isInteger(value) || value < 0) fail(`${field} must be a non-negative integer`);
  }
  return jobDurationMs + retryTailMs + clockSkewMs;
}

export interface RetryPolicy {
  /** 整个作业允许的重试绝对上限。 */
  maxRetries: number;
  /** 重试数不得超过首次尝试数的比例,防止失败放大。 */
  maxRetryRatio: number;
}

export interface RetrySnapshot {
  firstAttempts: number;
  retries: number;
  remaining: number;
}

/**
 * 重试预算是作业级的共享池,不是每个条目各给三次。
 * 否则上游整体故障时,每个条目都重试会形成放大风暴。
 */
export class RetryLedger {
  private readonly policy: RetryPolicy;
  private firstAttempts = 0;
  private retries = 0;

  constructor(policy: RetryPolicy) {
    if (!Number.isInteger(policy.maxRetries) || policy.maxRetries < 0) fail("maxRetries must be a non-negative integer");
    if (!Number.isFinite(policy.maxRetryRatio) || policy.maxRetryRatio < 0 || policy.maxRetryRatio > 1) {
      fail("maxRetryRatio must be in [0, 1]");
    }
    this.policy = { ...policy };
  }

  recordFirstAttempt(count = 1): void {
    if (!Number.isInteger(count) || count < 0) fail("count must be a non-negative integer");
    this.firstAttempts += count;
  }

  canRetry(): boolean {
    const ratioBudget = Math.floor(this.firstAttempts * this.policy.maxRetryRatio);
    return this.retries < this.policy.maxRetries && this.retries < ratioBudget;
  }

  recordRetry(): void {
    if (!this.canRetry()) fail("retry budget exhausted");
    this.retries += 1;
  }

  snapshot(): RetrySnapshot {
    const ratioBudget = Math.floor(this.firstAttempts * this.policy.maxRetryRatio);
    return {
      firstAttempts: this.firstAttempts,
      retries: this.retries,
      remaining: Math.max(0, Math.min(this.policy.maxRetries, ratioBudget) - this.retries),
    };
  }
}

export type ItemOutcome = "ok" | "retryable" | "permanent" | "quarantined";

export interface ItemRun {
  key: string;
  outcome: ItemOutcome;
  attempts: number;
  costMicros: number;
}

export interface RunSummary {
  total: number;
  ok: number;
  retryable: number;
  permanent: number;
  quarantined: number;
  failureRate: number;
  costMicros: number;
}

export function summarizeRun(runs: readonly ItemRun[]): RunSummary {
  const summary: RunSummary = {
    total: runs.length, ok: 0, retryable: 0, permanent: 0, quarantined: 0, failureRate: 0, costMicros: 0,
  };
  for (const run of runs) {
    if (!Number.isInteger(run.attempts) || run.attempts < 0) fail("attempts must be a non-negative integer");
    if (run.costMicros < 0) fail("costMicros must be non-negative");
    summary[run.outcome] += 1;
    summary.costMicros += run.costMicros;
  }
  summary.failureRate = summary.total === 0 ? 0 : (summary.total - summary.ok) / summary.total;
  return summary;
}

export interface CommitPolicy {
  maxFailureRate: number;
  allowPartial: boolean;
}

export interface CommitDecision {
  mode: "all" | "partial" | "hold";
  reason: string;
}

/** 部分失败不是异常:要么发布成功子集并隔离失败条目,要么整批扣住。 */
export function decideCommit(summary: RunSummary, policy: CommitPolicy): CommitDecision {
  if (!Number.isFinite(policy.maxFailureRate) || policy.maxFailureRate < 0 || policy.maxFailureRate > 1) {
    fail("maxFailureRate must be in [0, 1]");
  }
  if (summary.total === 0) return { mode: "hold", reason: "空作业没有可提交内容" };
  if (summary.failureRate === 0) return { mode: "all", reason: "全部条目成功" };
  if (policy.allowPartial && summary.failureRate <= policy.maxFailureRate) {
    return { mode: "partial", reason: `失败率 ${(summary.failureRate * 100).toFixed(2)}% 在阈值内` };
  }
  return { mode: "hold", reason: `失败率 ${(summary.failureRate * 100).toFixed(2)}% 超过阈值或不允许部分发布` };
}

export interface ResumePlan<T extends { key: string }> {
  todo: T[];
  reused: number;
  inFlight: number;
}

/** 断点续跑:已完成的条目直接复用结果,不再调用模型。 */
export function planResume<T extends { key: string }>(
  items: readonly T[],
  store: IdempotencyStore,
): ResumePlan<T> {
  const plan: ResumePlan<T> = { todo: [], reused: 0, inFlight: 0 };
  for (const item of items) {
    const lookup = store.peek(item.key);
    if (lookup.state === "done") plan.reused += 1;
    else if (lookup.state === "in-flight") plan.inFlight += 1;
    else plan.todo.push(item);
  }
  return plan;
}

planShards 用 bucket % workerCount 分配,简单但扩缩容会移动大量桶。生产上可以换成一致性哈希或区间分配,只要保证「条目 → 桶」不变、幂等键不含 worker 号,结论不变。

RetryLedger 的预算在首次尝试登记后固定。如果作业边发现边处理,比例预算会随首次尝试数增长,前期的失败可能因为预算太小被过早隔离;这时应改为按滚动窗口或按已完成比例计算,并在作业报告里写明口径。

IdempotencyStore 的 in-flight 也有过期时间。进程崩溃后,键会在 TTL 后回到 fresh,允许重跑;这是有意的取舍:不设过期,崩溃会永久卡住条目;设得太短,又会重复计费。TTL 必须大于最长运行时间加时钟偏差。

测试与运行结果

分片、重试与幂等相关测试:

node --test --test-name-pattern="按键分片|重试预算耗尽|断点续跑|幂等窗口短于" test/perf/batch.test.ts

本机一次运行的真实输出摘要:

ℹ tests 4
ℹ suites 0
ℹ pass 4
ℹ fail 0
ℹ duration_ms 586.0255

测试没有只验证「重跑能跑通」,而是验证了三个反例:输入前插 500 条后按键分片零漂移、预算耗尽后条目被隔离而不是继续重试、1 小时窗口在 2 小时后重跑会重复 6,300 次。反例决定这套机制在事故里是否真的生效。

与 7.1、卷一 8.2 的衔接

7.1 决定哪些作业离开在线路径;本节决定离开之后怎样安全地重复执行。幂等键与卷一 8.2 的计量键要一致:计费用什么键,去重就用什么键,否则会出现「结果复用了,账单又记了一次」。

分片与重试也不能绕过 7.1 的窗口预算。重试消耗的是同一个窗口的容量;如果预算不设上限,重试会把窗口吃掉,让原本能完成的作业错过截止。

小结

分片键要绑定业务身份,不绑数组下标;条目到虚拟桶稳定,桶到 worker 可以变,但幂等键不能含 worker 号。重试预算放在作业级共享池,由绝对上限与首次尝试比例共同约束,耗尽后隔离而不是无限重试。幂等键由作业、条目与输入版本组成,去重窗口覆盖运行时间、重试尾巴与时钟偏差。

部分失败要按条目隔离,提交方式由失败率阈值与是否允许部分发布共同决定。已隔离条目的成本仍然要计量。

下一节把这三件事放进窗口:批大小、并发与单位成本怎样取舍,错过窗口时按什么顺序降级。

阅读导航:上一节:7.1 在线与离线的边界 · 下一节:7.3 批处理调度与成本模型 。

常见错误与边界

  • 按数组下标分片。输入顺序一变,所有条目换分片,重跑去重全部失效。
  • 把 worker 号或分片号拼进幂等键。扩容一次,同一条目就变成新工作。
  • 每个条目各给三次重试。上游整体故障时,重试流量会放大数倍。
  • 预算耗尽后静默跳过。失败率好看,实际缺结果。
  • 幂等窗口只覆盖单次运行。崩溃重跑跨天时,重复计费照样发生。
  • 用 in-flight 状态统计缺口。统计动作本身会把键占住,真正处理时无键可用。
  • 把部分失败当异常整批回滚。一条毒数据可以毁掉 12 万条已完成结果。
  • 失败率为零才允许提交。业务可能允许 1% 的缺失,但必须显式配置并输出隔离清单。
  • 结果复用了但计量没复用。幂等省下的调用,不能在账单上再记一次。
  • 重试不占窗口容量。重试预算再小,也要算进当天的作业预算。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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