← 中文资源

简体中文版本

负缓存与正缓存

负缓存存储查询结果不存在以避免重复的昂贵未命中,但在缺失项可能很快出现时必须快速过期以防返回假缺失。

阅读英文原文

概念概述

负缓存与正缓存(Negative Versus Positive Caching)指的是:负缓存存储查询结果不存在以避免重复的昂贵未命中,但在缺失项可能很快出现时必须快速过期以防返回假缺失。

从更严格的定义看,Negative caching is the practice of caching a not-found or empty result, in contrast to positive caching which stores the presence of a value, so repeated misses become cheap. 该概念在工程实践中的价值,在于它能帮助团队用统一的语言描述一个具体的质量属性或设计约束,从而在评审与协作中减少歧义。

为什么需要它

It protects origins from repeated lookups of identifiers that do not exist, such as a missing user or a deleted resource. 它与 Cache Preloading and Warming、Distributed Caching、Write-Behind Caching 等概念经常一起出现,用于解决相近的工程问题。理解它的适用场景,有助于在多种备选方案之间做出有依据的取舍,而不是凭直觉选择技术方案。

常见误解

  • negative cache entries should live as long as positive ones
  • a negative cache cannot cause incorrect behaviour

需要避免的反模式

  • caching not-found responses for the same duration as hits
  • using negative caching for values that are created shortly after first lookup

实践要点

  1. A single lookup of a nonexistent key can be as expensive as a real lookup, so caching the miss prevents an attacker from forcing repeated expensive queries.
  2. The risk is a false absence: if a resource is created moments after a miss was cached, readers will keep seeing not-found until the entry expires.
  3. Keep negative entries short-lived and, where the workflow allows, invalidate the negative entry explicitly when the underlying record is created.

参考来源

https://www.rfc-editor.org/rfc/rfc2308

代码示例

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);