扩容申请被一张曲线拦下
「回声」的缓存命中率停在 50%,团队申请把容量从 64 条翻倍到 128 条,理由很直接:容量翻倍,命中率至少该涨一大截。
扩容后命中率到 60%,只多了 10 个百分点。更尴尬的是,另一个团队给 ticket.classify 加了语义缓存,命中率涨了 2 个百分点,月度净收益反而少了 534 微元。两件事指向同一个问题:容量与层数都不是「越多越好」,它们各自有边际收益,也有各自的查找与服务成本。本节用同一套确定性访问序列,把容量、淘汰策略与分层放进取舍表;文中命中数、比例与微元均来自 ai-lab 的示例样本运行结果,不是生产实测。
扩容一倍的收益不是一倍
先看单层容量曲线。240 次访问、120 个键、按 1.8 次幂倾斜的示例序列上,LRU 的结果是:
| 容量 | 命中 | 命中率 | 淘汰 | 相比上一档新增命中 |
|---|---|---|---|---|
| 8 | 26 | 10.83% | 206 | — |
| 16 | 43 | 17.92% | 181 | +17 |
| 32 | 75 | 31.25% | 133 | +32 |
| 64 | 120 | 50.00% | 56 | +45 |
| 128 | 144 | 60.00% | 0 | +24 |
从 64 到 128,容量翻倍,新增命中只有 24 次;而 32 到 64 新增 45 次。原因不是「容量没用」,而是 128 条时 120 个键已全部装下、淘汰为 0;命中率的上限由重复率决定,只有 60%,继续扩容不能再提高它。
判断该不该扩容,看的是「每多一条容量能多几次命中」,不是「命中率还能涨多少」。示例里 32→64 每条约 0.70 次新增命中,64→128 只有 0.375 次;后一档的存储成本相同,收益却减半。
容量上限与淘汰策略
容量相同,淘汰策略也会改变命中。示例序列上的对比:
| 容量 | FIFO | LRU | LFU |
|---|---|---|---|
| 16 | 40(16.67%) | 43(17.92%) | 56(23.33%) |
| 32 | 68(28.33%) | 75(31.25%) | 82(34.17%) |
LFU 在这条倾斜序列上最好,因为它把容量留给高频键,不会被一次性长尾挤出。但 LFU 有两个代价:一是新键要先积累计数,冷启动阶段几乎不命中;二是旧热点降温后仍占着位置,需要额外的衰减或过期机制。LRU 对突发流量更稳,FIFO 最便宜但完全不看复用。
没有一种策略在所有序列上最优。可执行的做法是:用自己能力最近一段访问序列,把三种策略各跑一遍,选命中最高且实现成本可接受的那个;不要因为「LFU 更聪明」就直接上 LFU。
三层缓存放在哪
容量不是唯一维度,层的位置决定了每次查找的价格。
| 层 | 判定方式 | 示例容量 | 查找成本 | 命中服务 | 适合放什么 |
|---|---|---|---|---|---|
| 本地 | 进程内精确键 | 16 | 1 微元 | 2 微元 | 极热、可容忍实例间不一致 |
| 共享 | 跨实例精确键 | 128 | 4 微元 | 6 微元 | 跨实例复用、需要统一失效 |
| 语义 | 意图簇相似 | 128 | 9 微元 | 10 微元 | 高价值、误命中可承受 |
数字是示例参数,不是厂商报价;重要的是量级:语义层的查找比本地贵九倍,命中服务贵五倍,还多一项误命中成本。它必须换来足够多的额外命中,才值得放在链路末端。
分层查找的账
把三种层按「本地→共享→语义」串联,请求在任一层命中就停止;只有未命中的请求才进入下一层。三个能力的净收益是:
| 能力 | 无缓存 | 仅本地 | 本地+共享 | 再加语义 | 语义层增量 |
|---|---|---|---|---|---|
| chat.reply | 0 | 9,730 | 26,540 | 26,830 | +290 |
| search.summary | 0 | 6,766 | 28,934 | 34,654 | +5,720 |
| ticket.classify | 0 | 432 | 972 | 438 | −534 |
chat.reply 的语义层只多 10 次命中,扣掉查找与误命中后净增 290 微元,相对共享方案只有 1.09% 的增量;search.summary 多 29 次命中,增量 19.77%;ticket.classify 只多 2 次,查找成本却翻倍,净收益从 972 掉到 438。
这张表说明「分层」不是架构姿势,而是一笔逐层追加的投资。每加一层,都要问:它带来的额外命中,扣掉该层的查找、服务与误命中成本后,还剩下多少?
按能力设定缓存层
用「增量净收益超过 5% 才加层」的示例策略,三个能力的配置是:
| 能力 | 选层 | 容量 | 关键理由 |
|---|---|---|---|
| chat.reply | 本地+共享 | 16 + 128 | 语义增量 290,仅 1.09%,不值得多一跳 |
| search.summary | 本地+共享+语义 | 16 + 128 + 128 | 语义增量 5,720,占 19.77%,回本 |
| ticket.classify | 本地+共享 | 16 + 128 | 语义增量 −534,加层即亏 |
三个能力共用一套层结构,但容量与是否启用语义层按能力配置。共享层可以是一个池,按能力分配额;语义层只为 search.summary 开启索引,避免为低价值能力维护向量与审计成本。
5% 是策略值,不是真理。团队可以按业务对错误率的容忍度调整;关键是把它写成显式规则,并和样本一起版本化,而不是每次评审临时拍脑袋。
与卷一 9.1 的分工
卷一 9.1 把缓存分成精确、语义与单飞三层,讲的是「按什么判定命中」以及键如何隔离。本节不重复判定方式,只回答两个卷一没有展开的问题:同样总容量应该放在本地还是共享,以及一个能力到底该不该加语义层。判定方式是正确性问题,容量与分层是经济性问题;前者错了会串数据,后者错了只是多花钱。
实现
下面的模块做三件事:单层容量与淘汰模拟、分层串联模拟、按层统计命中与成本。它不访问真实缓存,输入是一条确定性访问序列;语义层是否命中由访问里的 cluster 标注决定。
// file: src/perf/cache-tiers.ts
// tier: L2
// verify: node --test test/perf/cache-tiers.test.ts
import { AiError } from "../core/errors.ts";
export type EvictionPolicy = "lru" | "lfu" | "fifo";
/** cluster 是“同一意图”的标注,只有语义层用它判定命中。 */
export interface CacheAccess {
key: string;
cluster?: string;
}
export interface LayerStats {
capacity: number;
hits: number;
evictions: number;
}
interface LayerState {
capacity: number;
policy: EvictionPolicy;
semantic: boolean;
keys: Map<string, true>;
counts: Map<string, number>;
hits: number;
lookups: number;
evictions: number;
}
function fail(message: string): never {
throw new AiError("invalid-request", `cache-tiers: ${message}`);
}
function checkAccesses(accesses: readonly CacheAccess[]): void {
if (accesses.length === 0) fail("访问序列不能为空");
for (const access of accesses) {
if (access.key.trim() === "") fail("key 不能为空");
}
}
function createState(
capacity: number,
policy: EvictionPolicy,
semantic: boolean,
): LayerState {
if (!Number.isInteger(capacity) || capacity < 0) fail("capacity 必须是非负整数");
return {
capacity,
policy,
semantic,
keys: new Map(),
counts: new Map(),
hits: 0,
lookups: 0,
evictions: 0,
};
}
/** 一次查找:命中返回 true;未命中时写入本层,超容量按策略淘汰。 */
function lookup(state: LayerState, access: CacheAccess): boolean {
state.lookups += 1;
if (state.capacity === 0) return false;
const key = state.semantic ? (access.cluster ?? access.key) : access.key;
if (state.policy === "lfu") {
const count = state.counts.get(key);
if (count !== undefined) {
state.counts.set(key, count + 1);
state.hits += 1;
return true;
}
state.counts.set(key, 1);
if (state.counts.size > state.capacity) {
let victim = "";
let lowest = Number.POSITIVE_INFINITY;
for (const [candidate, value] of state.counts) {
if (value < lowest) {
victim = candidate;
lowest = value;
}
}
state.counts.delete(victim);
state.evictions += 1;
}
return false;
}
if (state.keys.has(key)) {
if (state.policy === "lru") {
state.keys.delete(key);
state.keys.set(key, true);
}
state.hits += 1;
return true;
}
state.keys.set(key, true);
if (state.keys.size > state.capacity) {
const oldest = state.keys.keys().next();
if (!oldest.done) state.keys.delete(oldest.value);
state.evictions += 1;
}
return false;
}
/** 单层容量实验:同一访问序列在不同容量与淘汰策略下的命中。 */
export function simulateLayer(
accesses: readonly CacheAccess[],
capacity: number,
policy: EvictionPolicy,
): LayerStats {
checkAccesses(accesses);
if (policy !== "lru" && policy !== "lfu" && policy !== "fifo") {
fail(`未知淘汰策略 ${String(policy)}`);
}
const state = createState(capacity, policy, false);
for (const access of accesses) lookup(state, access);
return { capacity, hits: state.hits, evictions: state.evictions };
}
/** 一层缓存的配置。semantic 为真时按 cluster 判定,且允许配置误命中率。 */
export interface TierConfig {
name: string;
capacity: number;
policy: EvictionPolicy;
lookupMicros: number;
serveMicros: number;
falseHitRate: number;
falseHitCostMicros: number;
semantic?: boolean;
}
export interface TierReport {
name: string;
capacity: number;
hits: number;
lookups: number;
hitRate: number;
lookupMicros: number;
serveMicros: number;
expectedFalseHits: number;
falseHitMicros: number;
}
export interface LayeredReport {
requests: number;
baselineMicros: number;
totalMicros: number;
netBenefitMicros: number;
netPerRequestMicros: number;
costReduction: number;
generated: number;
expectedFalseHits: number;
falseHitShare: number;
lookupPerRequestMicros: number;
tiers: TierReport[];
}
function checkTier(tier: TierConfig): void {
if (tier.name.trim() === "") fail("tier.name 不能为空");
if (!Number.isInteger(tier.capacity) || tier.capacity < 0) fail("capacity 必须是非负整数");
if (!Number.isInteger(tier.lookupMicros) || tier.lookupMicros < 0) {
fail(`${tier.name}: lookupMicros 必须是非负整数`);
}
if (!Number.isInteger(tier.serveMicros) || tier.serveMicros < 0) {
fail(`${tier.name}: serveMicros 必须是非负整数`);
}
if (!(tier.falseHitRate >= 0 && tier.falseHitRate <= 1)) {
fail(`${tier.name}: falseHitRate 必须落在 [0, 1]`);
}
if (!Number.isInteger(tier.falseHitCostMicros) || tier.falseHitCostMicros < 0) {
fail(`${tier.name}: falseHitCostMicros 必须是非负整数`);
}
}
/**
* 分层模拟:请求按顺序穿过每一层,任何一层命中就停止;
* 只有上一层未命中的请求才会进入下一层,避免用全量流量高估下层命中。
*/
export function simulateLayered(
accesses: readonly CacheAccess[],
tiers: readonly TierConfig[],
generationMicros: number,
): LayeredReport {
checkAccesses(accesses);
if (!Number.isInteger(generationMicros) || generationMicros < 0) {
fail("generationMicros 必须是非负整数");
}
for (const tier of tiers) checkTier(tier);
const states = tiers.map((tier) =>
createState(tier.capacity, tier.policy, tier.semantic === true),
);
let totalMicros = 0;
let lookupChargedMicros = 0;
let generated = 0;
for (const access of accesses) {
let served = false;
for (let level = 0; level < tiers.length; level += 1) {
const tier = tiers[level];
totalMicros += tier.lookupMicros;
lookupChargedMicros += tier.lookupMicros;
if (!lookup(states[level], access)) continue;
totalMicros += tier.serveMicros;
if (tier.falseHitRate > 0) {
totalMicros += Math.round(tier.falseHitRate * tier.falseHitCostMicros);
}
served = true;
break;
}
if (!served) {
totalMicros += generationMicros;
generated += 1;
}
}
const tiersReport: TierReport[] = tiers.map((tier, index) => {
const state = states[index];
const expectedFalseHits = state.hits * tier.falseHitRate;
return {
name: tier.name,
capacity: tier.capacity,
hits: state.hits,
lookups: state.lookups,
hitRate: state.hits / accesses.length,
lookupMicros: state.lookups * tier.lookupMicros,
serveMicros: state.hits * tier.serveMicros,
expectedFalseHits,
falseHitMicros: Math.round(expectedFalseHits * tier.falseHitCostMicros),
};
});
const baselineMicros = accesses.length * generationMicros;
const expectedFalseHits = tiersReport.reduce((sum, tier) => sum + tier.expectedFalseHits, 0);
return {
requests: accesses.length,
baselineMicros,
totalMicros,
netBenefitMicros: baselineMicros - totalMicros,
netPerRequestMicros: (baselineMicros - totalMicros) / accesses.length,
costReduction: baselineMicros === 0 ? 0 : (baselineMicros - totalMicros) / baselineMicros,
generated,
expectedFalseHits,
falseHitShare: expectedFalseHits / accesses.length,
lookupPerRequestMicros: lookupChargedMicros / accesses.length,
tiers: tiersReport,
};
}
simulateLayer 只做容量实验,simulateLayered 才把查找与服务成本算进来。两者分开,是为了避免「命中率实验」被成本参数污染;容量曲线应该在成本假设变化时保持不变。
分层模拟里,下层只看见上层的未命中,且命中的结果不回填上层。回填能提高上层命中,但会带来额外的写放大;是否回填应当作为单独的策略评估,而不是默认打开。
测试与运行结果
测试覆盖容量曲线、三种淘汰策略、分层净收益、语义层成本、下层流量口径与非法输入。执行:
node --test test/perf/cache-tiers.test.ts
本机一次运行的真实输出摘要如下,耗时随机器变化:
ℹ tests 6
ℹ pass 6
ℹ fail 0
ℹ duration_ms 145.918541
访问序列由固定种子的线性同余发生器生成,同样的代码在任何机器上得到同一组命中数。示例里的 120 个键与 1.8 次幂倾斜是刻意构造的:它让容量曲线出现明显的拐点,便于观察边际收益,而不是声称它代表某个真实业务的分布。
小结
缓存容量要看边际:每多一条能多几次命中,而不是命中率还能涨几个点。示例里 64→128 的边际命中只有 32→64 的一半。淘汰策略要按自己的访问序列实测,LFU 在倾斜序列上领先,但冷启动与旧热点是它的代价。
分层是一笔追加投资:本地最便宜但容量小,共享贵一些但能跨实例复用,语义最贵且带误命中成本。按能力算增量净收益,chat.reply 停在本地+共享,search.summary 加语义,ticket.classify 不加,才是这张账的结论。
下一节讨论这些层怎样保持一致:版本推进、租户隔离、误命中审计与回退,都会改变本节算出的净收益。
阅读导航:上一节:5.1 结果缓存的经济性判据 · 下一节:5.3 缓存失效与一致性 。
常见错误与边界
- 认为容量翻倍命中率就翻倍。工作集装下之后,新增容量的边际命中会快速下降。
- 只看命中率选淘汰策略。还要看冷启动、旧热点残留与实现复杂度。
- 给所有能力都加语义层。低价值能力多出的命中覆盖不了查找与误命中成本。
- 用全量流量估算下层命中。下层只看见上层的未命中,全量口径会高估收益。
- 把示例参数当成厂商报价。查找与服务的量级来自样本假设,真实价格要按账单填。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。