《互联网产品的 AI 架构·推理性能与成本工程》5.3 缓存失效与一致性

为修 300 条过期结果,团队删掉 10,000 条缓存,重建成本 117.4 万微元;细粒度失效只要 6,300 微元。本节算清失效扩散、版本回滚、租户分区与误命中审计的成本,并用 8 项测试逐项复算失效、回滚与降级交互产生的每一张判定表。

为 300 条过期结果删掉 10,000 条

「回声」的提示词升级到 v4 后,审计发现 300 条缓存结果仍带着旧口径。值班同学的第一反应是「全删」,一条命令清空了 10,000 条条目。清空只花了几秒,代价在后面:接下来半小时,所有请求同时回源,缓存命中率归零,模型调用量涨了三倍。按示例参数算,这次误删造成的重建成本是 116.4 万微元,而真正需要作废的 300 条,传播成本只有 300 微元。

失效从来不是「删几条数据」这么简单。它同时产生传播成本、误删后的重建成本,以及细粒度索引的维护成本。本节把这三类成本放进同一张账,并讨论版本、租户、误命中与降级四个一致性边界;文中金额与比例均来自 ai-lab 的示例样本运行结果,不是生产实测。

一次全量失效的账单

示例参数:作用域内 10,000 条条目,其中 300 条过期,一条仍有效的结果被误删后重建要 120 微元,单条传播成本 1 微元。两种做法:

做法删除条目传播成本索引成本误删重建总成本
粗粒度全删10,00010,00001,164,0001,174,000
细粒度按事实删3003006,00006,300

细粒度便宜 1,167,700 微元,前提是维护「事实到缓存键」的索引:示例里 2,000 次写入、每次 3 微元,共 6,000 微元。索引不是免费的,但它把「删 10,000 条」变成了「删 300 条」。反过来,如果作用域只有 100 条、过期 5 条、写入却有 10,000 次,细粒度总成本 20,005 微元,粗粒度只有 2,000 微元。粗粒度反而便宜 18,005 微元。粒度没有绝对优劣,取决于失效范围与写入频率的比值。

失效的三类成本

传播按被删条目数计,误删重建按「误删条目数 × 重建成本」计,索引维护按「写入次数 × 单次索引成本」计。三类成本的量级完全不同。示例里传播只占粗粒度总成本的 0.85%,误删重建占 99.15%。团队在评审失效方案时,最容易盯着「删得快不快」,而真正决定账单的是「删掉了多少本来还能用的结果」。

版本联动与回滚

版本联动解决的是「旧结果不能在新版本下被读到」。卷一 9.2 用作用域纪元把 promptVersion、modelId、permissionScope 写进准入条件,旧版本写入会被拒绝。本节关心的是经济粒度:一次版本推进要作废多少条目。版本键越细,失效范围越小,但条目在过渡期会同时存在新旧两份,容量翻倍、命中率下降。版本键越粗,过渡期短,但一次回滚会连带作废大量仍有效的结果。可执行的做法是把版本粒度与回滚概率一起评估。

回滚时是否保留上一版缓存,是一笔保险:

回滚概率期望省下的重建多占一份存储净收益结论
2%24,00050,000−26,000不保留
20%240,00050,000+190,000保留

示例是 10,000 条、单条重建 120 微元、单条存储 5 微元。回滚概率低于 4.17% 时,保留上一版不划算;高于它,保留就是理性选择。概率只能从发布记录估,不能拍一个「感觉不会回滚」。

租户隔离的容量账

共享缓存池能按需求分配容量,按租户均分则每个租户拿到固定份额。同样 128 条总容量、480 次访问、租户流量 70/10/10/10 的示例:

方案命中命中率相对共享池
共享池 128 条25953.96%—
每租户 32 条18438.33%少 75 次命中

