← 中文资源

简体中文版本

负缓存

负缓存为“未找到”与错误响应存一个短 TTL,吸收对不存在键的重复查找,保护源站免受 miss 风暴。

阅读英文原文

概念定义

缓存通常只存成功结果,对不存在键的查询会完全绕过它——随机用户名扫描或爬虫探测就直接砸向源站。负缓存堵上这个洞:“不存在”成为一种可缓存、会过期的事实。

工作机制

边缘或应用层把 404 或空结果以短 TTL 存储,与正向 TTL 区分;条目创建后翻转:TTL 过后的下一次查找未命中,重新缓存成功。墓碑是写侧等价物——被删实体缓存其“不存在”,重复读取不再触库。

使用场景

用户名与 slug 查找、vanity URL 与短链解析、对不存在资源的权限校验、以及被机器人随机标识符清扫的端点。

常见误区

负缓存不等于白费——miss 流量本身就是问题。TTL 要对照创建率:秒级创建的名字配 60 秒负 TTL 会折磨创建者。404 与 410 要区分:永久缺席才配更长缓存;5xx 与授权拒绝绝不能负缓存——一次失败不该变成一分钟的封锁,删墓碑也不过期会挡住合法重建。

代码示例

export interface CacheLookup<V> {
  hit: boolean;
  value?: V;
}

interface CacheEntry<V> {
  value: V | null;
  expiresAt: number;
}

export class NegativeCache<K, V> {
  private readonly entries = new Map<K, CacheEntry<V>>();
  private readonly ttlMs: number;
  private readonly now: () => number;

  constructor(ttlMs: number, now: () => number = () => Date.now()) {
    if (!Number.isFinite(ttlMs) || ttlMs <= 0) {
      throw new RangeError("ttlMs must be a positive finite number");
    }
    if (typeof now !== "function") throw new TypeError("now must be a function");
    this.ttlMs = ttlMs;
    this.now = now;
  }

  get(key: K): CacheLookup<V> {
    const entry = this.entries.get(key);
    if (!entry) return { hit: false };
    if (this.now() >= entry.expiresAt) {
      this.entries.delete(key);
      return { hit: false };
    }
    if (entry.value === null) return { hit: true };
    return { hit: true, value: entry.value };
  }

  setMiss(key: K): void {
    this.entries.set(key, { value: null, expiresAt: this.now() + this.ttlMs });
  }

  setValue(key: K, value: V): void {
    if (value === null) throw new TypeError("value must not be null; use setMiss");
    this.entries.set(key, { value, expiresAt: this.now() + this.ttlMs });
  }

  delete(key: K): boolean {
    return this.entries.delete(key);
  }

  get size(): number {
    return this.entries.size;
  }
}

export async function withNegativeCache<K, V>(
  cache: NegativeCache<K, V>,
  key: K,
  loader: () => Promise<V | null>,
): Promise<V | null> {
  if (typeof loader !== "function") throw new TypeError("loader must be a function");
  const cached = cache.get(key);
  if (cached.hit) return cached.value ?? null;
  const value = await loader();
  if (value === null) cache.setMiss(key);
  else cache.setValue(key, value);
  return value;
}
interface CacheEntry<T> {
  value: T;
  expiresAt: number;
}

function Cached<T>(ttlMs: number) {
  return function (
    target: object,
    propertyKey: string,
    descriptor: TypedPropertyDescriptor<(...args: never[]) => Promise<T>>,
  ) {
    const original = descriptor.value!;
    const store = new Map<string, CacheEntry<T>>();

    descriptor.value = async function (this: unknown, ...args: never[]) {
      const key = propertyKey + ':' + JSON.stringify(args);
      const hit = store.get(key);
      const now = Date.now();

      if (hit && hit.expiresAt > now) {
        (this as { __cacheHits?: number }).__cacheHits ??= 0;
        return hit.value;
      }

      const value = await original.apply(this, args);
      store.set(key, { value, expiresAt: now + ttlMs });
      return value;
    };

    return descriptor;
  };
}

class ExchangeRates {
  private hits = 0;

  @Cached(30_000)
  async ratesFor(base: string): Promise<Record<string, number>> {
    const res = await fetch('https://api.example.com/rates?base=' + base);
    return res.json();
  }

  @Cached(300_000)
  async currencies(): Promise<string[]> {
    return ['USD', 'EUR', 'JPY'];
  }
}

const rates = new ExchangeRates();
const first = await rates.ratesFor('USD');
const second = await rates.ratesFor('USD');
console.log('cached identity:', first === second);