2020 年做内容页 SSR 时,我们给页面数据加了 60 秒缓存。平均响应时间立刻下降,压测曲线却每隔一分钟出现一次整齐尖峰:缓存到期的瞬间,几十个并发请求都发现 miss,同时查询接口、拼装页面并写缓存。缓存本来为了保护上游,却把均匀流量压成周期性风暴。

这类问题后来我习惯叫“缓存击穿”或 stampede。关键不是把 TTL 调长,而是规定缓存过期时谁负责刷新,其他请求得到什么。

缓存过期后一个请求回源,其他请求复用旧值的时序

图 1:数据过期不等于立刻不可用;短时间返回 stale 值,能把刷新成本集中到一个请求。

把缓存状态从有/无变成三段

type CacheEntry<T> = {
  value: T;
  freshUntil: number;
  staleUntil: number;
};
 
type CacheResult<T> =
  | { state: "fresh"; value: T }
  | { state: "stale"; value: T }
  | { state: "miss" };

freshUntil 之前直接返回;进入 stale 窗口后仍可返回旧值,同时后台刷新;超过 staleUntil 才是真正 miss。对新闻内容页,晚几十秒通常比所有用户一起等待更可接受,但库存和权限数据不能照搬这套策略。

单进程先做 single flight

const refreshes = new Map<string, Promise<PageData>>();
 
async function refreshOnce(key: string) {
  const existing = refreshes.get(key);
  if (existing) return existing;
 
  const promise = loadAndCache(key).finally(() => refreshes.delete(key));
  refreshes.set(key, promise);
  return promise;
}
 
async function getPage(key: string): Promise<PageData> {
  const cached = await cache.read<PageData>(key);
  if (cached.state === "fresh") return cached.value;
 
  if (cached.state === "stale") {
    void refreshOnce(key).catch(error => logger.warn("refresh_failed", { key, error }));
    return cached.value;
  }
 
  return refreshOnce(key);
}

这只能合并同一个 Node.js 进程里的请求。多实例部署需要共享锁、CDN 层请求合并,或者让缓存刷新成为独立任务。代码示例如果不写适用范围,很容易制造“已经全局解决”的错觉。

分布式锁要考虑持有者死掉

共享锁至少需要唯一 token 和租约时间。释放时只能删除自己持有的锁,不能简单 DEL key

SET refresh:page:42 <token> NX PX 10000

如果刷新耗时可能超过租约,要么续租,要么让写缓存带版本比较,防止旧刷新覆盖新结果。锁本身故障时系统也要有选择:返回 stale、有限等待,还是降级为直接回源。不能让缓存锁成为比上游更脆弱的单点。

TTL 加随机抖动,避免批量同刻失效

一次发布可能同时写入大量缓存,如果 TTL 都是 3600 秒,它们会在一小时后集体过期。我们把非关键缓存加入正负抖动:

function ttlWithJitter(baseSeconds: number) {
  const ratio = 0.15;
  return Math.round(baseSeconds * (1 - ratio + Math.random() * ratio * 2));
}

抖动只负责摊开时间,不能替代 single flight。热门 key 即使单独过期,仍可能被大量并发击穿。

失败时保留什么,由业务价值决定

页面 stale 允许时间 刷新失败策略
公开文章 10 分钟 返回旧内容并记录年龄
首页推荐 2 分钟 旧列表 + 隐藏实时标识
用户权限 0 不使用 stale,明确失败
实时库存 很短或 0 回源或进入保护性降级

我们最终监控 fresh/stale/miss 比例、刷新耗时、锁等待、上游 QPS 和 stale 年龄。只看命中率会掩盖一个问题:命中很多旧数据也可能让用户看到错误结果。

这次事故让我理解,缓存不是一个存取 API,而是数据新鲜度、并发控制和故障策略的组合。TTL 只回答“多久以后重新考虑”,真正的系统设计发生在过期那一刻。