大租户需要更多容量,小租户用不满自己的 32 条,均分把后者的闲置容量浪费了。共享池少丢 75 次命中,代价是必须把 tenantId 写进每一个键,且共享层的失效与配额要按租户隔离。什么时候仍然要分区?合规要求数据物理隔离、单个租户流量会打爆共享池、或者某个租户的键分布会污染其他租户的命中。分区是用命中换确定性,换多少必须用这张表算,而不是默认「隔离更安全」。

误命中评估:预算与区间

语义缓存的误命中率不能靠感觉。先算预算:单次命中净省 248 微元、单次误命中代价 1,500 微元,则误命中率上限是 16.53%;超过它,语义缓存整体不赚钱。

再用审计样本估计真实误命中率。400 次语义命中中确认 12 次错误,点估计 3.00%,Wilson 95% 区间是 [1.72%, 5.17%],上界远低于 16.53%,判 within-budget。另一个能力 80 次命中中 6 次错误,点估计 7.50%,区间 [3.48%, 15.41%],预算 11.67% 落在区间内,只能判 inconclusive,不能判达标。看区间而不是点估计,是因为小样本的点估计会骗人。80 次里抽到 6 次错误,真实错误率可能低到 3.48%,也可能高到 15.41%;后者已经超过预算。样本不够时,正确结论是「继续抽样」,不是「看起来还行」。

缓存与降级的交互

缓存过期不等于必须回源。当模型不可用或预算耗尽时,送一份有界的旧结果,可能比降级模板或直接失败更好。示例参数:TTL 30 分钟、最大 stale 窗口 2 小时、送旧代价 40、降级代价 120、失败代价 400。

场景可选动作与代价选择
10 分钟新鲜,0fresh
60 分钟送旧 40 / 降级 120 / 失败 400serve-stale
60 分钟,送旧代价 300送旧 300 / 降级 120 / 失败 400degrade
160 分钟,超出 stale 窗口降级 120 / 失败 400degrade
160 分钟,无降级可用失败 400fail

三条边界必须同时成立才允许送旧:仍在 stale 窗口内、送旧代价不高于其他选项、结果被明确标记为 stale。少了任何一条,送旧就会从「兜底」变成「默认体验」,用户会在不知情的情况下持续拿到过期答案。

与卷一 9.2、9.3 的分工

卷一 9.2 讲失效机制:作用域纪元、四类失效的传播范围、删除权如何从结果传到索引。9.3 讲度量口径:请求级、token 级、成本级命中率,以及精确命中与语义命中必须分开。本节不重复机制,只算机制背后的账:粗粒度与细粒度各要多少钱、保留上一版值不值、租户分区丢多少命中、误命中率的上界是否在预算内。机制决定「能不能一致」,经济性决定「为一致性付多少」。

实现

下面的模块做四件事:比较粗粒度与细粒度失效、比较租户共享与分区容量、用 Wilson 区间审计误命中、在过期时选择送旧/降级/失败。它不操作真实缓存,只处理可复算的输入。

// file: src/perf/cache-consistency.ts
// tier: L2
// verify: node --test test/perf/cache-consistency.test.ts

import { AiError } from "../core/errors.ts";
import type { CacheAccess } from "./cache-tiers.ts";
import { simulateLayer } from "./cache-tiers.ts";

export interface TenantAccess extends CacheAccess {
  tenantId: string;
}

export interface TenantCapacityReport {
  requests: number;
  sharedHits: number;
  partitionedHits: number;
  /** 分区后少掉的命中次数,正数表示容量碎片化的代价。 */
  lostHits: number;
}

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

function requireInteger(value: number, field: string, min: number): number {
  if (!Number.isInteger(value) || value < min) fail(`${field} 必须是 >= ${min} 的整数`);
  return value;
}

/**
 * 同样总容量下,按租户均分与共享池的命中差异。
 * 键必须带租户前缀:共享池省内存,但绝不能省隔离。
 */
