《互联网产品的 AI 架构·推理性能与成本工程》5.2 缓存分层与容量

缓存从 64 条扩到 128 条,命中只从 50% 涨到 60%,每多 64 条容量的边际命中已从 45 次降到 24 次。本节用容量曲线、LRU/LFU/FIFO 对比与分层查找账,说明本地、共享、语义缓存各该放多少容量,以及怎样按能力选层。

扩容申请被一张曲线拦下

「回声」的缓存命中率停在 50%,团队申请把容量从 64 条翻倍到 128 条,理由很直接:容量翻倍,命中率至少该涨一大截。

扩容后命中率到 60%,只多了 10 个百分点。更尴尬的是,另一个团队给 ticket.classify 加了语义缓存,命中率涨了 2 个百分点,月度净收益反而少了 534 微元。两件事指向同一个问题:容量与层数都不是「越多越好」,它们各自有边际收益,也有各自的查找与服务成本。本节用同一套确定性访问序列,把容量、淘汰策略与分层放进取舍表;文中命中数、比例与微元均来自 ai-lab 的示例样本运行结果,不是生产实测。

扩容一倍的收益不是一倍

先看单层容量曲线。240 次访问、120 个键、按 1.8 次幂倾斜的示例序列上,LRU 的结果是:

容量命中命中率淘汰相比上一档新增命中
82610.83%206—
164317.92%181+17
327531.25%133+32
6412050.00%56+45
12814460.00%0+24

从 64 到 128,容量翻倍,新增命中只有 24 次;而 32 到 64 新增 45 次。原因不是「容量没用」,而是 128 条时 120 个键已全部装下、淘汰为 0;命中率的上限由重复率决定,只有 60%,继续扩容不能再提高它。

判断该不该扩容,看的是「每多一条容量能多几次命中」,不是「命中率还能涨多少」。示例里 32→64 每条约 0.70 次新增命中,64→128 只有 0.375 次;后一档的存储成本相同,收益却减半。

容量上限与淘汰策略

容量相同,淘汰策略也会改变命中。示例序列上的对比:

容量FIFOLRULFU
1640(16.67%)43(17.92%)56(23.33%)
3268(28.33%)75(31.25%)82(34.17%)

LFU 在这条倾斜序列上最好,因为它把容量留给高频键,不会被一次性长尾挤出。但 LFU 有两个代价:一是新键要先积累计数,冷启动阶段几乎不命中;二是旧热点降温后仍占着位置,需要额外的衰减或过期机制。LRU 对突发流量更稳,FIFO 最便宜但完全不看复用。

没有一种策略在所有序列上最优。可执行的做法是:用自己能力最近一段访问序列,把三种策略各跑一遍,选命中最高且实现成本可接受的那个;不要因为「LFU 更聪明」就直接上 LFU。

三层缓存放在哪

容量不是唯一维度,层的位置决定了每次查找的价格。

层判定方式示例容量查找成本命中服务适合放什么
本地进程内精确键161 微元2 微元极热、可容忍实例间不一致
共享跨实例精确键1284 微元6 微元跨实例复用、需要统一失效
语义意图簇相似1289 微元10 微元高价值、误命中可承受

数字是示例参数,不是厂商报价;重要的是量级:语义层的查找比本地贵九倍,命中服务贵五倍,还多一项误命中成本。它必须换来足够多的额外命中,才值得放在链路末端。

分层查找的账

把三种层按「本地→共享→语义」串联,请求在任一层命中就停止;只有未命中的请求才进入下一层。三个能力的净收益是:

能力无缓存仅本地本地+共享再加语义语义层增量
chat.reply09,73026,54026,830+290
search.summary06,76628,93434,654+5,720
ticket.classify0432972438−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 缓存失效与一致性 。

常见错误与边界

  • 认为容量翻倍命中率就翻倍。工作集装下之后,新增容量的边际命中会快速下降。
  • 只看命中率选淘汰策略。还要看冷启动、旧热点残留与实现复杂度。
  • 给所有能力都加语义层。低价值能力多出的命中覆盖不了查找与误命中成本。
  • 用全量流量估算下层命中。下层只看见上层的未命中,全量口径会高估收益。
  • 把示例参数当成厂商报价。查找与服务的量级来自样本假设,真实价格要按账单填。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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