精确缓存只能消掉已经写进去的命中。缓存失效或冷启动时,同一句提示词会 concurrent 打穿上游:重复计费,也容易把限额打满。缓存键怎么设计、未命中如何合并,是两层问题;后者不该靠再往 Redis 里加一个 TTL 解决。下面用 Node.js 把进程内单飞、跨实例短锁合并、按租户隔离的等待队列,以及超时、失败穿透和负缓存一次做完。

击穿发生在未命中这一层

精确匹配缓存的职责很窄:键相同则直接返回。它解决不了「键相同但值还不在」的窗口。模型调用延迟往往是几百毫秒到数秒,这段时间里网关、重试、前端连点都会把同一提示词打成一排并发。每一个未命中都去调模型,就是缓存击穿(stampede):钱花多份,RPM/TPM 也一起被打满。

所以实现上要拆两层,不要揉进一个「再设短一点的 TTL」。

第一层是缓存键。键必须稳定、可复现,通常至少包含:模型名、采样参数(温度、max tokens 等会影响输出的字段)、租户、以及规范化后的消息列表。不要把「当前时间」「请求 id」放进键;也不要直接用超长原文当 Redis key,用哈希即可。

第二层是未命中合并。同一键在飞行中只允许一个 leader 去打上游,其余 waiter 挂在同一份 Promise(进程内)或同一把短锁(跨实例)上。合并的是「这一次计算」,不是「把 TTL 设短一点盼着别撞车」。

下面这段只负责键,不负责合并,可以单独测:

const crypto = require("crypto");

function normalizeMessages(messages) {
  return messages.map((m) => ({
    role: m.role,
    content: String(m.content).trim(),
  }));
}

function llmCacheKey({ tenantId, model, temperature, maxTokens, messages }) {
  const payload = JSON.stringify({
    v: 1,
    tenantId,
    model,
    temperature: temperature ?? 0,
    maxTokens: maxTokens ?? null,
    messages: normalizeMessages(messages),
  });
  const hash = crypto.createHash("sha256").update(payload).digest("hex");
  return `llm:v1:${tenantId}:${hash}`;
}

module.exports = { llmCacheKey, normalizeMessages };

键层可以换存储、换哈希,不必改合并层。反过来,合并层只认字符串 key,不解析提示词。

进程内 Singleflight:同一 key 只飞一次

单进程里,最干净的做法是 inflight Map:第一个请求把 Promise 放进去,后来者直接 await 同一份。无论成功失败,finally 里删掉,避免把错误结果永久钉在 Map 上(那是负缓存的职责,下一节再谈)。

下面是一份可直接 node singleflight.js 跑的示例,上游用 mock,方便看合并效果:

class Singleflight {
  constructor() {
    this.inflight = new Map();
  }

  do(key, fn) {
    const hit = this.inflight.get(key);
    if (hit) return hit;

    const p = Promise.resolve()
      .then(fn)
      .finally(() => {
        this.inflight.delete(key);
      });

    this.inflight.set(key, p);
    return p;
  }
}

class MemoryCache {
  constructor() {
    this.store = new Map();
  }

  get(key) {
    const row = this.store.get(key);
    if (!row) return undefined;
    if (row.expireAt !== 0 && Date.now() > row.expireAt) {
      this.store.delete(key);
      return undefined;
    }
    return row.value;
  }

  set(key, value, ttlMs) {
    const expireAt = ttlMs > 0 ? Date.now() + ttlMs : 0;
    this.store.set(key, { value, expireAt });
  }
}

async function mockModel(prompt) {
  await new Promise((r) => setTimeout(r, 80));
  mockModel.calls += 1;
  return { text: `echo:${prompt}`, calls: mockModel.calls };
}
mockModel.calls = 0;