export function compareTenantCapacity(
  accesses: readonly TenantAccess[],
  sharedCapacity: number,
  perTenantCapacity: number,
): TenantCapacityReport {
  if (accesses.length === 0) fail("访问序列不能为空");
  requireInteger(sharedCapacity, "sharedCapacity", 0);
  requireInteger(perTenantCapacity, "perTenantCapacity", 0);

  const tenants = new Set<string>();
  const sharedTrace: CacheAccess[] = accesses.map((access) => {
    if (access.tenantId.trim() === "") fail("tenantId 不能为空");
    tenants.add(access.tenantId);
    return {
      key: `${access.tenantId}\u0000${access.key}`,
      cluster: access.cluster === undefined ? undefined : `${access.tenantId}\u0000${access.cluster}`,
    };
  });

  const sharedHits = simulateLayer(sharedTrace, sharedCapacity, "lru").hits;
  let partitionedHits = 0;
  for (const tenantId of tenants) {
    const own = accesses
      .filter((access) => access.tenantId === tenantId)
      .map((access) => ({ key: access.key, cluster: access.cluster }));
    partitionedHits += simulateLayer(own, perTenantCapacity, "lru").hits;
  }
  return {
    requests: accesses.length,
    sharedHits,
    partitionedHits,
    lostHits: sharedHits - partitionedHits,
  };
}

export interface InvalidationOption {
  strategy: "coarse" | "fine";
  entries: number;
  staleEntries: number;
  /** 一条本可命中的结果被误删后,重建它的成本。 */
  hitValueMicros: number;
  propagationMicrosPerEntry: number;
  /** 细粒度失效需要维护“事实到缓存键”的索引,每次写入都要付。 */
  indexMicrosPerWrite: number;
  writes: number;
}

export interface InvalidationCost {
  strategy: "coarse" | "fine";
  purgedEntries: number;
  wastedRebuilds: number;
  propagationMicros: number;
  indexMicros: number;
  wastedHitValueMicros: number;
  totalMicros: number;
}

function evaluateInvalidation(option: InvalidationOption): InvalidationCost {
  requireInteger(option.entries, "entries", 1);
  requireInteger(option.staleEntries, "staleEntries", 0);
  if (option.staleEntries > option.entries) fail("staleEntries 不能大于 entries");
  requireInteger(option.hitValueMicros, "hitValueMicros", 0);
  requireInteger(option.propagationMicrosPerEntry, "propagationMicrosPerEntry", 0);
  requireInteger(option.indexMicrosPerWrite, "indexMicrosPerWrite", 0);
  requireInteger(option.writes, "writes", 0);

  const purgedEntries = option.strategy === "coarse" ? option.entries : option.staleEntries;
  const wastedRebuilds = purgedEntries - option.staleEntries;
  const propagationMicros = purgedEntries * option.propagationMicrosPerEntry;
  const indexMicros = option.strategy === "fine" ? option.writes * option.indexMicrosPerWrite : 0;
  const wastedHitValueMicros = wastedRebuilds * option.hitValueMicros;
  return {
    strategy: option.strategy,
    purgedEntries,
    wastedRebuilds,
    propagationMicros,
    indexMicros,
    wastedHitValueMicros,
    totalMicros: propagationMicros + indexMicros + wastedHitValueMicros,
  };
}

/** 粗粒度失效便宜在索引,贵在误删;细粒度相反。比较的是总成本,不是删除条数。 */
export function compareInvalidation(
  coarse: InvalidationOption,
  fine: InvalidationOption,
): { coarse: InvalidationCost; fine: InvalidationCost; preferred: "coarse" | "fine"; savingMicros: number } {
  if (coarse.strategy !== "coarse" || fine.strategy !== "fine") {
    fail("compareInvalidation 需要一条 coarse 与一条 fine 方案");
  }
  const left = evaluateInvalidation(coarse);
  const right = evaluateInvalidation(fine);
  return {
    coarse: left,
    fine: right,
    preferred: left.totalMicros <= right.totalMicros ? "coarse" : "fine",
    savingMicros: Math.abs(left.totalMicros - right.totalMicros),
  };
}

