为 300 条过期结果删掉 10,000 条
「回声」的提示词升级到 v4 后,审计发现 300 条缓存结果仍带着旧口径。值班同学的第一反应是「全删」,一条命令清空了 10,000 条条目。清空只花了几秒,代价在后面:接下来半小时,所有请求同时回源,缓存命中率归零,模型调用量涨了三倍。按示例参数算,这次误删造成的重建成本是 116.4 万微元,而真正需要作废的 300 条,传播成本只有 300 微元。
失效从来不是「删几条数据」这么简单。它同时产生传播成本、误删后的重建成本,以及细粒度索引的维护成本。本节把这三类成本放进同一张账,并讨论版本、租户、误命中与降级四个一致性边界;文中金额与比例均来自 ai-lab 的示例样本运行结果,不是生产实测。
一次全量失效的账单
示例参数:作用域内 10,000 条条目,其中 300 条过期,一条仍有效的结果被误删后重建要 120 微元,单条传播成本 1 微元。两种做法:
| 做法 | 删除条目 | 传播成本 | 索引成本 | 误删重建 | 总成本 |
|---|---|---|---|---|---|
| 粗粒度全删 | 10,000 | 10,000 | 0 | 1,164,000 | 1,174,000 |
| 细粒度按事实删 | 300 | 300 | 6,000 | 0 | 6,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,000 | 50,000 | −26,000 | 不保留 |
| 20% | 240,000 | 50,000 | +190,000 | 保留 |
示例是 10,000 条、单条重建 120 微元、单条存储 5 微元。回滚概率低于 4.17% 时,保留上一版不划算;高于它,保留就是理性选择。概率只能从发布记录估,不能拍一个「感觉不会回滚」。
租户隔离的容量账
共享缓存池能按需求分配容量,按租户均分则每个租户拿到固定份额。同样 128 条总容量、480 次访问、租户流量 70/10/10/10 的示例:
| 方案 | 命中 | 命中率 | 相对共享池 |
|---|---|---|---|
| 共享池 128 条 | 259 | 53.96% | — |
| 每租户 32 条 | 184 | 38.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 分钟 | 新鲜,0 | fresh |
| 60 分钟 | 送旧 40 / 降级 120 / 失败 400 | serve-stale |
| 60 分钟,送旧代价 300 | 送旧 300 / 降级 120 / 失败 400 | degrade |
| 160 分钟,超出 stale 窗口 | 降级 120 / 失败 400 | degrade |
| 160 分钟,无降级可用 | 失败 400 | fail |
三条边界必须同时成立才允许送旧:仍在 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 次命中,安全要用命中换。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。