← 中文资源

简体中文版本

缓存击穿

缓存击穿发生在热点键过期时,大量并发请求同时未命中,把重复负载一起打向源站。

阅读英文原文

一次未命中,多次重算

想象一个缓存 60 秒的首页片段,每秒被请求 5,000 次。过期瞬间,在第一次重算完成前到达的每个请求都未命中。如果重算需要 300 毫秒,数百个请求可能同时启动同一个查询。源站承受的尖峰完全由缓存正常工作引起。

解决办法是确保只有一个调用方重算缺失键,其他调用方等待结果、接收过期值,或收到短暂重试信号。这些选择在新鲜度和延迟上有不同权衡。

合并并发未命中

Single-flight 把同一键的并发调用合并为一次进行中的操作:

export function createCoalescer<T>() {
  const inFlight = new Map<string, Promise<T>>();

  return function load(key: string, compute: () => Promise<T>): Promise<T> {
    const existing = inFlight.get(key);
    if (existing) return existing;

    const promise = compute().finally(() => inFlight.delete(key));
    inFlight.set(key, promise);
    return promise;
  };
}

每个调用方拿到同一个 Promise 和同一个结果或错误。在 finally 中删除条目,使之后的未命中可以重试。单进程内这就足够;跨进程需要使用带超时的分布式租约,并确保持有者不会永久卡住。

过期时继续服务

产品能容忍短暂陈旧时,返回缓存值并在后台刷新。过期后的第一个请求触发刷新但不等待,后续请求继续收到旧值,直到刷新完成。添加新鲜度响应头或较短的客户端 TTL,避免下游把陈旧数据当作永久新鲜。

TTL 抖动可以降低许多键同时过期的概率。固定 60 秒改为 60 秒加随机偏移。这不能解决单个热点键,但能避免整个缓存同步失效。

保护源站

即使有合并,部署或重启后的冷缓存仍可能把许多不同键打向源站。在源站边界增加并发限制,在接流量前预热关键键,并对重复的未找到查询做负缓存。如果源站失败,不要删除键后立即重试;应退避,并在策略允许时继续提供旧值。

监控缓存命中率、未命中延迟、未命中期间的源站 QPS 和合并请求数。击穿表现为未命中率骤升并伴随重复源站查询。通过在高负载下使热点键过期并确认源站只收到一个请求来测试。

何时使用

当每个调用方都需要新鲜值且重复工作昂贵时,使用 single-flight 合并。当产品容忍有界陈旧且延迟比立即新鲜更重要时,使用 stale-while-revalidate。后者的优点是没有调用方等待源站;缺点是缺陷可能让旧数据服务时间超出预期。与分布式锁相比,请求合并更简单,但只覆盖一个进程;跨进程保护需要租约或协调缓存库。