export interface WilsonInterval {
  point: number;
  low: number;
  high: number;
}

/** 误命中率是小比例事件,用 Wilson 区间比正态近似更稳,样本少时不会给出负下界。 */
export function wilsonInterval(successes: number, total: number, z = 1.96): WilsonInterval {
  requireInteger(successes, "successes", 0);
  requireInteger(total, "total", 1);
  if (successes > total) fail("successes 不能大于 total");
  if (!(z > 0) || !Number.isFinite(z)) fail("z 必须是正有限数");
  const point = successes / total;
  const denominator = 1 + (z * z) / total;
  const centre = point + (z * z) / (2 * total);
  const spread = z * Math.sqrt((point * (1 - point)) / total + (z * z) / (4 * total * total));
  return {
    point,
    low: Math.max(0, (centre - spread) / denominator),
    high: Math.min(1, (centre + spread) / denominator),
  };
}

export interface FalseHitAuditInput {
  hits: number;
  falseHits: number;
  savedMicrosPerHit: number;
  falseHitCostMicros: number;
  z?: number;
}

export interface FalseHitAuditReport extends WilsonInterval {
  /** 预算 = 单次命中净省 / 单次误命中代价。 */
  budget: number;
  verdict: "within-budget" | "over-budget" | "inconclusive";
}

/** 审计结论看区间上界,不看点估计:样本少时“看起来达标”可能只是没抽到。 */
export function auditFalseHits(input: FalseHitAuditInput): FalseHitAuditReport {
  requireInteger(input.hits, "hits", 1);
  requireInteger(input.falseHits, "falseHits", 0);
  if (input.falseHits > input.hits) fail("falseHits 不能大于 hits");
  requireInteger(input.savedMicrosPerHit, "savedMicrosPerHit", 0);
  requireInteger(input.falseHitCostMicros, "falseHitCostMicros", 0);
  const interval = wilsonInterval(input.falseHits, input.hits, input.z ?? 1.96);
  const budget =
    input.falseHitCostMicros === 0
      ? 1
      : Math.min(1, input.savedMicrosPerHit / input.falseHitCostMicros);
  const verdict =
    interval.high <= budget ? "within-budget" : interval.low > budget ? "over-budget" : "inconclusive";
  return { ...interval, budget, verdict };
}

export interface VersionRetentionReport {
  retain: boolean;
  expectedSavingMicros: number;
  storageCostMicros: number;
  netMicros: number;
}

/**
 * 回滚时保留上一版本的缓存,等价于买一份保险:
 * 期望省下的冷启动重建成本,要大于多占一份内存的存储成本。
 */
export function evaluateVersionRetention(
  entries: number,
  rebuildCostMicros: number,
  storageMicrosPerEntry: number,
  rollbackProbability: number,
): VersionRetentionReport {
  requireInteger(entries, "entries", 0);
  requireInteger(rebuildCostMicros, "rebuildCostMicros", 0);
  requireInteger(storageMicrosPerEntry, "storageMicrosPerEntry", 0);
  if (!(rollbackProbability >= 0 && rollbackProbability <= 1)) {
    fail("rollbackProbability 必须落在 [0, 1]");
  }
  const expectedSavingMicros = Math.round(rollbackProbability * entries * rebuildCostMicros);
  const storageCostMicros = entries * storageMicrosPerEntry;
  const netMicros = expectedSavingMicros - storageCostMicros;
  return { retain: netMicros > 0, expectedSavingMicros, storageCostMicros, netMicros };
}

export interface StaleDecisionInput {
  ageMs: number;
  ttlMs: number;
  maxStaleMs: number;
  staleServeCostMicros: number;
  degradeCostMicros: number;
  failCostMicros: number;
  degradedAvailable: boolean;
}

export interface StaleDecision {
  decision: "fresh" | "serve-stale" | "degrade" | "fail";
  expectedCostMicros: number;
  reason: string;
}

