概念定义
缓存通常只存成功结果,对不存在键的查询会完全绕过它——随机用户名扫描或爬虫探测就直接砸向源站。负缓存堵上这个洞:“不存在”成为一种可缓存、会过期的事实。
工作机制
边缘或应用层把 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);