概念定义
缓存总是成批填充——部署、预热、流量尖峰。固定 TTL 会把填充突发变成到期突发:同一批一千个键在同一秒过期、一起 miss,源站看到的是自己亲手制造的洪峰。
工作机制
每次写入计算 TTL × (1 + 均匀随机分数) 并封顶乘数,到期便在整个窗口内近似均匀摊开。抖动可以复合:多层缓存各自加抖动,能防止整条栈上对齐的到期级联。
使用场景
一切成批写入的缓存:预热循环、部署后的 CDN 刷新、定时任务填充的应用缓存。抖动保持比例适当——够摊开、又不至于让容量规划失去可预测性。
常见误区
抖动解决的是“多键同步”而非“单键热”——热点键仍需合并或 SWR;平均陈旧度期望仍是 TTL 本身,抖动只重分布到期时刻。抖动必须发生在写方的写入时刻,读侧补加只影响新条目;范围过大会让到期分布失去可预测性。动态响应完全可以按请求计算随机化的 Cache-Control max-age。
代码示例
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;
}
import { LRUCache } from 'lru-cache';
const cache = new LRUCache({
max: 500,
ttl: 60_000,
staleTtl: 300_000,
allowStale: true,
fetchMethod: async (key) => {
const res = await fetch('https://api.example.com/rates/' + key);
if (!res.ok) throw new Error('upstream ' + res.status);
return res.json();
},
});
const inflight = new Map();
async function getRate(currency) {
const value = await cache.fetch(currency);
return value;
}
async function revalidate(key, loader) {
if (inflight.has(key)) return inflight.get(key);
const task = loader()
.then((fresh) => { cache.set(key, fresh); return fresh; })
.finally(() => { inflight.delete(key); });
inflight.set(key, task);
return task;
}
const usd = await getRate('usd');
const again = await getRate('usd');
console.log('rate:', usd.rate, 'second read:', again.rate);