/**
 * 缓存与降级的交互:过期不等于必须回源。
 * 只有在 stale 窗口内、且“送旧结果”的代价不高于降级或失败时,才允许送旧。
 */
export function decideStale(input: StaleDecisionInput): StaleDecision {
  if (!Number.isFinite(input.ageMs) || input.ageMs < 0) fail("ageMs 必须是非负有限数");
  if (!Number.isFinite(input.ttlMs) || input.ttlMs < 0) fail("ttlMs 必须是非负有限数");
  if (!Number.isFinite(input.maxStaleMs) || input.maxStaleMs < 0) fail("maxStaleMs 必须是非负有限数");
  requireInteger(input.staleServeCostMicros, "staleServeCostMicros", 0);
  requireInteger(input.degradeCostMicros, "degradeCostMicros", 0);
  requireInteger(input.failCostMicros, "failCostMicros", 0);

  if (input.ageMs <= input.ttlMs) {
    return { decision: "fresh", expectedCostMicros: 0, reason: "within-ttl" };
  }
  const staleMs = input.ageMs - input.ttlMs;
  const alternatives: Array<{ decision: StaleDecision["decision"]; cost: number; reason: string }> = [];
  if (staleMs <= input.maxStaleMs) {
    alternatives.push({ decision: "serve-stale", cost: input.staleServeCostMicros, reason: "stale-within-window" });
  }
  if (input.degradedAvailable) {
    alternatives.push({ decision: "degrade", cost: input.degradeCostMicros, reason: "degraded-available" });
  }
  alternatives.push({ decision: "fail", cost: input.failCostMicros, reason: "no-alternative" });
  alternatives.sort((left, right) => left.cost - right.cost);
  return {
    decision: alternatives[0].decision,
    expectedCostMicros: alternatives[0].cost,
    reason: alternatives[0].reason,
  };
}

compareInvalidation 把传播、索引与误删重建分开返回,评审才能指出「贵在哪一项」。compareTenantCapacity 复用 5.2 的单层模拟,保证共享池与分区使用同一套淘汰口径。

auditFalseHits 返回区间与预算,而不是一个布尔值。布尔值会掩盖样本量;区间能告诉团队「现在是超标、达标,还是样本不够」。

decideStale 把「过期」拆成新鲜、送旧、降级、失败四个动作。它不假设送旧总是对的,而是把三种代价放在一起排序。

测试与运行结果

测试覆盖租户分区、两种失效粒度、Wilson 区间、误命中审计、版本保留与 stale 决策。执行:

node --test test/perf/cache-consistency.test.ts

本机一次运行的真实输出摘要如下,耗时随机器变化:

ℹ tests 8
ℹ pass 8
ℹ fail 0
ℹ duration_ms 149.624417

示例里的访问序列、审计样本与成本参数都是固定的,同样的代码在任何机器上得到同一组结论。真实系统要把 entries、staleEntries、writes 换成自己的观测值;公式不变,结论会变。

小结

失效的成本不止传播,还包括误删后的重建与细粒度索引的维护。示例里粗粒度全删 1,174,000 微元,细粒度 6,300 微元;但当作用域小、写入多时,粗粒度反而更便宜。版本联动要同时评估回滚概率与存储成本;租户共享池在倾斜流量下多出 75 次命中,但必须把租户写进键;误命中审计看区间上界,不看点估计;缓存与降级之间,送旧只能在有界窗口内、且代价低于其他选项时使用。

下一章换一个复用维度:当结果不能被复用时,怎样用更少的 token 表达同样的上下文。

阅读导航:上一节:5.2 缓存分层与容量 · 下一节:6.1 量化的精度与延迟权衡 。

常见错误与边界

  • 用「全删」处理局部过期。删除很快,回源重建才是账单的大头。
  • 只算传播成本,不算误删重建。示例里后者占粗粒度总成本的 99.15%。
  • 认为细粒度永远更好。作用域小、写入多时,索引维护会反超。
  • 租户分区默认更安全。示例里均分丢掉 75 次命中,安全要用命中换。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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