async function main() {
  const sf = new Singleflight();
  const cache = new MemoryCache();
  const key = "llm:v1:demo:abc";

  const load = async () => {
    const cached = cache.get(key);
    if (cached !== undefined) return cached;
    return sf.do(key, async () => {
      const again = cache.get(key);
      if (again !== undefined) return again;
      const result = await mockModel("hello");
      cache.set(key, result, 60_000);
      return result;
    });
  };

  const results = await Promise.all([load(), load(), load()]);
  console.log(results);
  console.log("upstream calls:", mockModel.calls);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

这里有两个容易漏的点。一是 leader 进 inflight 之后、写缓存之前,waiter 必须复用同一 Promise,而不是再查一次空缓存就放行。二是 do 内部再读一次缓存,避免 leader 刚写完、inflight 已删、并发尾巴又打一次上游。进程内这一层不引入 Redis,也不需要分布式锁。

超时用 AbortController 包在 fn 里即可,不要把超时逻辑写进 Singleflight 本身,否则所有 key 只能共用一个超时策略。Node.js 18+ 里这是标准 API:

function withTimeout(fn, ms, signal) {
  const ac = new AbortController();
  const onAbort = () => ac.abort();
  if (signal) {
    if (signal.aborted) ac.abort();
    else signal.addEventListener("abort", onAbort, { once: true });
  }
  const timer = setTimeout(() => ac.abort(), ms);
  return fn(ac.signal).finally(() => {
    clearTimeout(timer);
    if (signal) signal.removeEventListener("abort", onAbort);
  });
}

fn 调上游时把 signal 传给 fetch。超时后 inflight 仍会在 finally 清理,后续请求可以再飞,而不是一直挂死。

跨实例:短锁合并,不是再加一层 TTL

多副本时进程内 Map 互相看不见。常见错法是给业务缓存加一个更短的 TTL,指望错开失效。TTL 解决的是「能活多久」,不解决「同一毫秒谁去填洞」。跨实例要的是:同一个 cache key,同一时刻只有一个 leader 调模型。

用 Redis 的 SET key value NX EX 做短锁就够。这是 Redis 本身的原子命令,不要自己 GET 再 SET。leader 拿到锁后调模型、写缓存、再删锁;没拿到锁的实例轮询缓存,直到命中、锁消失并允许重试,或等到超时。

下面假设已安装 ioredis(API 为 set(key, value, 'EX', seconds, 'NX')),缓存值和锁分开两个 key:

const Redis = require("ioredis");

function sleep(ms) {
  return new Promise((r) => setTimeout(r, ms));
}

async function loadMerged(redis, { cacheKey, lockKey, ttlSec, lockSec, waitMs, pollMs, load }) {
  const cached = await redis.get(cacheKey);
  if (cached !== null) return JSON.parse(cached);

  const token = `${process.pid}:${Date.now()}:${Math.random()}`;
  const locked = await redis.set(lockKey, token, "EX", lockSec, "NX");

  if (locked === "OK") {
    try {
      const again = await redis.get(cacheKey);
      if (again !== null) return JSON.parse(again);
      const value = await load();
      await redis.set(cacheKey, JSON.stringify(value), "EX", ttlSec);
      return value;
    } finally {
      const current = await redis.get(lockKey);
      if (current === token) await redis.del(lockKey);
    }
  }

  const deadline = Date.now() + waitMs;
  while (Date.now() < deadline) {
    const hit = await redis.get(cacheKey);
    if (hit !== null) return JSON.parse(hit);
    const lockAlive = await redis.exists(lockKey);
    if (!lockAlive) {
      return loadMerged(redis, {
        cacheKey,
        lockKey,
        ttlSec,
        lockSec,
        waitMs: deadline - Date.now(),
        pollMs,
        load,
      });
    }
    await sleep(pollMs);
  }

  const err = new Error("coalesce_timeout");
  err.code = "COALESCE_TIMEOUT";
  throw err;
}

async function main() {
  const redis = new Redis(process.env.REDIS_URL || "redis://127.0.0.1:6379");
  const out = await loadMerged(redis, {
    cacheKey: "llm:v1:demo:abc",
    lockKey: "llm:lock:v1:demo:abc",
    ttlSec: 600,
    lockSec: 30,
    waitMs: 10_000,
    pollMs: 50,
    load: async () => ({ text: "from-upstream", t: Date.now() }),
  });
  console.log(out);
  await redis.quit();
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});

几点克制的判断。锁的 TTL(lockSec)应明显大于一次模型调用的 P99,否则 leader 还在跑,锁已经过期,第二个实例会再飞一趟——短锁是为了崩溃时能恢复,不是为了「尽快并发」。删锁前核对 token,避免误删别人的新锁。轮询间隔 50ms 量级通常够用;Pub/Sub 能少几次 GET,但要处理丢消息,不一定更简单。

注意:业务缓存的 TTL 仍然按「结果能复用多久」来设。锁 key 的 TTL 只覆盖这一次填充。两件事不要合成一个 key。

按租户隔离等待队列

单飞如果做成全局一张 Map,大租户一次击穿会让小租户的 waiter 挤在同一条逻辑队列里,排查时还分不清是谁把 inflight 撑大的。隔离很直接:flight 按 tenantId 分桶,信号量也按租户限制「同时当 leader 的数量」。

class Semaphore {
  constructor(max) {
    this.max = max;
    this.active = 0;
    this.q = [];
  }

  acquire() {
    if (this.active < this.max) {
      this.active += 1;
      return Promise.resolve();
    }
    return new Promise((resolve) => this.q.push(resolve));
  }

  release() {
    const next = this.q.shift();
    if (next) next();
    else this.active -= 1;
  }
}

class TenantFlights {
  constructor({ maxLeadersPerTenant = 8 } = {}) {
    this.maxLeadersPerTenant = maxLeadersPerTenant;
    this.flights = new Map();
    this.semas = new Map();
  }

  flight(tenantId) {
    let sf = this.flights.get(tenantId);
    if (!sf) {
      sf = new Singleflight();
      this.flights.set(tenantId, sf);
    }
    return sf;
  }

  sema(tenantId) {
    let s = this.semas.get(tenantId);
    if (!s) {
      s = new Semaphore(this.maxLeadersPerTenant);
      this.semas.set(tenantId, s);
    }
    return s;
  }

  async do(tenantId, key, fn) {
    const sema = this.sema(tenantId);
    return this.flight(tenantId).do(key, async () => {
      await sema.acquire();
      try {
        return await fn();
      } finally {
        sema.release();
      }
    });
  }
}

缓存键里已经有 tenantId 时,不同租户天然不会合并——这是对的:租户 A 的命中不该直接给租户 B。单飞的 key 必须带租户前缀,避免「提示词相同就共用一次模型调用」在多租户下串数据。信号量限制的是每个租户的 leader 数,不是全局 QPS;全局限额仍应放在上游客户端或网关。

跨实例时,锁 key 同样带租户:llm:lock:{tenantId}:{hash}。这样租户 A 的短锁过期或排队,不会挡住租户 B 去拿自己的锁。

超时、失败穿透与负缓存

合并之后,leader 的结果就是 waiter 的结果。这要求你明确三类失败,不要一律 setex。

第一类是超时和可重试错误(网络断开、429、5xx)。这类不要写业务缓存,inflight/锁清掉后允许下一波再飞,也就是失败穿透。若把 429 当成成功缓存 10 分钟,所有租户会在窗口内一直吃到同一句过期错误。

第二类是确定不会因为「再试一次」而变的结果:参数非法、模型名不存在、明确的内容拒绝。这类适合负缓存,TTL 短、键与正缓存分开或带 neg: 前缀,避免和正常回答混在一个 JSON 里却用同一长 TTL。

第三类是部分成功(流式已经吐了一截又断)。进程内不要把半截流当缓存值;等完整结果再写。流式场景下,waiter 要么等完整 Promise,要么自己开一条新流,不要把同一个 iterator 分给多个请求。

负缓存和正缓存可以共用合并层,只是 load 里分支不同:

function classifyError(err) {
  const status = err.status || err.statusCode;
  if (status === 400 || status === 404 || status === 422) return "negative";
  return "passthrough";
}

async function loadOrNegative({ cache, sf, key, negKey, load, posTtlMs, negTtlMs }) {
  const pos = cache.get(key);
  if (pos !== undefined) return pos;
  const neg = cache.get(negKey);
  if (neg !== undefined) throw Object.assign(new Error(neg.message), { cached: true });

  return sf.do(key, async () => {
    try {
      const value = await load();
      cache.set(key, value, posTtlMs);
      return value;
    } catch (err) {
      if (classifyError(err) === "negative") {
        cache.set(negKey, { message: err.message }, negTtlMs);
      }
      throw err;
    }
  });
}

跨实例时同样:正缓存 EX 按业务;负缓存另一个 key、更短的 EX;429 不写。锁始终只保护「正在 load」,不要把锁 TTL 当成负缓存 TTL。

总结

精确缓存负责「命中怎么返回」,单飞和短锁负责「未命中谁去打模型」。进程内用 inflight Promise,跨实例用 SET NX EX 短锁加缓存轮询,租户用分桶的 flight 和 leader 信号量隔开。超时与 5xx 让失败穿透,确定失败才负缓存。缓存键怎么设计、未命中如何合并,分成两层之后,就不必再靠把 TTL 调来调去赌并发窗口。